zynkr-ops-weekly · git:20260915.2dd891c · 2026-09-15 · sha256 02488741b1d1bfc0
zynkr-ops-weekly git:20260915.2dd891cA
Immutable. This exact content never changes and is served at /api/v1/blob/02488741b1d1bfc0.
---
name: zynkr-ops-weekly
sheetId: "3.19"
description: >-
The weekly operations loop that keeps a Google Chat space and a weekly operations Google Doc
in sync. Five scheduled modes run one beat each: `nudge` (Mon 09:00 — post the four-line
template plus last week's decisions), `rollup` (Tue 09:00 — read the week's `#週報` posts,
route each one to its department heading using the owner person-chips already in the Doc, and
write a clearly-marked auto-summary block), `chase` (Tue 09:30 — @ the owners who did not
post), `agenda` (Wed 17:00 — re-sweep for late arrivals, then produce carry-over, overdue,
KPI-off-target and the ≤3 decisions the Thursday meeting must actually make), and `decisions`
(Thu 22:00 — post the resolutions back to the space, send the recap mail, write the decisions
register, and assert the send actually happened). Routing is never hardcoded: it is read at
run time from the Doc's own owner chips, so changing the Doc changes both the routing and the
recap-mail recipient list. Trigger EAGERLY on "/zynkr-ops-weekly", "營運週報", "週報彙整",
"把大廳的週報整理進 Doc", "roll up the chat updates", "誰還沒回報", "補件提醒", "週四議程",
"產週會議程", "會後回貼決議", "recap 信", "weekly ops loop", "ops weekly rollup", or any ask to
collect / chase / summarise the team's weekly updates, build the weekly meeting agenda, or
publish the meeting's decisions. BOUNDARY — do NOT hijack: /zynkr-gm (the founder's own
company-level Monday brief, reads this Doc but never writes it), /project-status-update (one
project's status email from its own tracker), /planning-tracker-sync (the H2 tracker block and
its own nudges), /consult-status-report (client-facing consulting status), /admin-meeting-prep
(per-meeting packets for external meetings). This skill owns exactly one artefact — the weekly
operations Doc's current week section — and one channel loop around it.
category: operations
project: zynkr-ops-weekly
platform: claude
status: WIP
author: Peter Tu
input: "A mode (nudge | rollup | chase | agenda | decisions | status) and an optional 'as of' date; every identifier comes from the private config at ~/.config/zynkr/ops-weekly.json."
process: "Anchor on today → resolve the target Thursday (Monday's posts belong to the NEXT Thursday) → load config → check idempotency → read routing from the Doc's owner chips → sweep Chat by createTime → parse the four lines → write a marked block → assert delivery."
output: "Per mode: a Chat post, a marked 〔自動彙整〕 block under each department heading of the current week's Doc section, a Wednesday agenda block, and a Thursday recap email."
synergy: [zynkr-gm, project-status-update, planning-tracker-sync, operations-flow-optimization, admin-governance]
executed_by: internal-user
---
# zynkr-ops-weekly
```bash
npx skills add https://github.com/peter-tu-zynkr/zynkr-skill-builder --skill zynkr-ops-weekly
```
A small team posts its weekly update into a Google Chat space on Monday, and discusses
operations from a weekly Google Doc on Thursday. Nothing connects the two, so the Doc records
what people *intended* to do (week after week, near-verbatim) while the actual progress stays
in chat scrollback. This skill is the connective tissue: it collects, routes, chases, and
closes the loop — one scheduled beat at a time.
It is built around a single discovery about the Doc: **every department heading already carries
its owner's Google Docs person chip**, keyed by email. That is a routing table someone is
already maintaining by hand for other reasons. So this skill keeps **no department map of its
own** — it reads the chips at run time. Change the Doc, and routing *and* the recap-mail
recipient list both follow.
**It writes narrowly.** Auto-content only ever lands inside a block stamped
`〔自動彙整 WB 9/14 · 09-15 12:00〕`. It never edits a line a human wrote. A bot that silently
rewrites prose in a doc people are actively editing is a bot nobody trusts by week two.
---
## The cadence
| When | Mode | Who | What happens |
|---|---|---|---|
| Mon 09:00 | `nudge` | skill | Post the four-line template + last week's decisions + the Tue 09:00 cut-off |
| Mon (all day) | — | team | People post `#週報` in the space |
| **Tue 09:00** | `rollup` | skill | Read the window, route by owner chip, write the marked block, backfill metrics |
| **Tue 09:30** | `chase` | skill | Owners in the Doc − people who posted → @ the difference |
| Wed 17:00 | `agenda` | skill | Re-sweep for late arrivals, then carry-over · overdue · KPI · ≤3 decisions |
| **Thu 21:00** | — | team | The weekly meeting. Discuss exceptions and decisions only; edit the Doc live |
| **Thu 22:00** | `decisions` | skill | Resolutions → space (3 lines) + recap mail + decisions register + **assert the send** |
| Thu 23:00 | `weeklyMaintenance` | **Apps Script** | Duplicate the newest week section and re-stamp next Thursday, **then archive every section past the newest three**. Runs **after** `decisions` — see Step 4.1 |
`chase` must run **after** `rollup` — it cannot know who is missing until the roll-up has
resolved who posted. Both beats sit on Tuesday morning so that the Doc's Thursday section is
already full two days before anyone opens it.
**Why the scaffold and the archive are not this skill's job.** The split is by *whether
judgement is needed*, not by preference. Duplicating a section and retiring an old one are
purely mechanical and must never fail, so both belong to Apps Script, whose authorisation does
not expire. In the week this skill breaks entirely, the skeleton still opens, Thursday still has
a page, and the Doc still stops growing. See `references/scaffold.md`.
**Why the Doc stops growing — it takes both halves of the fix.** Every writer here is
insert-only, and the scaffold used to copy the newest section *verbatim*. Audited 2026-09-15:
the newest section held 22 auto-blocks of which only 4 were that week's, one week's four blocks
existed 16 times over, and sections had grown 130 → 292 lines in four weeks with no ceiling.
Two distinct leaks, and capping one without the other fixes nothing:
| Leak | Symptom | Fix |
|---|---|---|
| Sections are never retired | The tab grows by a whole section every week | `archiveOldWeeks()` — keep the newest 3, move the rest |
| Each section is a copy of a copy | The newest section inherits **every** auto-block ever written, so it keeps growing even at a fixed section count | `scaffoldNextWeek()` drops `〔自動彙整〕` blocks when snapshotting |
A week's summary belongs to the week it summarised. The scaffold carries the human skeleton and
the owner chips forward; Tuesday's `rollup` writes that week's blocks from scratch.
---
## Configuration (private — never in this repo)
This repository is public. The method is here; the **identifiers are not**. At runtime load
`~/.config/zynkr/ops-weekly.json` (schema in `references/config.example.json`, notes in
`references/config.README.md`; override the path with `ZYNKR_OPS_WEEKLY_CONFIG`).
| Config key | Role |
|---|---|
| `google_account` | account for every `google-workspace` MCP call |
| `space.id` | the Chat space, in `spaces/<id>` form — **the `spaces/` prefix is required** |
| `space.name` | human label, for report lines only |
| `doc.id` · `doc.tab_id` · `doc.tab_name` | the weekly operations Doc and the tab that holds the week sections |
| `doc.archive_tab_name` · `doc.keep_sections` | where Apps Script retires old sections to, and how many stay live (3). **Read-only here** — the skill asserts against them, never acts on them |
| `chat_ids` | **the only hardcoded map** — 6 rows of Chat `users/<id>` → email. See below |
| `reporters` | the emails expected to post each week (6 people; excludes non-reporting members) |
| `sources.main_tracker` · `sources.okr_kpi_tracker` | sheets read to backfill metrics and overdue items |
| `routine.*` | how the five beats are scheduled — mechanism, model, timezone, per-beat windows. See `references/scheduling.md` |
If a required value is missing or still a placeholder, **fail loud** (`config: doc.id unset`).
Never guess an id, and never fall back to a hardcoded department map.
### Why `chat_ids` has to exist
Chat and Docs do not share an identity space. The Chat payload **carries no email field at
all**; the Doc gives an email and no user id. Nothing bridges them automatically — the People
API resolves the id but exposes no name or email for domain profiles. So the bridge is six
hardcoded rows, and only six. Everything else is read from the Doc.
The map has **two key forms**, and a real space yields a mix of both: the MCP renders a sender
as a **display name** when that person is in the account's personal Contacts, and as
`users/<21-digit id>` when they are not. Key each person by the form their messages actually
arrive as, and keep both if unsure — a person joining Contacts later would otherwise silently
stop resolving.
---
## Required reading (before acting)
| File | Read when |
|---|---|
| `references/post-format.md` | Always — the four-line format, what each line feeds, and the parse rules |
| `references/routing.md` | Always — how to read owner chips out of the Doc, and the off-by-one |
| `references/doc-write-rules.md` | Any mode that writes — marked blocks, tab targeting, idempotency |
| `references/message-templates.md` | Composing any Chat post or the recap mail |
| `references/wording.md` | **Always, before writing any zh-TW the team will read** — house voice, per `/content-translator` |
| `references/scaffold.md` | Installing or debugging the Apps Script half |
---
## Step 0 — Anchor on today, and get the off-by-one right
Resolve today in `Asia/Taipei`. Compute:
- **Week label** — `WB 9/14`, the **Monday that opens the week**. This is what goes in every
stamp, every Chat footer and the recap subject: it is the day the team posts, and it is a date
a reader can place without counting. It replaced the ISO ordinal (`W38`) on 2026-09-15.
- **ISO week key** — `2026-W38`. Machine-only: the launchd state files and the receipt line's
`week=` field. Never shown to the team. `references/wording.md` explains why both exist.
- **The window** — Monday 00:00 of the current ISO week → now.
- **The target Thursday** — the Doc names its sections by **Thursday** date (`Aug 27`,
`Aug 20`, …), but the team reports on **Monday**.
> **Monday's posts belong to the Thursday that is coming, not the one that just passed.**
> This is the single easiest thing to get wrong, and it silently writes a whole week's
> updates into the previous meeting's section. Assert it: the target Thursday must be
> **≥ today**. If the newest section in the Doc is already in the past, the scaffold did not
> run — say so loudly rather than writing into a stale section.
## Step 1 — Load config and check idempotency
Load the private config; fail loud on placeholders. Then check whether this mode already ran
for this ISO week:
- Chat-delivering modes (`nudge`, `chase`, `agenda`, `decisions`) — list the space's messages
for today and look for this skill's own marker line (each template ends with a
`— zynkr-ops-weekly · <week>` footer). Found → stop and report "already ran".
- `rollup` — look for a `〔自動彙整 <week>` stamp inside the target Thursday section. Found →
do not write a second block; re-run in *append-new-only* mode (Step 4.4).
> **Until 2026-10-06, match the old week shape too.** The label changed from `W38` to
> `WB 9/14` on 2026-09-15, so a section written before then is stamped `〔自動彙整 2026-W38`
> and a footer posted before then reads `· W38`. Search for **either**. Matching only the new
> shape makes `rollup` write a duplicate block and makes `decisions` conclude the loop never
> ran and refuse to send the recap. `references/wording.md` → "The changeover".
## Step 2 — Read routing from the Doc (every run)
Fetch the Doc **as markdown** — `get_doc_as_markdown`. This matters:
`get_doc_content` returns plain text and **silently strips person chips**, which is exactly the
data the routing depends on. In markdown a chip arrives as `[Sam Rivera](mailto:owner-a@example.com)`.
Pipe it through `scripts/parse_routing.py` to get `{heading → owner email}`. Rules, precedence
and the heading-grouping details are in `references/routing.md`.
Sanity-check the result before using it: every email must appear in `reporters` or be a known
non-reporting owner. An unexpected address means a chip changed — surface it in the report
rather than dropping the section.
## Step 3 — Read the week's posts (`rollup`, `chase`, `agenda`)
Call `get_messages` with the **`space_id` and a `createTime` window**.
> Do **not** use `search_messages` to find the posts. It is a *client-side* scan bounded by
> `max_spaces` × `page_size`; the Chat API's underlying `spaces.messages.list` supports only
> `createTime` and `thread.name` filters and has no full-text search at all. Used for
> retrieval it under-scans silently and reports "nothing found" for messages that exist.
Paginate until the window is covered. Then `scripts/parse_reports.py` turns the raw messages
into records — one per reporter — resolving the sender through `chat_ids` and splitting the
four lines. Posts without the `#週報` tag are ignored: the space is a mixed channel and carries
plenty of other traffic.
**While the format is still being adopted**, pass `--accept-untagged`. The team's pre-tag
shapes (`上禮拜進度` … `本週待辦`, `這個禮拜我的 focus`) carry no tag, so a tag-only parse
returns nothing and the first roll-up reads as total non-compliance. Each record is stamped
`format: "tagged" | "legacy"`; report how many are still legacy, and drop the flag once that
number has been zero for two weeks. An optional format is not a format.
## Step 4 — Mode bodies
### 4.1 `nudge` (Mon 09:00)
Read **last week's** Doc section and extract its decisions — from the **Doc**, not from the
recap mail that was sent. Mail is a delivery channel, never a data dependency; a failed send
should cost an archive copy, not break the chain.
Post the template from `references/message-templates.md` with those decisions quoted above it
and the Tue 09:00 cut-off stated. Reference last week's decisions so people report *against*
something.
**Then assert Thursday's maintenance fired — both halves of it.** This is the "prove it fired"
check for the Apps Script side. It lives here rather than in `decisions` because the trigger runs
later on Thursday evening than `decisions` does — checking at that moment would fail every week
for the wrong reason, and a notice that cries wolf weekly is worse than no notice.
1. **The scaffold.** Confirm a section for the *upcoming* Thursday exists. Missing → post a
failure notice and stop; do **not** create the section here, because rebuilding it loses the
owner person chips, which no API can recreate.
2. **The archive.** Count the dated sections in the live tab. It should be exactly **3**. More
means `archiveOldWeeks()` did not run or threw — report it, and say how many. Do **not**
archive from here; that is Apps Script's job (Guardrails). Fewer than 3 is the louder
problem: it means something removed a section the scaffold needs, so say so explicitly.
3. **The copy is clean.** The upcoming Thursday's section should contain **no** `〔自動彙整〕`
block at all — Tuesday's `rollup` writes the first one. If it arrived carrying last week's
blocks, the scaffold is running an old copy of `scaffold.gs` that still copies verbatim.
That one is easy to miss because everything still *works*; it just silently regrows the Doc.
### 4.2 `rollup` (Tue 09:00)
1. Parse the window (Step 3).
2. Resolve routing (Step 2). For each record, its owner email selects the department heading(s)
it belongs under. One owner may hold several headings — write the block under each, or under
the primary one if the report names a department explicitly.
3. **Backfill metrics.** Reporters fill the `數字:` line only with what they have on hand. For
the rest, read `sources.main_tracker` / `sources.okr_kpi_tracker` and fill the Doc's metric
slots from there. Cite the source cell for every number written. Never invent a number and
never carry one forward from a prior week — an empty slot is information.
4. **Compute carry-over.** `scripts/carryover.py` compares this week's items to prior sections
and emits `↻N週` counts. An item at `↻3週` or higher is agenda material by default.
Two things it deliberately does **not** count, both learned from the real Doc:
**template rows** — a label like `Funnel` or `CTR` that appears in most sections carries no
work and would otherwise dominate the agenda; and **drifting matches** — the walk-back
requires a near-exact match, because loose matching chains across unrelated items and
invents long streaks. Digits are stripped before comparison, so `90%` → `92%` is correctly
one item rather than two.
5. Write one marked block per department under its heading — `references/doc-write-rules.md`.
6. Report: who posted, who did not, what was written where. The "did not" list is `chase`'s input.
### 4.3 `chase` (Tue 09:30)
`missing = reporters − posters`. Empty → post nothing and report full coverage; a chase message
that chases nobody teaches people to ignore chase messages.
Otherwise post one short message naming the missing people and the Wed 12:00 cut-off. Name them
in plain text — `send_message` posts text, and reliable programmatic @-mentions need the
annotation payload the MCP tool does not currently expose.
> If `chase` names the same person two weeks running, the problem is the format or the routing,
> not the person. Say that in the report.
### 4.4 `agenda` (Wed 17:00)
1. **Re-sweep the window first** (Mon 00:00 → now) to pick up anything that arrived after the
chase. Append only records not already stamped in the Doc — match on reporter + ISO week so
a re-run cannot duplicate.
2. Assemble, in this order, and cap it: **carry-over `↻N週`** · **overdue** (from the tracker)
· **KPI off-target** · **≤3 decisions**. The decisions come from the reporters' `卡關:` line —
that is the only field in the format that forces a decision, which is why it cannot be
dropped.
3. Write the agenda at the **top of the target Thursday section**, in its own marked block.
4. Post a short pointer to the space with the Doc link — the agenda itself lives in the Doc.
If there are more than three candidate decisions, choose the three with the largest blast
radius and list the rest under a "not this week" line. An agenda that lists everything makes no
decisions.
### 4.5 `decisions` (Thu 22:00)
Runs **after** the meeting, against the section as the humans edited it live.
The hour is not arbitrary and is the one beat that must be re-timed if the meeting moves. It sits
in the gap between the meeting ending and the 23:00 scaffold: fire it *during* the meeting and it
recaps a section nobody has edited yet — and it does not merely write to the Doc, it mails every
owner-chip address, so a premature run is a wrong email to the whole team, not a stale line.
**Precondition — refuse to recap a week the loop never ran.** Before anything else, look for this
week's `〔自動彙整 W<week>` stamp in the target section. Missing → `rollup` never ran, so nothing in
that section has been through the loop and the meeting had nothing to work from. Post a one-line
notice to the space, send **no mail**, and stop. This is what makes the five triggers safe to
install on any day of the week: a mid-week install, a public holiday or a failed Tuesday can no
longer put a recap of a week that never happened in front of the whole team.
1. Extract resolutions: **decision · owner · date**. A resolution missing an owner or a date is
not a resolution — list it as still open rather than promoting it.
2. `send_message` — three lines to the space. Short, because next Monday's `nudge` quotes it.
3. `send_gmail_message` — the full recap to **the owner-chip emails read in Step 2**, not to a
list maintained here. Contents: decisions · owner · date / overdue and carry-over / KPI
off-target / next week's focus per department / a link back to this week's Doc section.
**Search `in:sent` for this week's subject BEFORE composing, and skip the send if it is
already there.** A non-ok run is now retried up to three times inside its window, so this
step can legitimately be reached more than once in one evening. Step 1's idempotency check
guards the Chat post, not the mail, and this mail goes to the whole team: the one failure
this loop must never produce is three copies of the same recap in six inboxes.
4. **Assert it fired.** Immediately search `in:sent` for the subject just used, within the last
few minutes. Not found → post a one-line failure notice to the space and say so in the
report. This is the SDD "prove it fired" rule, and it is here for a concrete reason: two
consecutive weekly sends once failed unnoticed because nothing checked. A dead token must
surface within a week, not five.
5. Write the resolutions into the tracker's decisions register.
6. **Do not check the scaffold here.** Apps Script fires within an *hour window*, and the
scaffold trigger runs later on Thursday evening than this mode does — so at this point next
week's section legitimately does not exist yet. Asserting it here produces a weekly false
alarm, which trains everyone to ignore the notice that matters. The check lives in `nudge`
(Step 4.1), which runs Monday: clear of the window, and a day before `rollup` needs it.
### 4.6 `status` (on demand)
Read-only. Print what the loop currently sees: target Thursday, who has posted, what is
already stamped in the Doc, which triggers ran this week. Writes nothing — use it to debug
before reaching for a mode that writes.
## Step 5 — Report, and receipt the run
Every run ends with a compact report: mode, ISO week, target Thursday, records parsed, who is
missing, what was written where, what was delivered, and — for `decisions` — the send
assertion result. If a step was skipped, say which and why.
**Then end the report with a receipt line, on its own line, exactly in this shape:**
```
ZYNKR-OPS-WEEKLY-RESULT: mode=<mode> week=<ISO week> status=ok|partial|failed delivered=<short>
```
`week=` here stays the **ISO key** (`2026-W38`), not the `WB 9/14` label the team reads. This
line is machine-facing, it needs to be year-qualified and sortable, and it sits next to the
`<week>.<mode>.done` state files that use the same key. See `references/wording.md`.
`status=ok` means **every** side effect this mode owes actually landed and you verified it —
the message is in the space, the block is in the Doc, the mail is in `in:sent`. Anything
short of that is `partial` (some landed) or `failed` (none did), with the reason in
`delivered=`. Examples:
```
ZYNKR-OPS-WEEKLY-RESULT: mode=rollup week=2026-W36 status=ok delivered=6-blocks-verified
ZYNKR-OPS-WEEKLY-RESULT: mode=agenda week=2026-W36 status=partial delivered=doc-written;chat-404-app-not-configured
ZYNKR-OPS-WEEKLY-RESULT: mode=nudge week=2026-W36 status=failed delivered=none;mcp-timeout
```
This line is not decoration and it is not for humans. `run_ops_weekly.sh` parses it and stamps
the week done **only** on `status=ok`; anything else retries on the next tick inside the window
and gives up after three attempts. **Never write `status=ok` because the run finished — write it
because you checked.** The scheduler has no other way to tell a delivered beat from a beat that
politely explained why it could not run: `claude -p` exits 0 either way. It waved through a
silent Monday and a half-failed Wednesday in W36 before this line existed.
---
## Guardrails
- **Never edit a human's line.** Auto-content lives only inside `〔自動彙整 …〕` blocks. Promotion
or deletion of that content is a human act, at Thursday's meeting.
- **Never rebuild the Doc's skeleton.** Person chips cannot be created by Apps Script or the
Docs REST API — only copied. Rebuilding loses the routing table. Copy, or do nothing.
- **Never write to a past section.** If the target Thursday is behind today, stop and report.
- **Never archive, and never write to the archive tab.** Retiring a section is Apps Script's
job, for the same reason scaffolding is: mechanical, must not fail, authorisation that does
not expire. The skill half reads `每週事項 封存` and nothing more. If the live tab is holding
more than three weeks, that is a finding to report, not a thing to fix in-run.
- **Never invent a metric.** Cite the cell, or leave the slot empty.
- **Never treat mail as an input.** Every mode reads state from the Doc and the tracker.
- **Fail loud on config.** Placeholder id → stop; a wrong id writes into someone else's file.
- **Write like a colleague, not a report generator.** Every published line is zh-TW that six
people read in a chat room. No 官腔 (`徵集`/`產出`/`決議候選`), no half-translated lines
(`KPI off-target`, `carry-over`, `Not started`), no internal mode names (`rollup`, `骨架`)
and no parenthetical explanations of your own filtering. `references/wording.md` carries
the rules and the frozen strings; it applies to the free text you compose at run time,
not just to the templates.
## Limitations
- `send_message` posts plain text; @-mentions render as names, not live mentions.
- Metric backfill only covers metrics that exist in the configured sheets; anything measured
outside them stays a human line.
- The Apps Script half must be installed once by hand, and `installTriggers()` must actually be
*run* — pasting the file does not schedule anything. See `references/scaffold.md`.
- **The archive tab must be created by hand, once.** No API can create a Docs tab from Apps
Script, so `archiveOldWeeks()` fails loud when `每週事項 封存` is missing rather than inventing
somewhere to put a year of work. It must sit **directly after** the live tab, in the **same
document** — `references/doc-write-rules.md` explains why both constraints are load-bearing.
- Archiving keeps three *sections*, not three *weeks of edits*. A human who pastes a long note
into the live tab below the oldest dated heading will see it archived with that section; the
cut is "everything from the fourth heading down". Standing notes belong above the newest
dated heading.
- The skill half runs on **launchd**, not a claude.ai cloud routine: there is no Google Chat
connector, the cloud sandbox cannot read the private config, and the Drive connector cannot do
the chip-preserving Doc writes. See `references/scheduling.md`.
- Deliberately not built: writing to the H2 tracker's status column (that is
`planning-tracker-sync`'s), and any auto-promotion of an auto-summary line into a human line.