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.
- package/bin/create-forge.js +154 -20
- package/package.json +1 -1
- package/template/.claude/hooks/forge-size-gate.sh +26 -0
- package/template/.claude/hooks/tests/forge-size-gate.test.sh +28 -0
- package/template/.claude/settings.json +1 -1
- package/template/.claude/skills/notion-integration/SKILL.md +10 -6
- package/template/.forge/checks/forge-board-render.sh +231 -3
- package/template/.forge/checks/tests/forge-board-render.test.sh +291 -0
- package/template/.forge/migrations/0.82.0-roadmap-self-description.md +180 -0
package/bin/create-forge.js
CHANGED
|
@@ -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
|
|
299
|
-
* any legacy embedded forge section out in one pass.
|
|
300
|
-
*
|
|
301
|
-
*
|
|
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
|
|
307
|
-
*
|
|
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
|
|
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
|
|
348
|
-
|
|
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 —
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
@@ -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
|
# ===========================================================================
|
|
@@ -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
|
|
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` (
|
|
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
|
|
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 (
|
|
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`) —
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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.
|