forge-orkes 0.80.4 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -295,19 +295,25 @@ function upgradeDir(relDir, { autoClean = false } = {}) {
295
295
  }
296
296
 
297
297
  /**
298
- * Guarantee CLAUDE.md carries the single `@.forge/FORGE.md` import line, migrating
299
- * any legacy embedded forge section out in one pass. The framework prose itself lives
300
- * in .forge/FORGE.md (synced separately)this function never writes prose into
301
- * CLAUDE.md and never touches user content outside the legacy forge section.
298
+ * Guarantee CLAUDE.md carries the single `@.forge/FORGE.md` import line WITHIN ITS
299
+ * FIRST 3 LINES, migrating any legacy embedded forge section out in one pass. Claude
300
+ * Code truncates memory files at 40k chars from the TAIL an import line buried below
301
+ * that cliff silently unloads the entire framework, so the import must never live in
302
+ * the tail. The framework prose itself lives in .forge/FORGE.md (synced separately) —
303
+ * this function never writes prose into CLAUDE.md and never touches user content
304
+ * outside the legacy forge section / the one line it relocates.
302
305
  *
303
306
  * no CLAUDE.md → write the stub (comment + import) → 'created'
304
307
  * marker section present → replace section in place with the import line → 'migrated'
305
308
  * legacy `# Forge` header (no markers) → strip header-to-EOF, append import line → 'migrated'
306
- * import line already present → 'unchanged'
307
- * none of the above append comment + import line at EOF 'appended'
309
+ * import line within the first 3 lines untouched, no rewrite (no mtime churn) → 'unchanged'
310
+ * import line present but lower remove that line, insert as line 1 + blank line,
311
+ * every other byte preserved in order → 'moved'
312
+ * no import line anywhere → prepend import line + blank line, original
313
+ * content follows intact → 'appended'
308
314
  *
309
- * Newline cleanup is confined to the seam of the replaced region only — a global
310
- * collapse would mutate user content, which upgrades must never do.
315
+ * Newline cleanup is confined to the seam of the replaced/relocated region only — a
316
+ * global collapse would mutate user content, which upgrades must never do.
311
317
  */
312
318
  function ensureClaudeMdImport() {
313
319
  const destPath = path.join(targetDir, 'CLAUDE.md');
@@ -344,21 +350,90 @@ function ensureClaudeMdImport() {
344
350
  return 'migrated';
345
351
  }
346
352
 
347
- // Import line already present (tolerating trailing whitespace/CRLF) — nothing to do.
348
- if (FORGE_IMPORT_RE.test(existing)) return 'unchanged';
353
+ // Import line present somewhere (tolerating trailing whitespace/CRLF) — find its
354
+ // exact line so position and byte-preservation are decided on real line boundaries,
355
+ // not the regex's own (looser) match span.
356
+ if (FORGE_IMPORT_RE.test(existing)) {
357
+ const lines = existing.split('\n');
358
+ const idx = lines.findIndex((l) => l.trimEnd() === FORGE_IMPORT_LINE);
359
+
360
+ // Already within the first 3 lines — leave the file byte-for-byte untouched.
361
+ if (idx === -1 || idx <= 2) return 'unchanged';
362
+
363
+ // Remove the import's own line.
364
+ lines.splice(idx, 1);
365
+ // If that removal left two adjacent blank lines where there weren't three
366
+ // before, collapse them back to one — the only non-import byte we ever drop.
367
+ if (idx > 0 && idx < lines.length && lines[idx - 1].trim() === '' && lines[idx].trim() === '') {
368
+ lines.splice(idx, 1);
369
+ }
370
+ const merged = FORGE_IMPORT_LINE + '\n\n' + lines.join('\n');
371
+ fs.writeFileSync(destPath, merged);
372
+ return 'moved';
373
+ }
349
374
 
350
- // User-authored CLAUDE.md with no forge content and no import line — append it.
351
- const merged =
352
- existing.trimEnd() + '\n\n' + CLAUDE_STUB;
375
+ // User-authored CLAUDE.md with no forge content and no import line anywhere
376
+ // prepend the import (plus a separating blank line) so it is never buried below
377
+ // the truncation cliff. Original content follows intact, byte-for-byte.
378
+ const merged = FORGE_IMPORT_LINE + '\n\n' + existing;
353
379
  fs.writeFileSync(destPath, merged);
354
380
  return 'appended';
355
381
  }
356
382
 
383
+ // --- Hook identity helpers (per-hook dedupe key: event + matcher + script basename) ---
384
+
385
+ // Normalize a hook group's matcher for key comparisons — trim, and treat an
386
+ // absent/empty matcher as the same value so an unset matcher never desyncs
387
+ // from "".
388
+ function normalizeMatcher(matcher) {
389
+ return typeof matcher === 'string' ? matcher.trim() : '';
390
+ }
391
+
392
+ // Basename of the first path-looking token in a command string (quotes
393
+ // stripped) — "bash /path/to/x.sh" and "$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh"
394
+ // both resolve to "x.sh", so the same framework script hosted at a different
395
+ // path, or invoked via an interpreter prefix, is recognized as the same hook.
396
+ // No path-looking token (e.g. a bare `echo` one-liner) → the whole trimmed
397
+ // command is its own identity.
398
+ function hookScriptBasename(command) {
399
+ const tokens = command.match(/"[^"]*"|'[^']*'|\S+/g) || [];
400
+ for (const raw of tokens) {
401
+ let tok = raw;
402
+ if ((tok.startsWith('"') && tok.endsWith('"')) || (tok.startsWith("'") && tok.endsWith("'"))) {
403
+ tok = tok.slice(1, -1);
404
+ }
405
+ if (tok.includes('/')) return path.basename(tok);
406
+ }
407
+ return command.trim();
408
+ }
409
+
410
+ // Identity of a single hook entry within a group. Prompt-backed hooks key off
411
+ // the prompt text; anything else (neither command nor prompt) falls back to a
412
+ // full-entry identity so it never collides with something it isn't.
413
+ function hookEntryIdentity(hookEntry) {
414
+ if (typeof hookEntry.command === 'string') return 'cmd:' + hookScriptBasename(hookEntry.command);
415
+ if (typeof hookEntry.prompt === 'string') return 'prompt:' + hookEntry.prompt.trim();
416
+ return 'raw:' + JSON.stringify(hookEntry);
417
+ }
418
+
419
+ // Full dedupe key for one (event, matcher, hook-entry) registration. JSON-encoded
420
+ // as a tuple so no delimiter choice can be ambiguated by the values themselves.
421
+ function hookKey(event, matcher, hookEntry) {
422
+ return JSON.stringify([event, normalizeMatcher(matcher), hookEntryIdentity(hookEntry)]);
423
+ }
424
+
357
425
  /**
358
426
  * Additively install the template's framework-managed hooks into an existing
359
- * settings.json (ADR-017). Never removes a user's hooks; dedups by deep-equality
360
- * so re-running upgrade is idempotent. New groups (the safety guardrails) get
361
- * appended; groups the project already has (identical) are skipped.
427
+ * settings.json (ADR-017). Never removes a user's hooks. Dedupes PER HOOK
428
+ * ENTRY, keyed by (event, matcher, script basename) not whole-group
429
+ * equality because consumer repos legitimately reshape groups (combine two
430
+ * matcher="Bash" groups into one, host the same script at a project-local
431
+ * path, etc.) without that being a meaningfully different hook. A hook whose
432
+ * key already exists anywhere under that event is skipped; a genuinely new
433
+ * hook is appended into an existing dest group sharing its matcher, or into a
434
+ * newly created group when no such group exists yet. See cleanupDuplicateHooks()
435
+ * (called from upgradeSettings()) for the paired destination-side cleanup pass
436
+ * that collapses duplicates already baked into a project's settings.json.
362
437
  */
