Generates a weekly project status update for a project tracked in a Google Sheet. Reads the live 「專案管理總表」 task tab of a project-tracker Google Sheet you configure, reasons over task status + dates against today to derive project health, blockers, this-week actions, % complete, next milestone, recent decisions and a timeline (the 「專案狀態看板」 dashboard format), renders it as a styled HTML email, and creates a Gmail DRAFT (never auto-sends) to a configurable recipient list. Trigger this skill EAGER...
Scanned 9/19/2026
Install to Claude Code
npx -y skills add peter-tu-zynkr/zynkr-skill-builder --skill project-status-update --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Project Status Update?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/peter-tu-zynkr-project-status-update)More formats (shields.io, HTML) on the badges page.
---
name: project-status-update
sheetId: "3.09"
description: Generates a weekly project status update for a project tracked in a Google Sheet. Reads the live 「專案管理總表」 task tab of a project-tracker Google Sheet you configure, reasons over task status + dates against today to derive project health, blockers, this-week actions, % complete, next milestone, recent decisions and a timeline (the 「專案狀態看板」 dashboard format), renders it as a styled HTML email, and creates a Gmail DRAFT (never auto-sends) to a configurable recipient list. Trigger this skill EAGERLY whenever the user says "週報", "專案週報", "weekly report", "project weekly update", "draft the weekly status", "更新專案狀態", "PM 進度", "進度更新", "週進度更新", "跑一下專案看板", "update the dashboard", "draft the project dashboard", "寄週報給團隊", "/project-status-update", or otherwise asks for a weekly / status / progress update or dashboard on a project — even if they don't name the sheet, name a recipient instead of the project, or just say "the weekly". This is also the skill a scheduled / recurring / cron weekly run should invoke. When in doubt about a project status or weekly-update request, prefer this skill.
category: operations
project: project-status-update
platform: claude
status: Done
author: Peter Tu
input: "A project slug resolved through ~/.config/zynkr/pm.json (tracker Sheet ID, Google account, recipients, delivery spine, health thresholds). Optionally an explicit 'as of' date."
process: "Verify pack + config → detect the 管控表 version → read the tracker by header name → bucket Status into Done/WIP/Not started/Drop (illegal literals stay counted and named) → score the configured spine → derive health → draft a Gmail HTML email, never send."
output: "A Gmail DRAFT (never auto-sent): the 專案狀態看板 weekly dashboard as styled HTML, including the dropped-task list and any Status data errors, to a configurable recipient list."
synergy: [project-planning, project-note-specialist, project-init, project-minutes-sync]
house-style: bound
---
# Weekly Project Status Update
知識來源:references/pm-knowledge-pack.md · v1 · sha256 15640433fbee
You run a project tracked in a Google Sheet. The `專案管理總表` tab is the live task list; the `專案狀態看板` tab is a hand-formatted executive dashboard. This skill closes the gap: it reads the raw tracker, does the reasoning a human would do (what's late, what's blocked, how far along), and produces the dashboard **as a weekly HTML email draft** — so you only have to skim and hit send.
```bash
npx skills add https://github.com/peter-tu-zynkr/zynkr-skill-builder --skill project-status-update
```
The output is a **draft, never a send**. This is deliberate: the skill reasons over dates and status, and a wrong「🔴 DELAYED」blasted to your recipients is worse than one you review first. Leave it in the drafts folder for review.
This skill is a member of the PM family (3.07 · 3.08 · 3.09 · 3.20 · 3.21) and its rules come from one shared pack. **The pack is the authority; this file does not restate it.** Where a rule below carries a `pack §N` citation, read the pack section before improvising. An installed skill only ever gets its own folder, so `scripts/check-pm-refs.sh` copies **five** artefacts into it byte-identically — `references/pm-knowledge-pack.md` · `references/pm-sources.md` · `references/pm-sheet-schema.json` · `references/pm-status-crosswalk.json` · `scripts/pm-schema.py`. **Every path in this file is therefore skill-folder-relative**: nothing reaches back to a repo root that will not exist at install time.
---
## Constants
> **Nothing here is a hardcoded ID — or a hardcoded recipient, or a hardcoded spine.** The tracker Sheet, the Google account, the recipient list, the delivery spine and the health thresholds are all resolved at Step 0 from the private adapter config `~/.config/zynkr/pm.json`. The placeholders below are **documentation of the shape**, never a fallback value — a placeholder that survives into a run is a fail-loud condition, not a default.
| What | Where it comes from |
|---|---|
| **Project slug** | The invocation (`/project-status-update <slug>`). With no slug and exactly one project in `projects`, use it; with more than one, ask which |
| **Spreadsheet ID** | `pm.json` → `projects.<slug>.tracker_sheet_id`. The literal `<YOUR_GOOGLE_SHEET_ID>` is documentation only — it is never read as a value |
| **Source tab** | `專案管理總表` (name is fixed; the **range is not** — Step 2 decides it) |
| **Format reference tab** | `專案狀態看板` (read-only carry-forward context; this skill never writes to the sheet) |
| **Google account** | `pm.json` → `google_account`, used for every `google-workspace` MCP call. Shape: `your-google@example.com` |
| **Recipients** | `pm.json` → `projects.<slug>.report_recipients` — a non-empty array of addresses. The `you@example.com` shapes shown in Step 7 are **documentation of the shape only**; a missing or empty key is fail-loud, never a default |
| **Delivery spine** | `pm.json` → `projects.<slug>.spine` — an ordered array of stage names. Absent ⇒ the five delivery phases `啟動 · 規劃 · 執行 · 監控 · 結案`, **with a printed warning** (Step 5) |
| **Health thresholds** | `pm.json` → `defaults.health_thresholds` (`delayed_after_days` · `at_risk_within_days` · `blocker_stale_days`). Documented defaults `0 · 3 · 7` apply only when the block is absent |
| **MCP server** | `google-workspace` — use it for **everything** (reading the sheet AND creating the draft). The separate `gmail` server is often NOT authenticated here, so don't rely on it |
| **Schema validator** | `scripts/pm-schema.py` — **inside this skill folder**, copied byte-identically by `scripts/check-pm-refs.sh`. Invoke it exactly like the renderer: skill-folder-relative, one root, no split |
| **Renderer** | `scripts/render_dashboard_email.py` (inside this skill folder) |
| **JSON contract** | `references/dashboard_schema.json` — this skill's own payload shape |
| **Validator seeds** | `references/pm-sheet-schema.json` · `references/pm-status-crosswalk.json`. Pass both explicitly (`--schema` / `--crosswalk`): the validator's built-in defaults point at a repo root an installed skill does not have |
---
## Workflow at a glance
0. Verify the knowledge pack and load the config. Stop on either mismatch.
1. Establish "today" and the current week window.
2. **Detect the 管控表 schema version** before mapping a single column.
3. Read the source tab by header name (and the dashboard tab for carry-forward context).
4. Find last week's report in Gmail → build a week-over-week diff.
5. Reason over the data → assemble the dashboard JSON.
6. Render to HTML with the script.
7. Create the Gmail draft. Report back. Do **not** send.
---
## Step 0 — Verify the pack, then the config(不通過就停)
**0a · Knowledge pack.** The line under the H1 declares which pack this file was written against. Compute the real one and compare:
```bash
shasum -a 256 references/pm-knowledge-pack.md | cut -c1-12
```
If it does not equal the declared `sha256` value, **stop**. Report: 「知識包不一致:SKILL.md 宣告 `<declared>`,實際 `<actual>` — 請執行 `scripts/check-pm-refs.sh --sync` 後重跑」. Do not fall back to the reasoning in this file: a drifted pack means the rules below may no longer be the agreed ones.
**0b · Adapter config.** Load `~/.config/zynkr/pm.json` (override with env `ZYNKR_PM_CONFIG`). Validate in this order, stopping at the first failure and naming the key path:
1. the file exists → else `config: ~/.config/zynkr/pm.json missing`
2. `projects.<slug>` exists → else `config: projects.<slug> unknown`
3. `projects.<slug>.engagement_type` is a key of `engagement_types` → else `config: projects.<slug>.engagement_type invalid`
4. `projects.<slug>.tracker_sheet_id` is present, non-null and not a `<...>` placeholder → else `config: projects.<slug>.tracker_sheet_id unset`
5. `google_account` is present → else `config: google_account unset`
6. `projects.<slug>.report_recipients` is a **non-empty array** of addresses, none of them a `<...>` placeholder or an `example.com` documentation shape → else `config: projects.<slug>.report_recipients unset`. There is no default recipient list: a weekly status mail addressed by guesswork is a disclosure bug, not a convenience
**Never guess an ID.** Not from a Drive search by title, not from「the only sheet with that tab name」, not from what the last run used. A wrong Sheet ID writes a week of one project's status into another project's report.
`defaults.health_thresholds` and `projects.<slug>.spine` are the **only** soft keys. `health_thresholds` absent ⇒ use the documented defaults and say so in the Step 8 report (「health 門檻採預設值 0/3/7」). `spine` absent ⇒ the five delivery phases plus a printed warning (Step 5). Every other key above is fail-loud.
## Step 1 — Anchor on today
Determine today's date (the date the skill runs). Everything downstream — overdue checks, "this week", timeline icons — is relative to it. The current week window is **`week_start`→+6 days containing today** (`pm.json` → `defaults.week_start`, default `MON`); format it like `6/1 - 6/7` for the THIS WEEK header. All date maths run in `defaults.timezone`.
If the user gives an explicit "as of" date, use that instead.
## Step 2 — Detect the schema version(強制,先於任何欄位對映)
There are two live shapes of `專案管理總表`: **v2** (14 columns A–N, with `前置任務 Depends on` at K) and **legacy v1** (13 columns A–M, no `前置任務`). Reading one as the other silently mis-maps every column from K rightwards — that is exactly the bug this step exists to prevent. **Detect first, map second. Never hardcode a range.** (pack §3)
Read the top of the tab and find the header row — the first row whose column A is `no.` (typically row 3; row 1 carries the core goal in col D):
```
read_sheet_values(<tracker_sheet_id>, "專案管理總表!A1:N5", <google_account>)
```
Hand that header row to the validator. **This family uses `--file` for every `pm-schema.py` call** — `--headers` / `--values` are comma-separated and would split any header or value that itself contains a comma. Write a temp file as `{"tab": "專案管理總表", "headers": [...]}`, then, **from the skill folder**:
```bash
python3 scripts/pm-schema.py headers --file <tmp>/headers.json \
--schema references/pm-sheet-schema.json \
--crosswalk references/pm-status-crosswalk.json
```
Branch on the exit code, and **report which version you found** in Step 7 either way:
| Exit | Verdict | What you do |
|---|---|---|
| `0` | `OK — tab 專案管理總表 matches the v2 shape · 14 columns · range A1:N44` | Use the **v2** map and range `專案管理總表!A1:N44` |
| `2` | `LEGACY V1 DETECTED` | Use the **v1** map and range `專案管理總表!A1:M44`, and print the warning「此專案管控表仍是 legacy v1(13 欄,無「前置任務 Depends on」)— 前置任務相依檢查本次略過,建議升級為 v2」. The run continues; it is not an error |
| `1` | `HEADER MISMATCH` | **Stop.** Paste the validator's per-column DIFF table into the report. Neither map is safe, and guessing which column drifted is how the wrong number reaches the reader (pack §9.2) |
| `3` | setup error | Stop. The validator could not load the schema/crosswalk — report the message verbatim |
**v2 column map (14 cols A–N):**
`A no.` · `B 里程碑 Stage` · `C 任務描述 Task` · `D Priority` · `E Owner` · `F Facilitator` · `G Agent` · `H Status` · `I Start (YYYY/M/D)` · `J End (YYYY/M/D)` · **`K 前置任務 Depends on`** · `L Reference 連結` · `M Note` · `N DOD 完成定義/交付物`
**legacy v1 column map (13 cols A–M):** identical up to `J`, then `K Reference 連結` · `L Note` · `M DOD 完成定義/交付物`.
Even after the version is known, **address columns by header name, not by letter** — the letter is a convenience for a human reading this table, not the lookup key. `前置任務` points at another row's `no.`; a pointer to a `no.` that does not exist is reported, not repaired (pack §4).
Row 44 is the historical extent, not a law: if the last row read is non-empty, extend the range until you hit a blank block.
## Step 3 — Read the tracker
Read the source tab with the range Step 2 chose. Structure:
- **Row 1**: core goal (col D). **Row 3**: header row.
- **Stage rows** (`X.0`, e.g. `1.0`, `2.0`): stage/milestone name in `里程碑 Stage`, the stage date-range in Start/End, the stage's Definition-of-Done in `DOD`.
- **Task rows** (`X.Y`, e.g. `2.3`): task description, owner/facilitator, `Status`, Start/End, and a `Note` that often carries the real blocker context.
### Status → the four lifecycle buckets
`Status` sits on the **lifecycle** axis, and 鐵律 3 fixes it at exactly four values — `Done` · `WIP` · `Not started` · `Drop` (pack §1, §2.1). Trim + lowercase, then bucket:
| Bucket | Accepted spellings | Canonical |
|---|---|---|
| `Done` | `done` · `完成` · `✓` · `✅` | `done` |
| `WIP` | `wip` · `in progress` · `doing` · `進行中` | `in_progress` |
| `Not started` | blank · `not started` · `未開始` · `todo` · `n/a` | `not_started` |
| `Drop` | `drop` · `dropped` · `放棄` | `dropped` |
Anything outside those four buckets is a **data error, not a fourth guess**. Two wrong ways to handle it, and they fail in opposite directions:
- Folding it into `Not started` — the old behaviour — re-labels abandoned work as pending and **inflates** the denominator.
- Deleting the row from the maths **flatters** the denominator: work you cannot classify becomes work you never had to do.
So do neither. **An illegal literal stays in the denominator, sits outside `done`, and is named in `data_errors`.** Only `Drop` ever leaves a denominator (pack §2.2 裁決二) — that is the whole content of 裁決二, and an unclassifiable row is not a `Drop`. Concretely: keep the row in `counted(s)`, never in `done(s)`, never in `dropped(s)`; add it to the `data_errors` payload block **naming the row's `no.` and the offending literal**; and caveat the percent (see below). A row nobody can read is work nobody can claim is finished.
Two offenders have their own named message, because both come from a real neighbouring vocabulary:
- **`取消`** — not a legal value on any axis. The 管控表 word is `Drop`; the Main Tracker word is `放棄`. Report: 「資料錯誤:`<no.>` 的 Status 是「取消」,管控表 tab 1 沒有這個值,請改為 `Drop` 並在 Change & Decision Log 補一筆」.
- **`暫停`** — **paused does not exist at task level.** 鐵律 3 fixes tab 1 at four values, and none of them is paused; the Main Tracker's `暫停` is a **project-level** reading and never maps onto a 管控表 row (pack §2.2 裁決一). Report: 「資料錯誤:`<no.>` 的 Status 是「暫停」,任務層級沒有這個狀態 — 暫停中的任務寫成 `WIP` + Note 說明恢復條件,或寫成 `Drop` + 一筆 Change & Decision Log」.
You may verify a batch of literals against the crosswalk instead of eyeballing them. Same `--file` convention, same skill-folder-relative paths — write `{"axis": "lifecycle_sheet", "values": ["Done", "WIP", "取消", ...]}` and run:
```bash
python3 scripts/pm-schema.py values --file <tmp>/status-values.json \
--schema references/pm-sheet-schema.json \
--crosswalk references/pm-status-crosswalk.json
```
**Dates** are written `M/D` (US-style); some are full (`2026/5/30`), most are short (`5/30`). Don't blindly stamp the project's start year — the tracker may run past New Year (rows with `12/1`, `1/31`, `2/1`). Infer the year that places the date within or just after the project window relative to today: if a short `M/D` falls earlier in the calendar than the project's start month and you're already late in the project, it belongs to the **next** year (so `1/31`, `2/1` → next year, not this one). A bare back-dated guess here is dangerous — it can flip a future milestone into "massively overdue" and fabricate a false 🔴 DELAYED. If a date is unparseable, mark the item `(日期待確認)` and **exclude it from overdue math** rather than guessing (pack §4 · §9.2).
Also read the dashboard tab once for carry-forward context (prior wording, decision history):
```
read_sheet_values(<tracker_sheet_id>, "專案狀態看板!A1:F55", <google_account>)
```
## Step 4 — Find last week's report (week-over-week diff)
This is a *weekly* update, so the most useful thing is **what changed**. Because this skill only ever **drafts** (never sends), last week's report normally lives in **Drafts**, not Sent — so search there first:
```
search_gmail_messages(<google_account>, query='in:drafts subject:"【專案週報】"')
```
Take the newest match. Also peek at `in:sent subject:"【專案週報】"` in case a prior one was sent. Read the most recent and compare status / blockers / stage / % vs the current tracker. Populate `weekly_diff` with the concrete changes (status transitions, blockers cleared or added, % movement, milestones hit or slipped). If this is genuinely the first run (nothing found), omit `weekly_diff` entirely — don't fabricate movement.
## Step 5 — Reason over the data → build the JSON
Build a JSON payload matching `references/dashboard_schema.json`. The reasoning rules:
### Project health — derived, never typed
**health is a derivation, not a column** (pack §2.2 裁決三). It is computed from `End` × lifecycle × open blockers; it is never read from a human-typed cell, and the result is **never written back** into 管控表 `Status` — 會議記錄's `On track / At risk / Delayed` is a health *reading* on the same axis, not a lifecycle value.
Read the three numbers from `pm.json` → `defaults.health_thresholds`; do not hardcode them. Evaluate **per row, in this order**, then take the project verdict as the worst row reading:
| Reading | Condition |
|---|---|
| 🔴 `DELAYED` | `End < today − delayed_after_days` 天 且 lifecycle ≠ `done`(預設 `0`,即 `End < today`) |
| 🟡 `AT_RISK` | (`End ≤ today + at_risk_within_days` 天 且 lifecycle = `not_started`)或 有一筆開啟中、已超過 `blocker_stale_days` 天的阻礙 |
| 🟢 `ON_TRACK` | 以上皆不成立 |
`dropped` rows carry **no** health reading — they are out of the plan, so they can be neither late nor at risk.
The `summary` is one line of the *specific* reasons, `・`-separated. Name the actual task/gate numbers (e.g. `課綱 2.3 逾期`), so the verdict is auditable rather than vibes. A Stage Gate row deserves naming: a gate whose date has passed while its child tasks are not all `Done` is the strongest 🔴 evidence you have (pack §5).
### Blockers
A blocker is anything that stops forward progress: an overdue task that gates a milestone, or a Stage Gate that can't open because its prerequisites aren't `Done`. Pull the *why* from the task `Note` when present. Title = short, detail = impact.
A blocker older than `blocker_stale_days` must exist as a `Risk Register` row with `Owner` and `狀態` filled (pack §6). If it doesn't, say so in the blocker's detail — 「(尚未登錄 Risk Register)」 — and propose it. Report the gap; never open the row for them.
### This week
The tasks that should close in the current week window: overdue must-close items first (these often double as blockers), then tasks whose Start/End straddle this week. Keep it to the few that matter. The `note` should say why it matters this week (e.g.「已逾期,本週必須關閉,解除 Blocker」).
### Dropped tasks — visible, and out of the denominator
Collect every `Drop` row into the `dropped` payload block: `no.` · task · the reason from `Note` · whether a matching `Change & Decision Log` row exists (match on `關聯任務 no.`). A `Drop` with no Change & Decision Log entry is an **incomplete action**, not a finished one (pack §5) — flag it 「(Change & Decision Log 無對應紀錄)」.
Dropped rows are listed, never silently discarded. A reader who cannot see what left the plan cannot tell a finished project from an abandoned one.
### Current stage + % complete
The tracker numbering is often messy (parallel tracks, repeated `4.0`, jumps to `7.0`/`8.0`). Do **not** map % to raw row counts. Work from the **delivery spine** — the ordered stages a viewer thinks of as "the project's phases".
**The spine is adapter data, never knowledge.** Read it from `pm.json` → `projects.<slug>.spine`, an ordered array of stage names. A course project's spine and a client rollout's spine are facts about two different projects; neither belongs in a SKILL.md, and a spine baked into this file silently scores every other project against the wrong phases.
If the key is absent, fall back to the **five delivery phases** that `project-init` seeds as `X.0` rows — `啟動` · `規劃` · `執行` · `監控` · `結案` — and **print the warning**「`projects.<slug>.spine` 未設定,本次採用五大交付階段(啟動·規劃·執行·監控·結案),請確認看板階段對應」. Never fall back silently: a percent computed against a spine the reader did not choose looks exactly like one they did.
**`跨階段 Cross-Cutting` is not a stage.** It is a parallel track running alongside all five phases, so it never occupies a spine slot and **its rows never enter any denominator**. Surface its work in blockers / this-week / timeline instead. The same holds for any other parallel track (a 行銷 track, for example).
**Reconcile the configured spine to the live sheet each run** — don't trust the array blindly. Read the `X.0` stage names from `里程碑 Stage` and map them onto the configured slots by meaning. If they can't be mapped cleanly (renamed, added or removed stages), still emit the %, but add a `(專案結構已變動,請確認看板階段對應)` note in `summary` so a human checks it. `total_spine_stages` = the number of mapped slots **that survive the boundary rules below** — never a hardcoded 6, and never just the length of the configured array.
**Stage state (source-faithful — a stage is only ✅ done when its remaining work is actually all done):**
- `done` — **every** non-dropped child task is `Done`. A row carrying an illegal `Status` literal is not dropped and is not `Done`, so it keeps its stage out of `done` — the same reason it stays in the denominator.
- `now` — at least one child task is `Done`/`WIP` but not all are `Done` (work is underway). Real projects run out of order, so **more than one** stage can be `now` (e.g. Stage 1 still being closed while Stage 2 is active) — mark each in-progress stage `now`, and add a short `(收尾中)` to the name of an earlier in-progress stage so it reads correctly. `stage_index` = the **leading-edge** stage (the highest-numbered stage with any activity); that's the one the header's "Stage X / Y" points to.
- `todo` — no child task started.
**Percent — the pack's denominator rule, applied at every level.** 裁決二 says exactly one thing about denominators: **`dropped` rows leave them, and nothing else does** (pack §2.2 裁決二). The old behaviour — counting `取消` as `Not started` — left abandoned work in the denominator, inflated it, and made the project look permanently behind; deleting an illegal literal instead would deflate it just as dishonestly. Both are ruled out below.
Note what the number **is**: the percent is the **mean of the per-stage completion fractions**, not one global `done ÷ total` ratio. A stage of 1 task and a stage of 20 weigh the same, because a spine slot is a phase, not a pile of rows. The email footer states it in exactly those words — the footer and this formula must always say the same thing.
```
counted(s) = every child task row of s — including rows whose Status is illegal
# only `Drop` ever leaves a denominator (pack §2.2 裁決二)
done(s) = rows of s bucketed `done` # an illegal literal is never `done`
dropped(s) = rows of s bucketed `dropped`
stage_fraction(s) = done(s) / (counted(s) − dropped(s))
spine = configured stages, minus every stage where counted(s) − dropped(s) = 0
percent = round( Σ stage_fraction(s) over spine / total_spine_stages * 100 )
```
Boundary cases:
- **`counted(s) − dropped(s) = 0` → the stage leaves the spine** (`total_spine_stages` drops by 1) **and is named in `data_errors`.** Never divide by zero, and never quietly score it `0` — a `0` is a claim about work, and there is no work here to make a claim about. This one rule subsumes both older special cases: a stage with **no task rows at all**, and a stage whose **every** row is `Drop` (whose rows also stay listed in `dropped` — scoring it `0` would punish the project for a decision it already took).
- A stage of **1 `Drop` + 1 illegal literal is not** this case. The illegal row still counts, so the denominator is `1`, the stage stays on the spine at `0 / 1 = 0`, and the illegal row is named in `data_errors`. That is the difference 裁決二 makes.
- If the spine empties completely (every configured stage hit the rule above), do **not** emit `current_stage` at all. Say so in `health.summary` —「專案沒有任何可計入的階段(全為 Drop 或無任務列)」— and name every removed stage in `data_errors`. A percent with no denominator is not a `0`.
- If **every** surviving spine stage is `done`, `percent = 100` and no stage is `now`.
- A stage with trailing open tasks contributes its true fraction (e.g. 2/4 = 0.5), **not** a full 1.0 — that's the whole point: don't round a half-finished stage up to "done".
- If `data_errors` is non-empty, the percent is provisional: append 「(含 `<n>` 列狀態值不合法,數字待確認)」 to `health.summary`.
Worked example (illustrative — recompute every run, these numbers are not a target). Configured spine absent, so the five delivery phases are used, warning printed:
| Stage | task rows | `done` | `dropped` | `counted − dropped` | `stage_fraction` |
|---|---|---|---|---|---|
| 啟動 | 4(2 Done · 1 WIP · 1 Drop) | 2 | 1 | 3 | `0.667` |
| 規劃 | 4(2 Done · 1 Not started · 1「取消」) | 2 | 0 | **4** | `0.5` |
| 執行 | 2(both `Drop`) | 0 | 2 | **0** | — leaves the spine |
| 監控 | 0 | 0 | 0 | **0** | — leaves the spine |
| 結案 | 3(all Not started) | 0 | 0 | 3 | `0` |
`total_spine_stages = 3` · `percent = round((0.667 + 0.5 + 0) / 3 × 100) = 39%`, with 啟動 marked `now (收尾中)` and 規劃 marked `now`. `data_errors` carries three entries: the 規劃「取消」row, plus 執行 and 監控 as stages removed for an empty denominator. Watch 規劃's denominator: the 「取消」row stayed in it — that is 裁決二, and dropping it would have read `2/3 = 0.667`, lifting the project from 39% to 44% on a row nobody could even read.
### Next milestone
The earliest Stage Gate / `X.0` milestone that is not yet complete. `overdue` = its date is before today. Pull completion criteria from the stage's `DOD` and mark already-satisfied parts with inline ✅. A gate is only passed when the Gate row carries both 核准人 (`Owner`) and 核准日期 (`End`) — a next stage already in `WIP` behind an unsigned gate is a 鐵律 4 violation, and you report it rather than sign it yourself (pack §5).
### Overview
`project_name`, `goal` (row 1 col D, condensed to one line), `owners` (derive from the `Owner`/`Facilitator` columns), `timeline` (earliest Start → latest End across stage rows).
### Recent decisions
Read them from the 管控表 `Change & Decision Log` tab — that is where a 決議 touching 範疇/時程/預算 is required to land, so it is the register the weekly reads (pack §6). Use the dashboard tab's `RECENT DECISIONS` block (read in Step 3) only for carry-forward wording. Keep the 3–5 most recent. If genuinely none are new, carry forward — don't invent.
### Timeline
Key dated milestones across the project (stage gates + headline deliverables). **Derive each date from the source rows** — a milestone's date is the relevant task/stage `End` or a date stated in its `Note`, not a hand-picked estimate. State per row: `done` (date passed & milestone met), `overdue` (date passed, not met), `todo` (future), `target` (a 🎯 headline like 開課/上架). Order by date.
Set `sheet_version` to whichever version Step 2 found (`v2` or `v1`), so the rendered footer records what was read.
**Write final reader-facing zh-TW copy in every field** — the renderer escapes and prints text verbatim. Save the payload to a temp path (e.g. `dashboard.json`).
## Step 6 — Render the HTML
```bash
python3 scripts/render_dashboard_email.py <tmp>/dashboard.json --out <tmp>/email.html
```
(The renderer lives in this skill's own `scripts/` folder — the same root as `pm-schema.py`, so one cwd runs both.) The script owns all styling (colored health badge, progress bar, sectioned cards, zh-TW-friendly font stack) and carries the same column map and lifecycle buckets as this file. **Copies do not guarantee themselves** — `python3 scripts/pm-schema.py mirrors` is what proves the three still agree, and `scripts/check-pm-refs.sh` runs it. If it errors, your JSON doesn't match the schema — fix the JSON, not the script. Read the resulting HTML back so you can pass it to the draft tool.
## Step 7 — Create the draft (do NOT send)
Subject line format: `【專案週報】<project short name> — <M/D> 更新(<health emoji><health short>)`
e.g.`【專案週報】Claude Code 課程 — 6/1 更新(🔴 進度落後)`
Create the draft with the HTML body via the **google-workspace** server (the authenticated one). Its `to` field takes a single comma-separated string, and the HTML goes in `body` with `body_format="html"`. **Build `to` by joining `projects.<slug>.report_recipients` with `", "`** — Step 0 already refused to run without that key, so there is nothing to fall back to and no literal to hardcode:
```
mcp__google-workspace__draft_gmail_message(
user_google_email=<google_account>,
to=<", ".join(projects.<slug>.report_recipients)>, # shape: "you@example.com, teammate-a@example.com"
subject="<subject>",
body="<contents of email.html>",
body_format="html"
)
```
Pass the **full HTML** (the entire contents of `email.html`) as `body`. This signature is verified working (`to` as a comma-separated string, `body_format="html"`, returns a `Draft ID`). Confirm the tool returns a `Draft ID` before reporting success — if it errors on the `to` shape, retry with a list.
(Only if `google-workspace` is somehow unavailable but the `gmail` server is authenticated, fall back to `mcp__gmail__draft_email` with `to=<projects.<slug>.report_recipients, as a list>`, `htmlBody=<html>`, `mimeType="multipart/alternative"`. Same list, different shape — still no literal.)
## Step 8 — Report back
Report back, in chat:
- **which schema version Step 2 found** (`v2` 14 欄 / `legacy v1` 13 欄) — and the v1 warning if it fired,
- the health verdict and the one-line reason, plus the thresholds used (config or the documented defaults),
- the computed % and active stage, **and where the spine came from** (`projects.<slug>.spine`, or the five-delivery-phase fallback with its warning), plus any stage removed from the spine for an empty denominator,
- the blockers and this-week list,
- **the dropped rows**, and any 「Change & Decision Log 無對應紀錄」 among them,
- **any `data_errors`**, each naming its row `no.` and the offending literal,
- that a **draft** (not sent) is waiting in the inbox for the recipients, with the subject line.
Then stop. The user reviews and sends.
---
## Unattended / scheduled runs
This skill is built to run **weekly on a schedule** (a cron/launchd routine invokes it — typically Monday morning). In that mode:
- **Ask nothing.** Run Steps 0→7 straight through and stop at the draft. There is no human in the loop at run time, so never pause for a clarifying question — apply the defaults in this skill (today = run date, draft-only, source-faithful %). The one thing you may not default is the project: with more than one entry in `projects` and no slug supplied, stop and say so.
- **Still draft-only — never send.** The whole point of the weekly draft is that a human skims and sends. An unattended run must not send mail (pack §9.1). If you later want auto-send, that's a separate, explicit change — see Guardrails.
- **Fail loud, don't fake it.** The run depends on four things: the declared pack matching, `~/.config/zynkr/pm.json` resolving the slug, the `google-workspace` MCP being authenticated, and read access to the spreadsheet. If any fails, **stop and report it plainly** (e.g.「週報自動化失敗:無法讀取專案管理總表(google-workspace 未授權?)」) rather than drafting an empty or stale dashboard. A missing-data email is worse than a visible failure.
- **Idempotency.** Step 4 already searches Drafts for an existing `【專案週報】`. If a draft for the same week already exists, update/replace it or skip rather than piling up duplicates.
The scheduler only fixes *when* this runs; it does not change *what* it does. Everything else in this skill applies identically to a manual and a scheduled invocation.
---
## Guardrails
- **Never send.** Draft only. If asked to send, create the draft and ask for explicit confirmation first.
- **Don't write to the sheet.** Both tabs are read-only context here. (If a future change ever writes back: VALUES ONLY — no reformatting, no column widths, no re-conversion. pack §9.3)
- **Never guess an identifier.** No Sheet ID, no Google account, no threshold, **no recipient, no spine** gets invented at run time. Resolve from `pm.json` or fail loud with the key path (Step 0). The two soft keys (`health_thresholds`, `spine`) fall back to documented values *and say so out loud*; everything else stops the run.
- **Never hardcode the range.** `A1:M44` was the old bug: on a v2 sheet it reads `前置任務 Depends on` as `Reference 連結` and every column after it shifts. Step 2 decides the range; header names decide the mapping.
- **Don't invent status.** If a field is genuinely unknown (e.g. a decision's reason), write `(待補充)` rather than guessing — that mirrors how the dashboard already flags gaps. A `Status` literal outside the four legal values is reported as a data error naming the row, never bucketed by resemblance — **and never removed from the denominator** (pack §2.2 裁決二).
- **Health never goes back into the sheet.** It is derived output; `Status` is the fact (pack §2.2 裁決三).
- **Source-faithful, not flattering.** A stage is `done` only when every remaining task is `Done`; trailing open tasks keep it `now (收尾中)`. Dropped work leaves the denominator but stays in the report; an illegal `Status` literal **stays in** the denominator and is named. Overstating progress to stakeholders is the worst failure mode here; under-claiming is recoverable.
## House style
Writing style is **not owned by this file**. The house voice lives in two Google Docs under
`[@] 寫作指南` (`12DBdFz3SK22ie9im_ThFMI7IBRXsTZsV`), read at runtime:
- 《[2.0] Zynkr 通用風格指南 House Voice》 `10bOIQwRm9Pxwgct4hlwCwK_B4Pipai1HqBPZKzyRHSE` —
the universal core, plus the addendum for this surface
- 《[3.2] 禁用詞清單 Forbidden Words》 `1N5sHLP4qzmmhpCGsi6KElxi1z0MFe4QZ0Q_35T10Uyg`
Read both before producing client- or reader-facing text, and scan the draft against 《[3.2]》
before handing it over. If Drive is unreachable, say so in the output rather than proceeding
unchecked. Never re-implement either list inside this file.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!