363
438
  function mergeManagedHooks(destSettings, srcSettings) {
364
439
  if (!srcSettings.hooks) return;
@@ -366,12 +441,62 @@ function mergeManagedHooks(destSettings, srcSettings) {
366
441
  for (const event of Object.keys(srcSettings.hooks)) {
367
442
  const srcGroups = srcSettings.hooks[event] || [];
368
443
  const destGroups = destSettings.hooks[event] || (destSettings.hooks[event] = []);
369
- for (const group of srcGroups) {
370
- const groupJson = JSON.stringify(group);
371
- if (!destGroups.some((d) => JSON.stringify(d) === groupJson)) {
372
- destGroups.push(group);
444
+
445
+ const existingKeys = new Set();
446
+ const groupsByMatcher = new Map();
447
+ for (const group of destGroups) {
448
+ const nm = normalizeMatcher(group.matcher);
449
+ if (!groupsByMatcher.has(nm)) groupsByMatcher.set(nm, group);
450
+ for (const hookEntry of group.hooks || []) {
451
+ existingKeys.add(hookKey(event, group.matcher, hookEntry));
373
452
  }
374
453
  }
454
+
455
+ for (const srcGroup of srcGroups) {
456
+ for (const hookEntry of srcGroup.hooks || []) {
457
+ const key = hookKey(event, srcGroup.matcher, hookEntry);
458
+ if (existingKeys.has(key)) continue; // already installed, whatever group it sits in
459
+
460
+ const nm = normalizeMatcher(srcGroup.matcher);
461
+ let destGroup = groupsByMatcher.get(nm);
462
+ if (!destGroup) {
463
+ destGroup = { matcher: srcGroup.matcher, hooks: [] };
464
+ destGroups.push(destGroup);
465
+ groupsByMatcher.set(nm, destGroup);
466
+ }
467
+ destGroup.hooks.push(hookEntry);
468
+ existingKeys.add(key);
469
+ }
470
+ }
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Collapse duplicate hook registrations already baked into a project's
476
+ * settings.json — the same (event, matcher, script basename) appearing in
477
+ * more than one group, or more than once in the same group — keeping the
478
+ * first occurrence and dropping any group the collapse empties. Runs on
479
+ * every upgrade, independent of what mergeManagedHooks() just added, so
480
+ * repos that accumulated near-duplicate groups under the old whole-group
481
+ * equality merge converge without operator action. Idempotent by
482
+ * construction: a settings.json with no duplicates is left byte-identical.
483
+ */
484
+ function cleanupDuplicateHooks(destSettings) {
485
+ if (!destSettings.hooks) return;
486
+ for (const event of Object.keys(destSettings.hooks)) {
487
+ const groups = destSettings.hooks[event];
488
+ if (!Array.isArray(groups)) continue;
489
+ const seen = new Set();
490
+ for (const group of groups) {
491
+ if (!Array.isArray(group.hooks)) continue;
492
+ group.hooks = group.hooks.filter((hookEntry) => {
493
+ const key = hookKey(event, group.matcher, hookEntry);
494
+ if (seen.has(key)) return false;
495
+ seen.add(key);
496
+ return true;
497
+ });
498
+ }
499
+ destSettings.hooks[event] = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
375
500
  }
376
501
  }
377
502
 
@@ -475,6 +600,9 @@ function upgradeSettings() {
475
600
  migrateActiveSkillHook(destSettings);
476
601
  // Additively install framework-managed safety guardrails (ADR-017)
477
602
  mergeManagedHooks(destSettings, srcSettings);
603
+ // Converge any duplicate hook registrations already baked into the
604
+ // destination (pre-existing, or left by an older whole-group merge).
605
+ cleanupDuplicateHooks(destSettings);
478
606
  mergeManagedDeny(destSettings, srcSettings);
479
607
  mergeManagedAllow(destSettings, srcSettings);
480
608
 
@@ -535,6 +663,9 @@ async function install() {
535
663
  case 'appended':
536
664
  console.log(' Added @.forge/FORGE.md import line to existing CLAUDE.md');
537
665
  break;
666
+ case 'moved':
667
+ console.log(' Moved @.forge/FORGE.md import line to the top of CLAUDE.md');
668
+ break;
538
669
  case 'unchanged':
539
670
  console.log(' CLAUDE.md import line already present');
540
671
  break;
@@ -959,6 +1090,9 @@ async function upgrade() {
959
1090
  } else if (claudeStatus === 'appended') {
960
1091
  results.updated.push('CLAUDE.md');
961
1092
  say(' CLAUDE.md: @.forge/FORGE.md import line restored');
1093
+ } else if (claudeStatus === 'moved') {
1094
+ results.updated.push('CLAUDE.md');
1095
+ say(' CLAUDE.md: @.forge/FORGE.md import line moved to the top (was below the truncation-safe zone)');
962
1096
  } else if (claudeStatus === 'unchanged') {
963
1097
  results.unchanged.push('CLAUDE.md');
964
1098
  } else if (claudeStatus === 'created') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.80.4",
3
+ "version": "0.82.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -42,6 +42,16 @@
42
42
  # A declared-gate artifact that does not exist in this repo (e.g. no
43
43
  # prototypes/ dir yet) is skipped silently — not an error, not a violation.
44
44
  #
45
+ # Special-cased alongside the tiered skill-file class: the CONSUMER's
46
+ # repo-root CLAUDE.md, advisory at 30 KB (m-56). This is NOT a FORGE.md Size
47
+ # Gates table row — that table lists forge-owned artifacts, and CLAUDE.md
48
+ # belongs entirely to the consumer (the import-to-top fix keeps the
49
+ # @.forge/FORGE.md import out of Claude Code's 40k-char tail-truncation
50
+ # zone; this check is the companion advisory for when the consumer's own
51
+ # prose grows past a size where relocating it into path-scoped
52
+ # .claude/rules/ files, ADR-018, is worth considering). Absent CLAUDE.md or
53
+ # under 30 KB → silent, same contract as every other check.
54
+ #
45
55
  # Contract (SessionStart, mirrors forge-state-rollup.sh — ADR-032):
46
56
  # - Exit 0 ALWAYS. SessionStart is non-blocking; this hook must never abort
47
57
  # a session.
@@ -164,6 +174,22 @@ for f in "$root"/.claude/skills/*/SKILL.md; do
164
174
  check_gate "$f" "$skill_kb"
165
175
  done
166
176
 
177
+ # --- consumer CLAUDE.md advisory (special-cased — NOT a Size Gates table row) -
178
+ # CLAUDE.md is consumer-owned, not forge-owned, so it never joins the declared
179
+ # table above (m-56). Threshold is 30 KB BINARY (30720 B — Claude Code's own
180
+ # memory-file semantics use binary units), unlike the decimal-KB gates above.
181
+ # Missing file or under threshold → silent, same contract as every other check.
182
+
183
+ CLAUDE_MD_GATE_BYTES=30720
184
+
185
+ if [ -f "$root/CLAUDE.md" ]; then
186
+ claude_bytes="$(wc -c < "$root/CLAUDE.md" 2>/dev/null | tr -d ' ')"
187
+ if [ -n "$claude_bytes" ] && [ "$claude_bytes" -gt "$CLAUDE_MD_GATE_BYTES" ]; then
188
+ VIOLATIONS="${VIOLATIONS}${VIOLATIONS:+, }CLAUDE.md (${claude_bytes}B, over the 30KB advisory threshold — relocate prose into path-scoped .claude/rules/, ADR-018)"
189
+ VIOLATION_COUNT=$((VIOLATION_COUNT + 1))
190
+ fi
191
+ fi
192
+
167
193
  # --- summary (stdout → injected into boot context) ---------------------------
168
194
  # Silent when clean. One terse line when not.
169
195
 
@@ -114,6 +114,34 @@ check_has "missing tier-list → default 20KB tier applied even to 'forge'" "$ou
114
114
  rm -rf "$REPO/.claude/skills"
115
115
  printf 'forge\nchief-of-staff\n' > "$REPO/.claude/hooks/skill-gate-tiers.txt"
116
116
 
117
+ # ===========================================================================
118
+ # Case F1 — consumer CLAUDE.md over the 30KB (30720B) advisory threshold: one
119
+ # advisory line naming CLAUDE.md, its byte size, the 30KB threshold, and the
120
+ # .claude/rules/ relocation pointer. Special-cased, NOT a Size Gates table row.
121
+ # ===========================================================================
122
+ pad_to "$REPO/CLAUDE.md" 31000
123
+ out_f1="$("$GATE" --project "$REPO" 2>&1)"
124
+ check_has "over-30KB CLAUDE.md named" "$out_f1" "CLAUDE.md"
125
+ check_has "over-30KB CLAUDE.md shows actual bytes" "$out_f1" "31000B"
126
+ check_has "over-30KB CLAUDE.md names the 30KB threshold" "$out_f1" "30KB"
127
+ check_has "over-30KB CLAUDE.md points at .claude/rules/ relocation" "$out_f1" ".claude/rules"
128
+ rm -f "$REPO/CLAUDE.md"
129
+
130
+ # ===========================================================================
131
+ # Case F2 — consumer CLAUDE.md under the 30KB threshold → never named.
132
+ # ===========================================================================
133
+ pad_to "$REPO/CLAUDE.md" 5000
134
+ out_f2b="$("$GATE" --project "$REPO" 2>&1)"
135
+ check_absent "under-30KB CLAUDE.md never named" "$out_f2b" "CLAUDE.md"
136
+ rm -f "$REPO/CLAUDE.md"
137
+
138
+ # ===========================================================================
139
+ # Case F3 — no CLAUDE.md at all → no error, clean run, exit 0.
140
+ # ===========================================================================
141
+ out_f3="$("$GATE" --project "$REPO" 2>&1)"; rc_f3=$?
142
+ check "absent CLAUDE.md → exit 0" "$rc_f3" "0"
143
+ check_absent "absent CLAUDE.md never named" "$out_f3" "CLAUDE.md"
144
+
117
145
  # ===========================================================================
118
146
  # Case F — non-Forge repo / non-repo dir: silent no-op, exit 0.
119
147
  # ===========================================================================
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "forge": {
3
- "version": "0.80.4",
3
+ "version": "0.82.0",
4
4
  "default_tier": "standard",
5
5
  "beads_integration": false,
6
6
  "context_gates": {
@@ -5,7 +5,7 @@ description: "Use when rendering Forge state to the Notion board or reading its
5
5
 
6
6
  # The board (Notion adapter)
7
7
 
8
- The Notion adapter that renders **the board** — a rendered **status** window + a backlog **inbox** — and never anything else. Git (`.forge/`) stays the source of truth; the board is a **projection + inbox**, **never an approval surface** (D-D/M21). No runtime, no polling, no daemon (Fork 3): the render rides existing Forge lifecycle hooks (ADR-025).
8
+ The Notion adapter that renders **the board** — a rendered **status** window + a backlog **inbox**. Git (`.forge/`) stays the source of truth; the board is a **projection + inbox**, **never an approval surface** (D-D/M21). No runtime, no polling, no daemon (Fork 3): the render rides existing Forge lifecycle hooks (ADR-025).
9
9
 
10
10
  Two things the board is NOT: it does not gate any merge/deploy/promotion/fold (board data flows OUT only, except the inbox's explicit-intake read), and it does not compute or store any status of its own — it **delivers the state the rollups already compute** (compute-don't-store).
11
11
 
@@ -14,7 +14,7 @@ Two things the board is NOT: it does not gate any merge/deploy/promotion/fold (b
14
14
  The adapter activates only when both hold (else fully inert — zero behavior change at boot, handoff, or commit):
15
15
 
16
16
  1. **Config** — a `board:` block in `.forge/project.yml` with `enabled: true` and a `database_id` (see `docs/board.md` for the schema).
17
- 2. **Delivery** — for **attended** renders, a Notion MCP server present in the session; for **headless** renders (post-merge CI / cron), a scoped integration token in the env var named by `board.ci_token_env` (phase 57 — `forge-board-render.sh --deliver`).
17
+ 2. **Delivery** — for **attended** renders, a Notion MCP server present in the session; for **headless** renders (post-merge CI / cron), a scoped integration token in the env var named by `board.ci_token_env` (`forge-board-render.sh --deliver`).
18
18
 
19
19
  ## Degradation contract — non-blocking by construction
20
20
 
@@ -26,20 +26,24 @@ The single source of truth for *what the board shows* is the projection builder,
26
26
 
27
27
  - **`--emit`** computes and prints the normalized projection JSON (units + deferred view) from the existing folds — deterministic, offline, side-effect-free. This is the seam both transports deliver.
28
28
  - **Attended transport** (this skill's `render()`): run `forge-board-render.sh --emit`, then upsert each unit to Notion via the **session MCP**.
29
- - **Headless transport** (`forge-board-render.sh --deliver`, phase 57): the same `--emit` projection, delivered via a scoped-token `curl` for post-merge CI / cron (R11).
29
+ - **Headless transport** (`forge-board-render.sh --deliver`): the same `--emit` projection, delivered via a scoped-token `curl` for post-merge CI / cron (R11).
30
30
 
31
31
  Because both deliver the identical `--emit` object, there is no second source of truth for the projection.
32
32
 
33
33
  ## Data model
34
34
 
35
- Single Notion database (topology: one workspace, per-repo views). Rows are Forge units keyed by `Forge ID`, nested by a self-`Parent` relation. Full model: `.forge/phases/milestone-32/03-board-architecture/data-model.md` (extends ADR-019 with the `Repo` stamp + `Release State`; ADR-020 identity binding). Finer-grained rows (requirements/phases/tasks/plans) remain deferred — m-21 DEF-044/045, do not build.
35
+ Single Notion database (topology: one workspace, per-repo views). Rows are Forge units keyed by `Forge ID`, nested by a self-`Parent` relation. Full model: `.forge/phases/milestone-32/03-board-architecture/data-model.md` (extends ADR-019 with the `Repo` stamp + `Release State`; ADR-020 identity binding). Finer-grained phase/plan rows (m-21 DEF-044/045, resolved m-57) live in a second, opt-in DB — see below.
36
+
37
+ ### Second grain — phase + plan rows (opt-in, `board.plan_database_id`)
38
+
39
+ `--emit` also always computes `projection.phase_rows[]` (one row per phase/plan, self-related via `Parent phase`). `--deliver` pushes them only when `board.plan_database_id` is set — absent ⇒ inert, same opt-in contract as `board:` itself. Schema (`Grain`/`Intent`/`Progress` Select, self-relation, rich text), the three saved views (Roadmap / In flight / Outline — hand-created; Notion's API can't create views), and the Intent(human)/Progress(machine) split: `docs/board.md`.
36
40
 
37
41
  ### Field ownership — the collision-proof invariant (ADR-019, kept verbatim)
38
42
 
39
43
  Every property has exactly one owner. **`render()` writes ONLY agent-owned properties**, addressed by `Forge ID`, and issues **no write, ever** to a human-owned property. Non-overlapping ownership ⇒ **overwrite-on-render is safe by construction** (a hand-edited agent-owned cell is corrected at the next render; a human-owned cell is never touched).
40
44
 
41
45
  - **Agent-owned** (overwritten every render): `Forge ID`, `Name`, `Layer`, `Status`, `Tier`, `Progress`, `Blockers`, `Parent`, `Rollup Summary`, `Release State`, `Repo`, `Last Rendered`.
42
- - `Blockers`, `Parent`, and `Rollup Summary` are **reserved, not yet populated**: at the current milestone-only scope the renderer emits them empty (`blockers: []`, `parent: null`, no `rollup_summary`) — they gain real values only when finer-grained rows land (DEF-044/045). Ownership is declared now so the schema doesn't shift later (R084).
46
+ - `Blockers`, `Parent`, and `Rollup Summary` are **reserved, not yet populated**: at the current milestone-only scope the renderer emits them empty (`blockers: []`, `parent: null`, no `rollup_summary`) — phase/plan granularity now lives in the second DB above, not as sub-rows here. Ownership is declared now so the schema doesn't shift later (R084).
43
47
  - **Human-owned** (read as inbox proposals in `pull_inbox`, **never** written): `Priority`, `Queue Position`, `Triage Notes`, `Comments`.
44
48
 
45
49
  **No gate-read property exists.** Nothing on the board is read by any merge/deploy/promotion/fold path (acceptance test #6).
@@ -58,7 +62,7 @@ Deleting `map.yml` never duplicates rows — step 2 re-binds. `map.yml` is deriv
58
62
 
59
63
  ### `render()` — attended: push agent-owned state out (the status window)
60
64
 
61
- **Trigger:** `forge` boot, `chief-of-staff` Show/Sync (the attended render sites, phase 57) — plus on demand via `sync()`. Producer-side, like a checkpoint publish.
65
+ **Trigger:** `forge` boot, `chief-of-staff` Show/Sync — plus on demand via `sync()`. Producer-side, like a checkpoint publish.
62
66
 
63
67
  **Precondition:** `board.enabled: true` + a Notion MCP present. Else no-op (log *"board adapter inactive (MCP absent)"*).
64
68
 
@@ -102,11 +102,20 @@ unit_release_state() { # $1 = unit id (e.g. m-32)
102
102
  unit_progress() { # $1 = numeric id, $2 = current.phase, $3 = status
103
103
  roadmap="$FORGE/roadmap.yml"
104
104
  [ -f "$roadmap" ] || { printf ''; return 0; }
105
- phases=$(awk -v id="$1" '
105
+ # milestones[] ids may be bare (- id: 55) or quoted/labeled (- id: "m-53")
106
+ # accept either spelling against both the raw and m--stripped unit id forms
107
+ # (issue #49), same tolerant style as unit_id()/phase_table()'s mpart lookup.
108
+ bare="$1"; case "$1" in m-*) bare="${1#m-}" ;; esac
109
+ labeled="m-$bare"
110
+ phases=$(awk -v bare="$bare" -v labeled="$labeled" '
106
111
  /^ milestones:/ { inm=1 }
107
- inm && $0 ~ "^ - id: "id"[[:space:]]*$" { grab=1; next }
112
+ inm && /^ - id: / {
113
+ if (grab) exit
114
+ val=$0; sub(/^ - id: */,"",val); gsub(/[[:space:]]+$/,"",val); gsub(/^"|"$/,"",val); gsub(/^'"'"'|'"'"'$/,"",val)
115
+ if (val == bare || val == labeled) grab=1
116
+ next
117
+ }
108
118
  inm && grab && /phases:/ { sub(/^[^\[]*\[/,""); sub(/\].*/,""); gsub(/[[:space:]]/,""); print; exit }
109
- inm && grab && /^ - id:/ { exit }
110
119
  ' "$roadmap")
111
120
  [ -n "$phases" ] || { printf ''; return 0; }
112
121
  total=$(printf '%s' "$phases" | awk -F, '{print NF}')
@@ -179,6 +188,123 @@ emit_deferred() {
179
188
  done
180
189
  }
181
190
 
191
+ # ---------------------------------------------------------------------------
192
+ # Phase+plan grain rows (m-57 phase 103; derivation LOCKED in context/m-57.md).
193
+ # One row per roadmap phases[] entry (grain Phase) + one row per plan-*.md on
194
+ # disk under that phase's dir (grain Plan, parent = the phase row's forge_id).
195
+ # Progress is the same family as forge's {percent}: no phase dir => unplanned;
196
+ # dir exists but cursor not reached => planned; cursor AT the phase with
197
+ # status executing/verifying/reviewing => in_progress; behind the cursor or
198
+ # milestone complete => done. Intent is the roadmap `status:` field VERBATIM
199
+ # (human-owned; projected, never re-derived) — inherited onto plan rows too.
200
+ # ---------------------------------------------------------------------------
201
+
202
+ # roadmap milestone: value ("1", "m-33") -> the shared dir/state-file key ("1", "33")
203
+ mpart() {
204
+ case "$1" in m-*) printf '%s' "${1#m-}" ;; *) printf '%s' "$1" ;; esac
205
+ }
206
+
207
+ # TSV id\tname\tmilestone_raw\tstatus, in file order — the phases[] block only
208
+ # (the earlier `milestones:` block is never entered; inph stays 0 until it).
209
+ phase_table() {
210
+ roadmap="$FORGE/roadmap.yml"
211
+ [ -f "$roadmap" ] || return 0
212
+ awk '
213
+ /^ phases:/ { inph = 1; next }
214
+ inph && /^ [A-Za-z]/ { inph = 0 }
215
+ inph && /^ - id:/ {
216
+ if (id != "") print id "\t" name "\t" ms "\t" st
217
+ v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); id = v; name = ""; ms = ""; st = ""
218
+ next
219
+ }
220
+ inph && /^ name:/ { v=$0; sub(/^[^:]*:[[:space:]]*/,"",v); sub(/[[:space:]]*#.*/,"",v); gsub(/^"|"$/,"",v); name=v; next }
221
+ inph && /^ milestone:/ { v=$0; sub(/^[^:]*:[[:space:]]*/,"",v); sub(/[[:space:]]*#.*/,"",v); gsub(/^"|"$/,"",v); ms=v; next }
222
+ inph && /^ status:/ { v=$0; sub(/^[^:]*:[[:space:]]*/,"",v); sub(/[[:space:]]*#.*/,"",v); gsub(/^"|"$/,"",v); st=v; next }
223
+ END { if (id != "") print id "\t" name "\t" ms "\t" st }
224
+ ' "$roadmap"
225
+ }
226
+
227
+ # $1 mpart $2 phase_id -> the matching .forge/phases/milestone-{mpart}/{id}-* dir, or empty
228
+ phase_dir_for() {
229
+ base="$FORGE/phases/milestone-$1"
230
+ [ -d "$base" ] || return 0
231
+ for d in "$base/$2"-*; do
232
+ [ -d "$d" ] && { printf '%s' "$d"; return 0; }
233
+ done
234
+ }
235
+
236
+ # $1 dir(may be empty) $2 phase_id $3 milestone current.status $4 cursor phase $5 cursor status
237
+ phase_progress() {
238
+ [ -n "$1" ] || { printf 'unplanned'; return 0; }
239
+ [ "$3" = "complete" ] && { printf 'done'; return 0; }
240
+ [ -n "$4" ] || { printf 'planned'; return 0; }
241
+ if [ "$2" -lt "$4" ] 2>/dev/null; then printf 'done'; return 0; fi
242
+ if [ "$2" -eq "$4" ] 2>/dev/null; then
243
+ case "$5" in
244
+ executing|verifying|reviewing) printf 'in_progress' ;;
245
+ *) printf 'planned' ;;
246
+ esac
247
+ return 0
248
+ fi
249
+ printf 'planned'
250
+ }
251
+
252
+ # $1 phase progress $2 plan number $3 cursor plan (may be empty; only meaningful when $1=in_progress)
253
+ plan_progress() {
254
+ case "$1" in
255
+ done) printf 'done' ;;
256
+ in_progress)
257
+ if [ -z "$3" ]; then printf 'planned'; return 0; fi
258
+ if [ "$2" -lt "$3" ] 2>/dev/null; then printf 'done'
259
+ elif [ "$2" -eq "$3" ] 2>/dev/null; then printf 'in_progress'
260
+ else printf 'planned'; fi
261
+ ;;
262
+ *) printf 'planned' ;;
263
+ esac
264
+ }
265
+
266
+ emit_phase_rows() {
267
+ first=1
268
+ warned=""
269
+ phase_table | while IFS=' ' read -r pid pname pms pstatus; do
270
+ [ -n "$pid" ] || continue
271
+ part="$(mpart "$pms")"
272
+ munit="$(unit_id "$part")"
273
+ sf="$FORGE/state/milestone-$part.yml"
274
+ if [ -f "$sf" ]; then
275
+ mstatus=$(yget "$sf" current status)
276
+ cphase=$(yget "$sf" current phase)
277
+ cplan=$(yget "$sf" current plan)
278
+ else
279
+ mstatus=""; cphase=""; cplan=""
280
+ case " $warned " in
281
+ *" $munit "*) : ;;
282
+ *) warned="$warned $munit"
283
+ printf 'board-render: no state/milestone-%s.yml for %s — phase rows degrade to unplanned\n' "$part" "$munit" >&2 ;;
284
+ esac
285
+ fi
286
+ dir="$(phase_dir_for "$part" "$pid")"
287
+ [ -f "$sf" ] || dir="" # never-declared milestone (no cursor) => unplanned, never a crash
288
+ prog="$(phase_progress "$dir" "$pid" "$mstatus" "$cphase" "$mstatus")"
289
+ pkey="$munit/$pid"
290
+ [ "$first" = 1 ] && first=0 || printf ',\n'
291
+ printf ' {"forge_id": "%s", "grain": "Phase", "milestone": "%s", "phase_id": %s, "plan": null, "name": "%s", "intent": "%s", "progress": "%s", "parent": null}' \
292
+ "$(json_escape "$pkey")" "$(json_escape "$munit")" "$pid" "$(json_escape "$pname")" "$(json_escape "$pstatus")" "$(json_escape "$prog")"
293
+
294
+ [ -n "$dir" ] || continue
295
+ for pf in "$dir"/plan-*.md; do
296
+ [ -f "$pf" ] || continue
297
+ pbase=$(basename "$pf" .md)
298
+ nn=${pbase#plan-} # zero-padded, e.g. "01" — used in the key/name verbatim
299
+ nnum="$(printf '%s' "$nn" | sed 's/^0*//')"; [ -n "$nnum" ] || nnum=0 # arithmetic-safe (no octal-literal risk)
300
+ pprog="$(plan_progress "$prog" "$nnum" "$cplan")"
301
+ printf ',\n'
302
+ printf ' {"forge_id": "%s", "grain": "Plan", "milestone": "%s", "phase_id": %s, "plan": %s, "name": "%s", "intent": "%s", "progress": "%s", "parent": "%s"}' \
303
+ "$(json_escape "$pkey/plan-$nn")" "$(json_escape "$munit")" "$pid" "$nnum" "$(json_escape "$pbase")" "$(json_escape "$pstatus")" "$(json_escape "$pprog")" "$(json_escape "$pkey")"
304
+ done
305
+ done
306
+ }
307
+
182
308
  # ---------------------------------------------------------------------------
183
309
  # The shared projection builder — one source of truth for what the board shows.
184
310
  # --emit prints it; --deliver (phase 57) delivers this same object.
@@ -215,6 +341,9 @@ emit_projection() {
215
341
  "$unit" "$(json_escape "$nm")" "$(json_escape "$status")" "$(json_escape "$rstate")" "$(json_escape "$tier")" "$(json_escape "$prog")"
216
342
  done
217
343
  printf '\n ],\n'
344
+ printf ' "phase_rows": [\n'
345
+ emit_phase_rows
346
+ printf '\n ],\n'
218
347
  printf ' "deferred": [\n'
219
348
  emit_deferred
220
349
  printf '\n ]\n'
@@ -312,6 +441,98 @@ unit_properties() { # stdin unit $1 repo $2 ts $3 set_forge_id
312
441
  '
313
442
  }
314
443
 
444
+ # Second, opt-in DB for phase/plan grain rows (m-57 phase 103): board.plan_database_id.
445
+ # Own gitignored identity map (a distinct Notion database => distinct page ids) — mirrors
446
+ # MAP/map_lookup/map_record exactly rather than parameterizing them, so the existing
447
+ # unit delivery path above is untouched (R084-style: duplicate a few lines, don't risk it).
448
+ PLAN_MAP="$FORGE/notion/plan-map.yml"
449
+ planmap_lookup() { # $1 forge_id -> page_id (empty on miss)
450
+ [ -f "$PLAN_MAP" ] || return 0
451
+ awk -v id="$1" '$1 == id":" { print $2; exit }' "$PLAN_MAP"
452
+ }
453
+ planmap_record() { # $1 forge_id $2 page_id
454
+ [ -n "$2" ] || return 0
455
+ [ "$(git rev-parse --git-dir 2>/dev/null)" = "$(git rev-parse --git-common-dir 2>/dev/null)" ] || return 0
456
+ mkdir -p "$(dirname "$PLAN_MAP")" 2>/dev/null || return 0
457
+ if [ -f "$PLAN_MAP" ]; then grep -v "^$1:" "$PLAN_MAP" > "$PLAN_MAP.tmp" 2>/dev/null && mv "$PLAN_MAP.tmp" "$PLAN_MAP" 2>/dev/null; fi
458
+ printf '%s: %s\n' "$1" "$2" >> "$PLAN_MAP" 2>/dev/null || true
459
+ }
460
+
461
+ # Agent-owned Notion properties for one phase/plan row (stdin: the row JSON). $1 repo, $2 ts,
462
+ # $3 parent page-id (empty => omit the relation — a resolution miss logs and skips it, never
463
+ # fails the row), $4 = 1 to stamp `Forge ID` (create only, same identity convention as units).
464
+ plan_row_properties() { # stdin row $1 repo $2 ts $3 parent_page $4 set_forge_id
465
+ jq -c --arg repo "$1" --arg ts "$2" --arg parent "$3" --argjson setfid "$4" '
466
+ def sel(v): (if (v // "") == "" then {select: null} else {select: {name: v}} end);
467
+ {
468
+ "Name": {title: [{text: {content: (.name // "")}}]},
469
+ "Grain": sel(.grain),
470
+ "Intent": sel(.intent),
471
+ "Progress": sel(.progress),
472
+ "Milestone": {rich_text: [{text: {content: (.milestone // "")}}]},
473
+ "Last Rendered": {date: {start: $ts}}
474
+ }
475
+ + (if ($repo | length) > 0 then {"Repo": {select: {name: $repo}}} else {} end)
476
+ + (if $setfid == 1 then {"Forge ID": {rich_text: [{text: {content: .forge_id}}]}} else {} end)
477
+ + (if ($parent | length) > 0 then {"Parent phase": {relation: [{id: $parent}]}} else {} end)
478
+ '
479
+ }
480
+
481
+ # Upserts every projection.phase_rows[] entry into the plan-grain DB, keyed by forge_id
482
+ # (same map -> query -> create resolution order as units). Processes rows in projection
483
+ # order — a phase row upserts (and records its page id) before its own plan rows are
484
+ # reached, so "Parent phase" resolves within THIS run without a second pass. A parent
485
+ # miss (never happens in-order, but degrades safely) logs and omits the relation only —
486
+ # never fails the row. Every failure is non-blocking: log + continue (deliver_headless's posture).
487
+ deliver_plan_grain() { # $1 proj json $2 plan_db $3 token $4 repo $5 ts
488
+ pproj="$1"; pdb="$2"; ptok="$3"; prepo="$4"; pts="$5"
489
+ pok=0; pfail=0
490
+ pcount="$(printf '%s' "$pproj" | jq '.phase_rows | length')"
491
+ pi=0
492
+ while [ "$pi" -lt "$pcount" ]; do
493
+ row="$(printf '%s' "$pproj" | jq -c ".phase_rows[$pi]")"
494
+ pi=$((pi + 1))
495
+ rfid="$(printf '%s' "$row" | jq -r '.forge_id')"
496
+ [ -n "$rfid" ] && [ "$rfid" != "null" ] || continue
497
+
498
+ rparent_fid="$(printf '%s' "$row" | jq -r '.parent // empty')"
499
+ rparent_page=""
500
+ if [ -n "$rparent_fid" ]; then
501
+ rparent_page="$(planmap_lookup "$rparent_fid")"
502
+ [ -n "$rparent_page" ] || log_deliver "plan grain: parent $rparent_fid not yet resolved for $rfid — relation omitted"
503
+ fi
504
+
505
+ rpage="$(planmap_lookup "$rfid")"
506
+ if [ -z "$rpage" ]; then
507
+ rpage="$(query_page "$pdb" "$ptok" "$rfid")"
508
+ [ -n "$rpage" ] && planmap_record "$rfid" "$rpage"
509
+ fi
510
+
511
+ if [ -n "$rpage" ]; then
512
+ rprops="$(printf '%s' "$row" | plan_row_properties "$prepo" "$pts" "$rparent_page" 0)"
513
+ rbody="$(jq -n --argjson p "$rprops" '{properties: $p}')"
514
+ rresp="$(notion_curl PATCH "$NOTION_API/pages/$rpage" "$ptok" "$rbody")"
515
+ else
516
+ rprops="$(printf '%s' "$row" | plan_row_properties "$prepo" "$pts" "$rparent_page" 1)"
517
+ rbody="$(jq -n --arg db "$pdb" --argjson p "$rprops" '{parent: {database_id: $db}, properties: $p}')"
518
+ rresp="$(notion_curl POST "$NOTION_API/pages" "$ptok" "$rbody")"
519
+ fi
520
+
521
+ rcode="$(printf '%s' "$rresp" | tail -1)"
522
+ case "$rcode" in
523
+ 2*)
524
+ pok=$((pok + 1))
525
+ [ -z "$(planmap_lookup "$rfid")" ] && planmap_record "$rfid" "$(printf '%s' "$rresp" | sed '$d' | jq -r '.id // empty' 2>/dev/null)"
526
+ ;;
527
+ *)
528
+ pfail=$((pfail + 1))
529
+ log_deliver "plan-grain row $rfid → HTTP ${rcode:-none} (non-blocking, continuing)"
530
+ ;;
531
+ esac
532
+ done
533
+ log_deliver "plan grain deliver complete: $pok upserted, $pfail failed (non-blocking) — db=$pdb"
534
+ }
535
+
315
536
  deliver_headless() {
316
537
  board_enabled || { log_deliver "board disabled (board.enabled != true) — deliver skipped"; return 0; }
317
538
  var="$(token_env_name)"
@@ -364,6 +585,13 @@ deliver_headless() {
364
585
  esac
365
586
  done
366
587
  log_deliver "deliver complete: $ok upserted, $fail failed (non-blocking) — repo=$repo"
588
+
589
+ pdb="$(board_cfg plan_database_id)"
590
+ if [ -z "$pdb" ]; then
591
+ log_deliver "plan grain: no plan_database_id — skipped"
592
+ else
593
+ deliver_plan_grain "$proj" "$pdb" "$tok" "$repo" "$ts"
594
+ fi
367
595
  return 0
368
596
  }
369
597
 
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env sh
2
+ # Fixture-repo test suite for the phase+plan grain projection (m-57, plan 103-01).
3
+ #
4
+ # Contract under test (context/m-57.md LOCKED derivation + must_haves.truths):
5
+ # --emit adds a phase_rows[] array alongside units[]: one Phase-grain row per
6
+ # roadmap phases[] entry (including never-planned ones, progress: unplanned)
7
+ # plus one Plan-grain row per plan-*.md on disk under that phase's dir, with
8
+ # `parent` pointing at the phase row's forge_id. Progress derives from the
9
+ # milestone cursor (state/milestone-{id}.yml): no dir => unplanned; dir but
10
+ # cursor not reached => planned; cursor AT the phase (executing/verifying/
11
+ # reviewing) => in_progress; behind the cursor or milestone complete => done.
12
+ # Intent is the roadmap `status:` value verbatim (never re-derived). --emit
13
+ # stays deterministic (no wall-clock). --deliver upserts phase_rows to
14
+ # board.plan_database_id only when set; unset => one skip line, unit
15
+ # delivery byte-for-byte unchanged (inert by construction, ADR-025 pattern).
16
+ #
17
+ # Builds a throwaway git repo in a mktemp dir, no remote, offline by construction.
18
+ # The plan-db-inert case shadows `curl` with a PATH stub (same technique as
19
+ # board-render-deliver.test.sh) so --deliver runs its real logic with zero
20
+ # network traffic.
21
+ #
22
+ # Usage: ./.forge/checks/tests/forge-board-render.test.sh [--case <name>]
23
+ # cases: unplanned-visible, cursor-derivation, plan-parent-link,
24
+ # emit-deterministic, plan-db-inert, intent-verbatim,
25
+ # quoted-id-progress, bare-id-progress
26
+ # RED-phase friendly: a missing/non-executable script fails every case, exits non-zero.
27
+ set -u
28
+
29
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
30
+ RENDER="$SCRIPT_DIR/../forge-board-render.sh"
31
+
32
+ CASE=""
33
+ if [ "${1:-}" = "--case" ]; then CASE="${2:-}"; fi
34
+
35
+ command -v jq >/dev/null 2>&1 || { printf 'FATAL: jq required\n' >&2; exit 1; }
36
+ [ -f "$RENDER" ] || printf 'NOTE: %s missing — RED phase, every case should FAIL\n' "$RENDER" >&2
37
+
38
+ ROOT="$(mktemp -d)"
39
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
40
+ GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
41
+ export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
42
+
43
+ PASSED=0; FAILED=0
44
+ ok() { PASSED=$((PASSED+1)); printf ' ok %s\n' "$1"; }
45
+ bad() { FAILED=$((FAILED+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
46
+ want_case() { [ -z "$CASE" ] || [ "$CASE" = "$1" ]; }
47
+
48
+ # --- build the fixture repo -------------------------------------------------
49
+ # One milestone (m-60), four phases spanning all four progress states + one
50
+ # arbitrary Intent value ("next") to prove verbatim passthrough:
51
+ # 200 earlier — dir on disk, behind cursor (201) => done
52
+ # 201 first — dir on disk, AT cursor, status executing => in_progress
53
+ # 202 gap — NO dir on disk => unplanned
54
+ # 203 later — dir on disk, ahead of cursor => planned
55
+ REPO="$ROOT/proj"
56
+ mkdir -p "$REPO/.forge/state" "$REPO/.forge/checks" \
57
+ "$REPO/.forge/phases/milestone-60/200-earlier" \
58
+ "$REPO/.forge/phases/milestone-60/201-first" \
59
+ "$REPO/.forge/phases/milestone-60/203-later"
60
+ cd "$REPO" || exit 1
61
+ git init -q -b main; git config user.email t@t; git config user.name t
62
+
63
+ : > .forge/phases/milestone-60/200-earlier/plan-01.md
64
+ : > .forge/phases/milestone-60/201-first/plan-01.md
65
+ : > .forge/phases/milestone-60/201-first/plan-02.md
66
+ : > .forge/phases/milestone-60/203-later/plan-01.md
67
+
68
+ cat > .forge/state/milestone-60.yml <<'EOF'
69
+ milestone:
70
+ id: m-60
71
+ name: "Fixture milestone"
72
+ current:
73
+ tier: standard
74
+ phase: 201
75
+ status: executing
76
+ plan: 1
77
+ lifecycle:
78
+ deferred_at: null
79
+ resumed_at: null
80
+ EOF
81
+
82
+ # Regression-guard pair for issue #49: milestone-53 pairs with a roadmap
83
+ # milestones[] entry spelled quoted/labeled ("m-53"); milestone-55 pairs with
84
+ # one spelled bare (55) — mirrors the two spellings live in this repo's own
85
+ # roadmap.yml. Both mid-phase (status: executing, cursor at the SECOND
86
+ # declared phase) so a real, non-trivial N/M (1/2) is the only way either
87
+ # case passes — not just "non-empty".
88
+ cat > .forge/state/milestone-53.yml <<'EOF'
89
+ milestone:
90
+ id: m-53
91
+ name: "Quoted-id fixture milestone"
92
+ current:
93
+ tier: standard
94
+ phase: 94
95
+ status: executing
96
+ plan: 1
97
+ lifecycle:
98
+ deferred_at: null
99
+ resumed_at: null
100
+ EOF
101
+
102
+ cat > .forge/state/milestone-55.yml <<'EOF'
103
+ milestone:
104
+ id: m-55
105
+ name: "Bare-id fixture milestone"
106
+ current:
107
+ tier: standard
108
+ phase: 99
109
+ status: executing
110
+ plan: 1
111
+ lifecycle:
112
+ deferred_at: null
113
+ resumed_at: null
114
+ EOF
115
+
116
+ cat > .forge/roadmap.yml <<'EOF'
117
+ roadmap:
118
+ milestones:
119
+ - id: 60
120
+ name: "Fixture milestone"
121
+ phases: [200, 201, 202, 203]
122
+ - id: "m-53"
123
+ name: "Quoted-id fixture milestone"
124
+ phases: [93, 94]
125
+ - id: 55
126
+ name: "Bare-id fixture milestone"
127
+ phases: [98, 99]
128
+ phases:
129
+ - id: 200
130
+ name: "earlier"
131
+ milestone: "60"
132
+ status: complete
133
+ - id: 201
134
+ name: "first"
135
+ milestone: "60"
136
+ status: active
137
+ - id: 202
138
+ name: "gap"
139
+ milestone: "60"
140
+ status: next
141
+ - id: 203
142
+ name: "later"
143
+ milestone: "60"
144
+ status: planned
145
+ waves: []
146
+ EOF
147
+
148
+ cat > .forge/project.yml <<'EOF'
149
+ project: { name: fixture }
150
+ board:
151
+ enabled: true
152
+ provider: notion
153
+ database_id: db-fixture-unit-000
154
+ repo: fixturerepo
155
+ ci_token_env: FORGE_NOTION_TOKEN
156
+ EOF
157
+ git add -A; git commit -qm init
158
+
159
+ run_emit() { cd "$REPO" && sh "$RENDER" --emit 2>/dev/null; }
160
+ OUT="$(run_emit)"
161
+
162
+ # ============================================================================
163
+ # Case: unplanned-visible — a never-planned phase (202, no dir) still gets a
164
+ # row, and it reads progress: unplanned.
165
+ # ============================================================================
166
+ if want_case unplanned-visible; then
167
+ prog=$(printf '%s' "$OUT" | jq -r '.phase_rows[] | select(.forge_id=="m-60/202") | .progress')
168
+ if [ "$prog" = "unplanned" ]; then ok "never-planned phase 202 visible with progress: unplanned"
169
+ else bad "unplanned-visible" "got progress=[$prog]"; fi
170
+ fi
171
+
172
+ # ============================================================================
173
+ # Case: cursor-derivation — done/in_progress/planned across three phases,
174
+ # the same story forge's {percent} tells.
175
+ # ============================================================================
176
+ if want_case cursor-derivation; then
177
+ p200=$(printf '%s' "$OUT" | jq -r '.phase_rows[] | select(.forge_id=="m-60/200") | .progress')
178
+ p201=$(printf '%s' "$OUT" | jq -r '.phase_rows[] | select(.forge_id=="m-60/201") | .progress')
179
+ p203=$(printf '%s' "$OUT" | jq -r '.phase_rows[] | select(.forge_id=="m-60/203") | .progress')
180
+ if [ "$p200" = "done" ] && [ "$p201" = "in_progress" ] && [ "$p203" = "planned" ]; then
181
+ ok "cursor derivation: 200=done, 201=in_progress, 203=planned"
182
+ else
183
+ bad "cursor-derivation" "got 200=[$p200] 201=[$p201] 203=[$p203]"
184
+ fi
185
+ fi
186
+
187
+ # ============================================================================
188
+ # Case: plan-parent-link — plan rows carry their parent phase's key.
189
+ # ============================================================================
190
+ if want_case plan-parent-link; then
191
+ parent=$(printf '%s' "$OUT" | jq -r '.phase_rows[] | select(.forge_id=="m-60/201/plan-01") | .parent')
192
+ if [ "$parent" = "m-60/201" ]; then ok "plan row m-60/201/plan-01 parents to m-60/201"
193
+ else bad "plan-parent-link" "got parent=[$parent]"; fi
194
+ fi
195
+
196
+ # ============================================================================
197
+ # Case: emit-deterministic — two consecutive --emit runs, no repo change,
198
+ # byte-identical output.
199
+ # ============================================================================
200
+ if want_case emit-deterministic; then
201
+ a="$(run_emit)"; b="$(run_emit)"
202
+ if [ "$a" = "$b" ] && [ -n "$a" ]; then ok "two --emit runs are byte-identical"
203
+ else bad "emit-deterministic" "runs differ or empty"; fi
204
+ fi
205
+
206
+ # ============================================================================
207
+ # Case: intent-verbatim — Intent is the roadmap status: value verbatim, never
208
+ # re-derived (phase 202's "next" — outside the {planned,complete} pair used
209
+ # elsewhere in this fixture — proves passthrough, not a coincidental match).
210
+ # ============================================================================
211
+ if want_case intent-verbatim; then
212
+ intent=$(printf '%s' "$OUT" | jq -r '.phase_rows[] | select(.forge_id=="m-60/202") | .intent')
213
+ if [ "$intent" = "next" ]; then ok "intent projected verbatim (next, unchanged)"
214
+ else bad "intent-verbatim" "got intent=[$intent]"; fi
215
+ fi
216
+
217
+ # ============================================================================
218
+ # Case: quoted-id-progress (issue #49) — the roadmap milestones[] entry for
219
+ # m-53 is spelled quoted/labeled (- id: "m-53"). Before the fix, unit_progress()
220
+ # only matched unquoted ids and silently rendered progress: "" despite m-53
221
+ # declaring phases. Assert a real N/M instead.
222
+ # ============================================================================
223
+ if want_case quoted-id-progress; then
224
+ prog=$(printf '%s' "$OUT" | jq -r '.units[] | select(.forge_id=="m-53") | .progress')
225
+ if [ "$prog" = "1/2" ]; then ok "quoted-id m-53 progress resolves to 1/2 (not empty)"
226
+ else bad "quoted-id-progress" "got progress=[$prog]"; fi
227
+ fi
228
+
229
+ # ============================================================================
230
+ # Case: bare-id-progress — regression guard for the 120 legacy bare-numeric
231
+ # roadmap.yml milestones[] entries (- id: 55): the tolerant match must not
232
+ # break what already worked.
233
+ # ============================================================================
234
+ if want_case bare-id-progress; then
235
+ prog=$(printf '%s' "$OUT" | jq -r '.units[] | select(.forge_id=="m-55") | .progress')
236
+ if [ "$prog" = "1/2" ]; then ok "bare-id m-55 progress unchanged at 1/2"
237
+ else bad "bare-id-progress" "got progress=[$prog]"; fi
238
+ fi
239
+
240
+ # ============================================================================
241
+ # Case: plan-db-inert — board.plan_database_id unset: --deliver leaves unit
242
+ # delivery untouched and logs one plan-grain skip line, no network (curl is a
243
+ # PATH stub — canned responses only, exercised end-to-end offline).
244
+ # ============================================================================
245
+ if want_case plan-db-inert; then
246
+ STUBBIN="$ROOT/bin"
247
+ CAPTURE="$ROOT/curl-capture.txt"
248
+ export CAPTURE
249
+ mkdir -p "$STUBBIN"
250
+ cat > "$STUBBIN/curl" <<'STUB'
251
+ #!/usr/bin/env sh
252
+ { printf '=== call ===\n'; for a in "$@"; do printf '%s\n' "$a"; done; } >> "$CAPTURE"
253
+ url=""
254
+ for a in "$@"; do case "$a" in https://*) url="$a" ;; esac; done
255
+ case "$url" in
256
+ */query) printf '{"object":"list","results":[]}\n200' ;;
257
+ *) printf '{"object":"page","id":"stub-page-x"}\n200' ;;
258
+ esac
259
+ STUB
260
+ chmod +x "$STUBBIN/curl"
261
+ : > "$CAPTURE"
262
+
263
+ cd "$REPO" && PATH="$STUBBIN:$PATH" env FORGE_NOTION_TOKEN=ci-env-fake-value-do-not-use \
264
+ sh "$RENDER" --deliver >/dev/null 2>"$ROOT/err.txt"
265
+ rc=$?
266
+
267
+ [ "$rc" -eq 0 ] && ok "deliver exits 0 with plan_database_id unset" || bad "plan-db-inert exit 0" "rc=$rc"
268
+
269
+ if grep -q 'plan grain: no plan_database_id' "$ROOT/err.txt"; then
270
+ ok "logs the plan-grain skip line"
271
+ else
272
+ bad "plan-grain skip log" "got: $(cat "$ROOT/err.txt")"
273
+ fi
274
+
275
+ # unit delivery untouched: every --emit unit forge_id still reaches curl
276
+ missing=""
277
+ for fid in $(cd "$REPO" && sh "$RENDER" --emit 2>/dev/null | jq -r '.units[].forge_id'); do
278
+ grep -q "$fid" "$CAPTURE" || missing="$missing $fid"
279
+ done
280
+ [ -z "$missing" ] && ok "unit delivery unchanged (all unit forge_ids still reached curl)" \
281
+ || bad "unit delivery unchanged" "forge_ids never delivered:$missing"
282
+
283
+ # zero interaction with the plan grain: no phase/plan forge_id, no Grain property
284
+ if grep -q 'm-60/2' "$CAPTURE"; then bad "no plan-grain forge_ids sent" "found one in curl traffic"
285
+ else ok "no plan-grain forge_ids reached curl"; fi
286
+ if grep -q '"Grain"' "$CAPTURE"; then bad "no Grain property sent" "found one in curl traffic"
287
+ else ok "no Grain property reached curl (plan grain never touched)"; fi
288
+ fi
289
+
290
+ printf '\n%s passed, %s failed\n' "$PASSED" "$FAILED"
291
+ [ "$FAILED" -eq 0 ]
@@ -0,0 +1,180 @@
1
+ # Migration Guide: roadmap self-description — milestone keys + Intent normalization (Forge 0.82.0)
2
+
3
+ Applies to projects upgrading to 0.82.0 or later whose `.forge/roadmap.yml` predates
4
+ the board's phase+plan grain work (m-57, research/milestone-57.md). Two mechanical
5
+ edits land on `.forge/roadmap.yml`: every `phases[]` entry gains an explicit
6
+ `milestone:` field naming its owning milestone, and every phase's `status:` field is
7
+ normalized to the closed Intent vocabulary `planned | next | active | deferred |
8
+ complete`. Neither edit is optional — phase 103 (phase-plan-projection)'s board
9
+ renderer reads both fields directly, so an unmigrated `roadmap.yml` renders the
10
+ Roadmap/In-flight views with blank or unpredictable Grain/Intent columns.
11
+
12
+ This is forge's own dogfooded worked example: this repo's `.forge/roadmap.yml` (96
13
+ phase entries) was migrated by this same procedure in the phase that shipped this
14
+ guide. The mechanics below generalize from that pass, plus the repeating-phase-id
15
+ case forge's own repo does not have but a fan-out repo like canvaz does.
16
+
17
+ ## Prerequisites
18
+
19
+ 1. On 0.82.0 or later (`npx forge-orkes upgrade`, or the `upgrading` skill) —
20
+ `.forge/checks/forge-roadmap-check.sh` must exist so step 4 below can validate
21
+ the result.
22
+ 2. Working tree clean or changes committed — this migration is a mechanical text
23
+ edit across every `phases[]` entry in `.forge/roadmap.yml`, plus (if stale) one
24
+ `board.database_id` line in `.forge/project.yml`.
25
+ 3. Read `.forge/roadmap.yml`'s `milestones:` list once before editing — it is the
26
+ ONLY authoritative source for which milestone owns which phase id. Do not guess
27
+ ownership from comments, directory names, or proximity in the file.
28
+
29
+ ## Detection
30
+
31
+ Keyed on any `phases[]` entry lacking a `milestone:` field. Tolerant of a missing
32
+ file, a non-git tree, or an already-migrated repo (silent, exit 0 in all three
33
+ cases). Scoped to the `phases:` block only — the `milestones:` list's own `- id:`
34
+ entries are a different, unrelated shape and are never counted:
35
+
36
+ ```bash
37
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
38
+ [ -f .forge/roadmap.yml ] || exit 0
39
+
40
+ missing="$(awk '
41
+ BEGIN { in_phases=0; open=0; has_ms=1; count=0 }
42
+ /^ phases:$/ { in_phases=1; next }
43
+ /^ coverage_verified:/ { in_phases=0 }
44
+ in_phases && /^ - id:/ {
45
+ if (open && !has_ms) count++
46
+ open=1; has_ms=0
47
+ next
48
+ }
49
+ in_phases && open && /^ milestone:/ { has_ms=1 }
50
+ END { if (open && !has_ms) count++; print count }
51
+ ' .forge/roadmap.yml)"
52
+
53
+ [ -n "$missing" ] && [ "$missing" -gt 0 ] 2>/dev/null || exit 0
54
+
55
+ echo "MIGRATE — $missing phases[] entries in .forge/roadmap.yml have no milestone: field; see .forge/migrations/0.82.0-roadmap-self-description.md"
56
+ ```
57
+
58
+ ## Migration steps
59
+
60
+ ### 1. Derive each phase's owning milestone from `milestones:` — never guess
61
+
62
+ `.forge/roadmap.yml`'s `phases:` list is flat: phase ids are unique only within a
63
+ single milestone's line-of-work, not globally. **In a repo where planning
64
+ allocated ids per-milestone starting from 1 each time (the canvaz shape), phase
65
+ `id: 1` repeats once per milestone — e.g. canvaz's `id: 1` appears 29 times, `id:
66
+ 2` appears 24 times** — disambiguated today only by a human-readable comment or
67
+ proximity to a `# --- m-N ---` divider, which is exactly the ambiguity this field
68
+ removes. (Forge's own repo does not have this problem — `.forge/checks/forge-reserve.sh`
69
+ allocates phase ids from a single ledger, so every id here is already globally
70
+ unique — but the `milestone:` field ships on every repo's roadmap regardless,
71
+ because self-description should not depend on which id-allocation convention a
72
+ given repo happens to use.)
73
+
74
+ The `milestones:` list's `phases: [...]` array is the ONLY authoritative map. For
75
+ each milestone entry:
76
+
77
+ ```yaml
78
+ - id: 7
79
+ name: "Some Milestone"
80
+ phases: [23, 24, 25]
81
+ ```
82
+
83
+ every id in that array gets `milestone: "7"` (as a quoted string, matching the
84
+ convention already used for named milestone ids like `"m-33"`). Concretely, for
85
+ each `phases[]` entry:
86
+
87
+ 1. Find the milestone whose `phases: [...]` array (one-line flow, wrapped flow, or
88
+ block style — `forge-roadmap-check.sh` already parses all three shapes) contains
89
+ this phase's id.
90
+ 2. Insert `milestone: "<that milestone's id>"` as a new line directly after the
91
+ phase's `name:` line, before `milestone_dir:` (or `goal:` if the entry has no
92
+ `milestone_dir:`). Quote the value even when the milestone id is a bare number —
93
+ `milestone: "7"`, not `milestone: 7` — so numeric and named ids (`"m-33"`) render
94
+ identically wherever this field is read.
95
+ 3. **Every phase id must resolve to exactly one milestone.** If a phase id appears
96
+ in zero or more than one milestone's `phases:` array, stop and flag it to the
97
+ repo's operator instead of guessing — that is a pre-existing roadmap defect this
98
+ migration should surface, not silently paper over. (`forge-roadmap-check.sh`'s
99
+ `orphan-phase` and `dangling-phase-ref` findings catch exactly this class; run it
100
+ before this migration if you suspect drift.)
101
+ 4. Do not renumber, reorder, reindent, or touch any other field on any phase or
102
+ milestone entry. This is a shared-mutable file (other milestones' in-flight work
103
+ may be reading it concurrently) — the only permitted diff per entry is the one
104
+ inserted `milestone:` line plus the `status:` value substitution in step 2 below.
105
+
106
+ ### 2. Normalize every `status:` value to the closed Intent vocabulary
107
+
108
+ The Intent vocabulary is a closed set: `planned | next | active | deferred |
109
+ complete`. A drift survey (research/milestone-57.md, canvaz's 107-entry roadmap)
110
+ found seven distinct values in the wild. Apply this map verbatim to every
111
+ `phases[]` entry's `status:` field — nothing else:
112
+
113
+ | Observed value | Normalized to | Note |
114
+ |---|---|---|
115
+ | `planned` | `planned` | unchanged — already in the closed set |
116
+ | `pending` | `planned` | most common drift; "not yet started" reads as `planned` |
117
+ | `complete` | `complete` | unchanged — already in the closed set |
118
+ | `deferred` | `deferred` | unchanged — already in the closed set |
119
+ | `verified` | `complete` | a finished phase, whatever verb described it |
120
+ | `planning` | `active` | in-progress work, whichever gerund named the stage |
121
+ | `executing` | `active` | in-progress work, whichever gerund named the stage |
122
+
123
+ If your repo's `roadmap.yml` carries a `status:` value not in this table (this
124
+ repo's own migration hit exactly one: a stray `done`, on a single phase, not
125
+ anticipated by the survey above) — treat any other terminal-sounding value
126
+ (`done`, `shipped`, `finished`, …) the same way `verified` is treated: it collapses
127
+ to `complete`. Do not invent a sixth Intent value; the set is closed by design (the
128
+ board's Intent column is a Notion Select over exactly these five). A trailing `#
129
+ prose` comment after a status value moves to a comment line directly above the
130
+ `status:` field rather than being dropped — the normalized line itself must be bare
131
+ (`status: <value>`, nothing after it but optional whitespace).
132
+
133
+ ### 3. Check `board.database_id` in `project.yml` points at the live workspace
134
+
135
+ If this repo has a `board:` block in `.forge/project.yml` (ADR-025 — absent means
136
+ this step is inert, skip it), confirm `database_id` names the Notion database in
137
+ the workspace you actually intend to render into, not one that was abandoned or
138
+ replaced. This class of drift is easy to miss because **attended renders (an
139
+ interactive Claude Code session with the Notion MCP server) mask a stale id** — the
140
+ session's cached `.forge/notion/map.yml` page-ids keep resolving even after the
141
+ configured database itself has drifted, since the MCP calls key off page ids, not
142
+ the database id, once a row's `notion_page_id` is already cached. The gap only
143
+ surfaces in `forge-board-render.sh --deliver` (headless, CI, no MCP session, no
144
+ cached map) — which would write into the wrong (possibly dead) workspace with no
145
+ attended session present to notice.
146
+
147
+ ```bash
148
+ grep -n -A5 "^board:" .forge/project.yml
149
+ ```
150
+
151
+ Confirm `database_id` against the database you can actually open in the Notion
152
+ workspace you're rendering into today, not one recorded from an earlier workspace
153
+ migration. If it's stale, update it by hand — this is a one-line config edit, not
154
+ something this migration automates (the correct id can only come from the
155
+ operator, who knows which workspace is live).
156
+
157
+ ### 4. Re-run the roadmap integrity check
158
+
159
+ ```bash
160
+ .forge/checks/forge-roadmap-check.sh
161
+ ```
162
+
163
+ Compare its `finding=` line count against what it printed before you started this
164
+ migration. This migration must not introduce any NEW finding — pre-existing
165
+ findings unrelated to this change (e.g. a dangling wave reference from an earlier
166
+ drift) are not this migration's job to fix and may remain.
167
+
168
+ ## Validation
169
+
170
+ - `grep -c '^ milestone: ' .forge/roadmap.yml` matches the number of
171
+ `phases[]` entries (`grep -c '^ - id:' .forge/roadmap.yml` restricted to the
172
+ `phases:` block — every phase, no more, no fewer).
173
+ - No `status:` value under a `phases[]` entry falls outside `planned | next |
174
+ active | deferred | complete`.
175
+ - `.forge/checks/forge-roadmap-check.sh` exits with the same finding count (or
176
+ fewer) as before the migration — never more.
177
+ - If `board:` is configured, `database_id` names a database you can open today in
178
+ the live Notion workspace.
179
+ - Re-running the Detection block above now prints nothing (exits silently) — the
180
+ gap is closed.