forge-orkes 0.58.3 → 0.61.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.58.3",
3
+ "version": "0.61.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"
@@ -24,6 +24,16 @@
24
24
  # SLICE_RESULT phases=<completed> touches=<operator-touches> last_ping=<name>
25
25
  # All human/progress logging goes to stderr.
26
26
  #
27
+ # PARK -> ANSWER -> RESUME (the operator loop). When a phase parks (verdict=park) with an
28
+ # interrupt question, the operator answers by writing an ANSWER FILE at the conventioned path
29
+ # <work-dir>/phase-<N>/answer.md (beside that phase's phase-report.yml; N = phase index)
30
+ # On the next invocation the runner re-assembles THAT phase's prompt with its own parked report
31
+ # + the answer file, so the fresh-context re-run sees what it asked and the answer, and resumes
32
+ # past the ambiguity instead of replaying cold. A fresh park AFTER a new answer is a NEW ping
33
+ # (the park dedup key folds in the answer's cksum fingerprint); a crash-resume with the same
34
+ # answer state re-pings nothing. Files-only + recompute-from-disk still hold (Fork 3 / NFR-028):
35
+ # the answer file is another on-disk input, not runner-resident state.
36
+ #
27
37
  # Pure POSIX sh — no arrays, no bashisms, dependency-free (NFR-026).
28
38
  set -eu
29
39
 
@@ -378,6 +388,26 @@ for phase in $PHASES; do
378
388
  done
379
389
  fi
380
390
 
391
+ # Resume context: if THIS phase already parked on a previous run, show it its OWN parked
392
+ # report + the operator's answer (if written) so the fresh-context re-run resumes past the
393
+ # ambiguity instead of replaying cold (the park->answer->resume loop). Read $report BEFORE
394
+ # the launch below overwrites it — assembly runs first, so $report still holds the prior
395
+ # (parked) report at this point. Answer file convention: $wdir/answer.md (see header).
396
+ if [ -f "$report" ] && [ "$(report_field "$report" verdict)" = "park" ]; then
397
+ printf '## Your previous park on this phase (you asked; the operator may have answered)\n\n'
398
+ printf 'You parked here on a prior run. Your prior phase-report.yml:\n\n'
399
+ sed 's/^/ /' "$report"
400
+ printf '\n'
401
+ if [ -f "$wdir/answer.md" ]; then
402
+ printf "### The operator's answer\n\n"
403
+ cat "$wdir/answer.md"
404
+ printf '\n\nUse this answer to proceed. Do NOT re-park on the same question once it is resolved;\n'
405
+ printf 'park again only if a genuinely NEW ambiguity/decision remains.\n\n'
406
+ else
407
+ printf 'No answer file yet at %s — if you still cannot proceed, park again.\n\n' "$wdir/answer.md"
408
+ fi
409
+ fi
410
+
381
411
  printf '## Repo state (slice worktree)\n\n'
382
412
  printf '```\n'
383
413
  git -C "$WORKTREE" log --oneline -5 2>/dev/null || true
@@ -564,7 +594,14 @@ for phase in $PHASES; do
564
594
 
565
595
  case "$interrupt" in
566
596
  ambiguity|irreversible|fork|budget)
567
- slice_notify "$interrupt" "$phase" "$ipayload"
597
+ # Answer-aware dedup key: fold the answer file's fingerprint into the ping key so a
598
+ # re-park AFTER a new answer fires a FRESH ping (new key), while a crash-resume in the
599
+ # SAME answer state stays suppressed (unchanged key — preserves the crash-dedup intent).
600
+ # `none` when unanswered. cksum is POSIX + dependency-free (NFR-026). Scoped to THIS
601
+ # (phase-self-park) branch only — refresh/divergence/budget/halt/done keys are untouched.
602
+ _ans_fp=none
603
+ [ -f "$wdir/answer.md" ] && _ans_fp="$(cksum "$wdir/answer.md" 2>/dev/null | cut -d' ' -f1)"
604
+ slice_notify "$interrupt" "$phase-a$_ans_fp" "$ipayload"
568
605
  printf '[runner] phase %s: PARKED — §8 %s interrupt (stopping before next phase)\n' "$phase" "$interrupt" >&2
569
606
  printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
570
607
  exit 3
@@ -142,7 +142,7 @@ Document in `.forge/phases/milestone-{id}/{phase}-{name}/contracts/`.
142
142
 
143
143
  1. **Persist** — Confirm ADRs in `.forge/decisions/`, models and contracts in `.forge/phases/milestone-{id}/{phase}-{name}/`
144
144
  2. **Update state** — Set `current.status` to `planning` in `.forge/state/milestone-{id}.yml`
145
- 3. **State-sync commit** (State Commit Protocol): `git add .forge/` then `git commit -m "chore(forge): sync state after architecting — m{N} {phase-name}"` (scoped; never `git add .`).
145
+ 3. **State-sync commit** (State Commit Protocol): `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit -m "chore(forge): sync state after architecting — m{N} {phase-name}"` (worktree form — recorded `lifecycle.worktree_path` is authoritative over cwd; see FORGE.md State Commit Protocol. Scoped; never `git add .`).
146
146
  4. **Recommend context clear:**
147
147
 
148
148
  *"Architecture decisions written and synced. `/clear` then `/forge` to continue with planning."*
@@ -106,6 +106,22 @@ so `index.yml` may be slightly stale; read it as-is when deriving implicit-row
106
106
  coverage and suggest a `/forge` pass if it looks off. The Chief writes `active.yml`
107
107
  (derived class); it never writes `index.yml` or any milestone file.
108
108
 
109
+ ### Board render + inbox (attended)
110
+
111
+ After the Stream Rollup on **Show** and **Sync**, if `board.enabled: true` in
112
+ `project.yml` **and** a Notion MCP server is present, run the board **render** —
113
+ the `notion-integration` skill's `render()` (`forge-board-render.sh --emit`, then
114
+ upsert agent-owned props by `Forge ID` over the session MCP), the same attended
115
+ transport `forge` boot uses — then `pull_inbox()`: if the board holds Forge-ID-less
116
+ rows, surface ONE advisory line — *"inbox has N items, top: {X} — take one in?"* —
117
+ ordered by the operator's `Priority`/`Queue Position`. This rides the **existing**
118
+ intake moment (no new ceremony — the Gate-6 named risk); taking an item in is the
119
+ normal act (`EnterWorktree` + milestone declaration), and nothing materializes
120
+ silently (M21). **Advisory, non-blocking** (FR-175): unconfigured, MCP absent, or a
121
+ call errors → log one line and continue; a render or inbox read never blocks Show,
122
+ Sync, or any merge. Board disabled or no MCP → silent. The unattended render is
123
+ `forge-board-render.sh --deliver` under post-merge CI (R11), never here.
124
+
109
125
  ## Start Stream Flow
110
126
 
111
127
  1. Read the registry.
@@ -338,5 +338,5 @@ If milestone exists → skip to Step B.
338
338
  - Add any unresolved items to `## Needs Resolution`
339
339
  - If `context.md` already exists (post-planning discussion), update relevant sections + log amendments
340
340
  2. **Update state** -- `current.status` = `planning` (`architecting` for Full) in milestone yml
341
- 3. **State-sync commit** (State Commit Protocol): `git add .forge/` then `git commit -m "chore(forge): sync state after discussing — m{N} {name}"` (scoped; never `git add .`).
341
+ 3. **State-sync commit** (State Commit Protocol): `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit -m "chore(forge): sync state after discussing — m{N} {name}"` (worktree form — recorded `lifecycle.worktree_path` is authoritative over cwd; see FORGE.md State Commit Protocol. Scoped; never `git add .`).
342
342
  4. **Recommend clear:** *"State synced and verified ({N} decisions in context.md). `/clear` then `/forge` for {planning/architecting}."*
@@ -10,10 +10,17 @@ description: "Build to plan with atomic commits, deviation rules, and context en
10
10
  - [ ] Context.md locked decisions noted
11
11
  - [ ] Constitution.md gates satisfied
12
12
  - [ ] Milestone state updated to `status: executing`
13
+ - [ ] Worktree cwd verified — git ops pinned to the recorded path (see below)
13
14
  - [ ] Workspace prerequisites met — submodules populated (see below)
14
15
  - [ ] Baseline snapshot captured (see below)
15
16
  - [ ] Plan anchors re-derived against HEAD (see below)
16
17
 
18
+ ## Worktree cwd discipline
19
+
20
+ Run **before the first write/commit**. In a worktree session the process cwd can silently reset to the launch (main) checkout across a turn boundary; relative-path git ops then hit the WRONG checkout — and often **exit 0** (a stale local `main` HEAD == origin makes `git push origin HEAD:main` report "up-to-date"; `git add {worktree-only file}` fails but a sibling op succeeds), so the failure is silent.
21
+
22
+ Guard: read the recorded `lifecycle.worktree_path` from `state/milestone-{id}.yml`. Before the first write, compare it to `git rev-parse --show-toplevel`; on mismatch either `cd` back or — the robust default — **pin every git op with `git -C {worktree_path}`** (and use absolute paths for Write/Edit). The recorded path is authoritative (FORGE.md → "Recorded path is authoritative"). Same discipline the State Commit Protocol applies at handoff.
23
+
17
24
  ## Workspace Prerequisites
18
25
 
19
26
  Run **first, before the baseline snapshot** — a missing prerequisite makes the baseline's build/test commands fail for reasons that have nothing to do with the plan.
@@ -284,11 +291,12 @@ Once a task's verification bookkeeping settles (3-Strike result known, declared-
284
291
  | Context 40-60% | Consider fresh agent |
285
292
  | Context under 40% | Continue normally |
286
293
  | Switching unrelated subsystems | Spawn fresh agent |
287
- | Task carries ANY `complexity` tag (COST, M24) | Spawn a fresh executor on its resolved model — even under 40% context. Only an UNTAGGED glue task runs inline on the parent model. |
294
+ | Task carries a `complexity` tag whose resolved model **differs from the live session model** (COST, M24) | Spawn a fresh executor on that resolved model — even under 40% context (real routing). |
295
+ | Task carries a `complexity` tag but resolves to the **live session model** (e.g. no `models:` block) | Spawn changes nothing — treat the tag as a context signal only: spawn **iff** a row above fires. Otherwise run inline. |
288
296
 
289
- **Spawn via Agent tool** with: relevant plan details, specific files, locked decisions from context.md, clear success criteria. **Resolve the model PER TASK**, not once per skill run: `models.by_complexity.{task.complexity}` → `models.skills.executing` → `models.default` → parent (complexity overrides skill — FORGE.md → Model Routing). Display per spawn: *"Spawning executor with model: {model} (from {source})"*
297
+ **Spawn via Agent tool** with: relevant plan details, specific files, locked decisions from context.md, clear success criteria. **Resolve the model PER TASK**, not once per skill run: `models.by_complexity.{task.complexity}` → `models.skills.executing` → `models.default` → parent (complexity overrides skill — FORGE.md → Model Routing). **Then compare the resolved model to the live session model** (the model this session is actually running on): differ → spawn (mandatory routing); equal → the tag is a context signal, not a routing obligation. Display per spawn: *"Spawning executor with model: {model} (from {source})"*
290
298
 
291
- **Why the tag gates the spawn (enforced subagent spawning).** An inline task inherits the parent session's model and cannot be re-routedrouting only has teeth if every tagged task actually spawns. Spawning carries fixed briefing overhead, which is why the rule keys on the *tag* (a deliberate plan-time signal the work is worth routing) rather than "spawn everything": the cheapest untagged glue work stays inline. A full parent-as-orchestrator model (spawn everything, parent never implements) is a deferred follow-up, to be justified by M24 outcome-log evidence not implemented here.
299
+ **Why the tag gates the spawn (predicate A — the spawn must change the model).** Routing only *does* something when a task's resolved model differs from the **live session model**; a same-model spawn is pure briefing overhead m-30 burned ~315k subagent tokens spawning tasks that resolved straight back to the parent model for zero routing benefit. So the mandatory spawn fires only when resolving actually swaps the model (which requires a `models:` block whose resolution lands on a different tier than the one this session runs on). With **no `models:` block** every resolved model equals the parent == the live session model, so tagged tasks run inline unless a context trigger (20+ files / deep subsystem / >40%) independently calls for a fresh agent. The tag still marks work "worth routing," and an inline task genuinely cannot be re-routed — but that leak only matters when a different model was actually available, which is exactly the resolved-≠-live case the gate keeps mandatory. (A full parent-as-orchestrator model spawn everything, parent never implements remains a deferred follow-up, justified by M24 outcome-log evidence, not implemented here.)
292
300
 
293
301
  ## Execution Summary
294
302
 
@@ -348,5 +356,5 @@ After **both** layer plans are committed, the executing flow owns one final **se
348
356
  1. Confirm persistence — summary documented, commits made, state updated, desire-path files written
349
357
  2. **Run the Cross-Layer Seam Check** (above) if this phase was a Tier-2 contract split
350
358
  3. Set `current.status` to `verifying` in `.forge/state/milestone-{id}.yml`
351
- 4. **State-sync commit** (State Commit Protocol): `git add .forge/` then `git commit -m "chore(forge): sync state after executing — m{N} {phase-name}"`. Scoped — never `git add .`. Per-task code commits already landed; this captures the cursor + desire-path files at the phase boundary.
359
+ 4. **State-sync commit** (State Commit Protocol): `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit -m "chore(forge): sync state after executing — m{N} {phase-name}"` (worktree form — in a worktree session the recorded path is authoritative over cwd; see Worktree cwd discipline). Scoped — never `git add .`. Per-task code commits already landed; this captures the cursor + desire-path files at the phase boundary.
352
360
  5. Recommend: *"Tasks committed, state synced. `/clear` then `/forge` to continue with verifying."*
@@ -124,7 +124,7 @@ Uses only signals that stay current (`current.phase`, `current.status`, the road
124
124
 
125
125
  Beads enabled (`forge.beads_integration: true`) → `bd prime`. Optional.
126
126
 
127
- Notion adapter (opt-in): `notion.enabled: true` in `project.yml` **and** a Notion MCP server present → log *"Notion adapter active"* and (Phase 2) run `pull_inbox()` (`notion-integration` skill); config without MCP → log *"Notion adapter inactive (MCP absent)"*; neither → silent. When active, `project()` also fires at each phase **handoff** (the State Commit Protocol checkpoint) to push agent-owned state to the board. **Advisory — never blocks boot, handoff, or commit.**
127
+ Notion board adapter (opt-in): `board.enabled: true` in `project.yml` **and** a Notion MCP server present → log *"board render (attended)"* and, **after the State + Stream rollups**, run the board **render** — the `notion-integration` skill's `render()` (`forge-board-render.sh --emit`, then upsert agent-owned props by `Forge ID` over the session MCP) then `pull_inbox()` (the backlog inbox): if the board holds Forge-ID-less rows, surface ONE advisory line — *"inbox has N items, top: {X} — take one in?"* — ordered by the operator's `Priority`/`Queue Position`. This rides the **existing** intake moment (no new ceremony — the Gate-6 named risk); taking an item in is the normal act (`EnterWorktree` + milestone declaration, Step 3), and nothing materializes silently (M21). Config without MCP → log *"board adapter inactive (MCP absent)"*; neither → silent. When active, `render()` also fires at each phase **handoff** (the State Commit Protocol checkpoint) to push agent-owned state to the board. **Advisory — never blocks boot, handoff, or commit** (FR-175): an emit/render failure logs one line and boot continues. (The unattended render is `forge-board-render.sh --deliver` under post-merge CI — R11, not a boot concern.)
128
128
 
129
129
  Read `.forge/context.md`. **Needs Resolution** unchecked → warn: *"{N} unresolved discrepancies."* Quick proceeds; Standard/Full blocked at planning.
130
130
 
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: notion-integration
3
+ description: "Use when rendering Forge state to the Notion board or reading its backlog inbox: 'render the board', 'project Forge state to Notion', 'read the backlog inbox', 'sync notion'. Opt-in adapter — requires a Notion MCP server (attended) or a scoped token (headless) AND a board: block in project.yml. Inert when either is absent."
4
+ ---
5
+
6
+ # The board (Notion adapter)
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).
9
+
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
+
12
+ ## Prerequisites
13
+
14
+ The adapter activates only when both hold (else fully inert — zero behavior change at boot, handoff, or commit):
15
+
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`).
18
+
19
+ ## Degradation contract — non-blocking by construction
20
+
21
+ The adapter is **advisory everywhere**. Unconfigured, MCP/token absent, offline, or a call errors → **log one line and continue**. It **never** blocks a phase handoff, a `forge` boot, a commit, a fold, a merge, or a slice. A failed render leaves the board stale; the next successful render reconciles. (This is what makes acceptance test #5 hold by construction.)
22
+
23
+ ## One compute, two transports (ADR-025)
24
+
25
+ The single source of truth for *what the board shows* is the projection builder, **`forge-board-render.sh`**:
26
+
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
+ - **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).
30
+
31
+ Because both deliver the identical `--emit` object, there is no second source of truth for the projection.
32
+
33
+ ## Data model
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.
36
+
37
+ ### Field ownership — the collision-proof invariant (ADR-019, kept verbatim)
38
+
39
+ 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
+
41
+ - **Agent-owned** (overwritten every render): `Forge ID`, `Name`, `Layer`, `Status`, `Tier`, `Progress`, `Blockers`, `Parent`, `Rollup Summary`, `Release State`, `Repo`, `Last Rendered`.
42
+ - **Human-owned** (read as inbox proposals in `pull_inbox`, **never** written): `Priority`, `Queue Position`, `Triage Notes`, `Comments`.
43
+
44
+ **No gate-read property exists.** Nothing on the board is read by any merge/deploy/promotion/fold path (acceptance test #6).
45
+
46
+ ### Identity binding (ADR-020)
47
+
48
+ `Forge ID` is the **binding of record** — a text property set **once at create**, never re-projected. `.forge/notion/map.yml` (`forge-id → page-id`) is a **derived, gitignored** cache. Page resolution for a unit `U`:
49
+
50
+ 1. **map cache** → page id.
51
+ 2. **miss** → query Notion for `Forge ID == U.forge_id`; found → write the pair back (self-heal).
52
+ 3. **not found** → create the page, stamp `Forge ID`, record the pair.
53
+
54
+ Deleting `map.yml` never duplicates rows — step 2 re-binds. `map.yml` is derived (ADR-011 class): only the main/orchestrator session writes it; worktrees never hand-edit it.
55
+
56
+ ## Operations
57
+
58
+ ### `render()` — attended: push agent-owned state out (the status window)
59
+
60
+ **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.
61
+
62
+ **Precondition:** `board.enabled: true` + a Notion MCP present. Else no-op (log *"board adapter inactive (MCP absent)"*).
63
+
64
+ **Procedure:**
65
+
66
+ 1. Run `forge-board-render.sh --emit` → the projection JSON. (If it errors, log and stop — non-blocking.)
67
+ 2. For each `unit` in `projection.units`:
68
+ a. **Resolve** its page by the resolution order above (map → query → create), keyed by `unit.forge_id`.
69
+ b. **Upsert ONLY agent-owned properties** by `Forge ID`: `Name`, `Layer`, `Status` (per the Layer's vocabulary, ADR-015), `Tier`, `Progress`, `Blockers`, `Parent`, `Rollup Summary`, **`Release State`** (= `unit.release_state`), **`Repo`** (= `projection.repo`), `Last Rendered` = now.
70
+ c. **Never** write `Priority`, `Queue Position`, `Triage Notes`, or `Comments`.
71
+ d. On create, record `forge-id → page-id` in `.forge/notion/map.yml`.
72
+ 3. **Deferred view:** render `projection.deferred[]` as a Notion view/callout (the three surfaces the `deferred` skill aggregates). This is rendered, not stored — no fourth storage surface.
73
+
74
+ **Overwrite-on-render** is automatic: every render re-upserts the agent-owned props, so a hand-edited status cell is corrected next time (acceptance test #1). **Idempotent:** re-running with unchanged state is a no-op write — identity binding guarantees update-not-duplicate.
75
+
76
+ **Failure / offline:** skip, leave the map as-is; the next successful render reconciles. **Never blocks.**
77
+
78
+ ### `pull_inbox()` — attended: read the backlog inbox (proposals only, never materialize)
79
+
80
+ **Trigger:** `forge` boot, `chief-of-staff` Show/Sync — the same attended moments as `render()` — plus on demand via `sync()`. This is the **one** direction board data flows IN, and even then only as a *proposal* (D-D/M21): reading the inbox is read-and-surface, never an approval or a write.
81
+
82
+ **Precondition:** `board.enabled: true` + a Notion MCP present. Else no-op (one logged line, non-blocking — FR-175).
83
+
84
+ **Procedure:**
85
+
86
+ 1. Query the board for two things, both **read-only**:
87
+ a. **Reprioritizations** — human-owned `Priority` / `Queue Position` on existing (Forge-ID-bound) rows. Use them to ORDER the proposal only; **never** write them back (human-owned, per the ownership table).
88
+ b. **New drops** — rows with **no `Forge ID`**. These are inbox items the operator authored.
89
+ 2. **Surface, don't act.** If (b) is non-empty, emit a SINGLE proposal line — `inbox has {N} items, top: {highest-priority title}` — ordered by the (a) reprioritization, then STOP. Reading the inbox **never writes repo state**: no milestone, worktree, file, reservation, or `map.yml` entry comes into being as a side effect of the read.
90
+ 3. **Intake is the existing act.** An item enters repo state **only** when the operator explicitly takes it in through the **normal intake act** — the existing worktree + milestone-declaration flow (`EnterWorktree` + the stream/milestone declaration, `forge` Step 3 / `chief-of-staff`). The inbox adds **no second intake mechanism** (Gate-6 named-risk guard).
91
+ 4. On intake, the item's row gets a `Forge ID` (ADR-020) and thereafter renders as agent-owned **status** — it is no longer an inbox draft.
92
+
93
+ **Nothing in the inbox materializes silently (M21).** The board is a projection + inbox, never an approval surface: an inbox drop is a proposal the operator takes in at an existing intake moment, or ignores — the framework never turns a board row into repo state on its own.
94
+
95
+ **Failure / offline:** board disabled, MCP absent, or a read error → skip with one logged line. **Never blocks** boot, Show/Sync, or any work.
96
+
97
+ ### `sync()` — manual escape hatch
98
+
99
+ Explicit `sync` command → run `pull_inbox()` then `render()` once, on demand — same preconditions and non-blocking posture as both. For when the operator wants a fresh board push + inbox read outside the automatic boot / Show/Sync moments.
100
+
101
+ ## Credential safety
102
+
103
+ The Notion secret lives **only** in the Notion MCP server's config (attended) or the env var named by `board.ci_token_env` (headless). This skill — and every operation it defines — must **never** read, store, echo, or project that secret into `.forge/` (which is committed to git). The agent reaches Notion exclusively through MCP tool calls (attended) that already carry the credential; the headless script reads the token from the environment, never from a file.
@@ -452,5 +452,5 @@ Done when approved.
452
452
  1. **Persist** -- plans `.forge/phases/`, reqs `.forge/requirements/m{N}.yml`, roadmap `.forge/roadmap.yml`, context `.forge/context.md`
453
453
  2. **Reserve version (if delivery bumps the version)** -- if any plan in this milestone will bump `package.json`/the project version, append ONE entry to `.forge/releases.yml` (Version Reservation Protocol, FORGE.md): pull first, set `version` = highest reserved `max()` bumped by this change's semver level, append `{milestone, version, bump, reserved_at, summary}`. **Never edit prior entries.** No version bump in scope → skip. Surface the reserved number to the user.
454
454
  3. **State** -- `current.status` = `executing` in `.forge/state/milestone-{id}.yml`
455
- 4. **State-sync commit** (State Commit Protocol): `git add .forge/` then `git commit -m "chore(forge): sync state after planning — m{N} {phase-name}"` (scoped; never `git add .`). The `releases.yml` reservation rides in this commit — **push it** so a parallel session sees the number taken before they reserve.
455
+ 4. **State-sync commit** (State Commit Protocol): `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit -m "chore(forge): sync state after planning — m{N} {phase-name}"` (worktree form — recorded `lifecycle.worktree_path` is authoritative over cwd; see FORGE.md State Commit Protocol. Scoped; never `git add .`). The `releases.yml` reservation rides in this commit — **push it** so a parallel session sees the number taken before they reserve.
456
456
  5. *"Plan written and synced. `/clear` then `/forge` to continue."*
@@ -75,7 +75,7 @@ Invoked via forge routing (mid-workflow or refactor-backlog item with milestone
75
75
  1. Read `.forge/state/milestone-{id}.yml` for current position
76
76
  2. Follow standard workflow (above)
77
77
  3. **If this task worked a refactor-backlog item:** in `.forge/refactor-backlog.yml` set that item's `status: done`, write `completed` (today, ISO) + `completed_by` (commit sha / one-line), THEN run the same **compaction-on-write** as `reviewing` Step 7 → "Status vocab + compaction-on-write" (normalize statuses → archive terminal items to `.forge/refactor-backlog-archive.yml` → leave only actionable items). Compaction is idempotent. (See reviewing Step 7 for the canonical rule — not repeated here.) This closes the loop: a worked item leaves the working backlog instead of lingering as `pending`.
78
- 4. After commit: update `milestone-{id}.yml` — advance the `current` cursor if the task moved position and set `current.last_updated`; log deviations if any Rule 1-3 applied. Do **not** write a progress percent (derived on read by `forge`) and do **not** write `index.yml` (derived). Then **state-sync commit**: `git add .forge/` && `git commit -m "chore(forge): sync state after quick-task — m{N}"` (scoped; never `git add .`).
78
+ 4. After commit: update `milestone-{id}.yml` — advance the `current` cursor if the task moved position and set `current.last_updated`; log deviations if any Rule 1-3 applied. Do **not** write a progress percent (derived on read by `forge`) and do **not** write `index.yml` (derived). Then **state-sync commit**: `git -C {worktree_path} add .forge/` && `git -C {worktree_path} commit -m "chore(forge): sync state after quick-task — m{N}"` (worktree form — recorded `lifecycle.worktree_path` is authoritative over cwd; see FORGE.md State Commit Protocol. Scoped; never `git add .`).
79
79
  5. Report: fix description, files changed, current position
80
80
 
81
81
  ### Without Milestone
@@ -128,7 +128,7 @@ Artifact uses the Finding Format above, with two adjustments: prepend `Date: {YY
128
128
 
129
129
  1. **Write artifact** (see Research Artifact section above).
130
130
  2. **Update state** — Set `current.status` to `discussing` in `.forge/state/milestone-{id}.yml`
131
- 3. **State-sync commit** (State Commit Protocol): `git add .forge/` then `git commit -m "chore(forge): sync state after researching — m{N} {phase-name}"` (scoped; never `git add .`).
131
+ 3. **State-sync commit** (State Commit Protocol): `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit -m "chore(forge): sync state after researching — m{N} {phase-name}"` (worktree form — recorded `lifecycle.worktree_path` is authoritative over cwd; see FORGE.md State Commit Protocol. Scoped; never `git add .`).
132
132
  4. **Recommend context clear:**
133
133
 
134
134
  *"Research complete. State synced. `/clear` then `/forge` to continue with discussing."*
@@ -32,6 +32,8 @@ Read: .forge/refactor-backlog.yml → existing backlog items (if any)
32
32
  Read: .forge/deferred-issues/*.md → pre-existing failures logged during execution (glob the dir; else legacy .forge/deferred-issues.md)
33
33
  ```
34
34
 
35
+ **Worktree cwd guard.** In a worktree session, before the state-sync commit (or any git op), compare `git rev-parse --show-toplevel` to the recorded `lifecycle.worktree_path`; on mismatch, pin every git op with `git -C {worktree_path}`. The cwd silently resets to the main checkout across turns and a wrong-cwd git op can exit 0 (silent). Same discipline the State Commit Protocol applies.
36
+
35
37
  Skip by stack: no DB->SQL/NoSQL N/A, no frontend->XSS N/A, no CI/CD->Pipeline N/A.
36
38
 
37
39
  Diff start: git log milestone start -> fallback: first commit after prev milestone -> ask user.
@@ -412,5 +414,5 @@ If the milestone's phases produced `contract.md` files (planning Step 6.1 Tier 1
412
414
  3. **Run promoted-milestone completion hook** (above) if `milestone.origin` set
413
415
  4. **Run Contract Landing** (above) for any cross-layer phases — fold ratified contracts into their ADRs
414
416
  5. Set `current.status: complete` and `current.completed_at: "<ISO 8601 timestamp>"` in `milestone-{id}.yml`, then regenerate `index.yml` via the `forge` **Rollup**
415
- 6. **State-sync commit** (State Commit Protocol): `git add .forge/` then `git commit -m "chore(forge): sync state after reviewing — m{N} complete"` (scoped; never `git add .`).
417
+ 6. **State-sync commit** (State Commit Protocol): `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit -m "chore(forge): sync state after reviewing — m{N} complete"` (worktree form — recorded path is authoritative over cwd; see Worktree cwd guard). Scoped; never `git add .`.
416
418
  7. *"Milestone [{name}] complete. Report: `.forge/audits/milestone-{id}-health-report.md`. {N} backlog items. `/forge` or backlog."*
@@ -24,6 +24,8 @@ Read: .forge/requirements/m{N}.yml → requirement IDs for coverage check (curre
24
24
  Read: .forge/deferred-issues/*.md → known pre-existing failures (glob the dir; else legacy .forge/deferred-issues.md; advisory)
25
25
  ```
26
26
 
27
+ **Worktree cwd guard.** In a worktree session, before any git op (test runs off the tree are fine; the state-sync commit + the checkpoint push are not), verify the process cwd: compare `git rev-parse --show-toplevel` to the recorded `lifecycle.worktree_path`; on mismatch, pin every git op with `git -C {worktree_path}`. The cwd silently resets to the main checkout across turns, and a wrong-cwd op can **exit 0** — e.g. a checkpoint `git push origin HEAD:main` reporting "up-to-date" against a stale local `main` HEAD. Same discipline the State Commit Protocol + strand guard apply.
28
+
27
29
  ## Deferred Issues
28
30
 
29
31
  Glob `.forge/deferred-issues/*.md` before running any tests (ADR-023 — one file per issue). If the
@@ -306,7 +308,7 @@ After PASSED verdict:
306
308
  1. **Human Verification Gate** — binding-dispatched (see "Human Verification Gate" above). Default (`in_session_signoff`): capture explicit sign-off and write `current.human_verified`; if the human answers **not yet** → STOP here; do not advance to reviewing. The milestone stays open until sign-off (or a recorded override) exists. `promotion_approval`: no capture — surface the relocation line and continue.
307
309
  2. **Persist** — Confirm verification report documented, desire-path files written to `.forge/state/desire-paths/`
308
310
  3. **Update state** — Set `current.status` to `reviewing` in `.forge/state/milestone-{id}.yml`
309
- 4. **State-sync commit** (State Commit Protocol): `git add .forge/` then `git commit -m "chore(forge): sync state after verifying — m{N} {phase-name}"` (scoped; never `git add .`).
311
+ 4. **State-sync commit** (State Commit Protocol): `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit -m "chore(forge): sync state after verifying — m{N} {phase-name}"` (worktree form — recorded path is authoritative over cwd; see Worktree cwd guard). Scoped; never `git add .`.
310
312
  5. **Integration Checkpoint Publish** (see below) — if this phase is a checkpoint and the session is a worktree, fast-forward-only publish to main.
311
313
  6. **Recommend clear:** *"State synced. `/clear` then `/forge` for reviewing."*
312
314
 
@@ -322,8 +324,10 @@ Realizes producer-side continuous integration (FORGE.md → Stream Integration C
322
324
 
323
325
  **Strand guard — never push past the operator's local main (forge#13).** `git push origin HEAD:main` is fast-forward-only *relative to `origin/main`* — but `origin/main` is **not** the operator's canonical checkout. The local `main` ref (shared across this machine's worktrees via the common git dir) can carry commits that were never pushed — another stream's merge, a framework bump, the state-sync commits from step 4. If you push the worktree tip onto an `origin/main` that is *behind* local main, origin fast-forwards but **local main is left diverged** (it has commits origin's new tip lacks, and vice versa). The closing report then mis-reads this as "local main is behind — `git pull`", and `git pull --ff-only` *fails* on divergence while a bare `git pull` makes a surprise merge commit — the exact failure the merge discipline exists to prevent. So gate the push on local main being in the publish's direct line:
324
326
 
327
+ **Worktree pin:** in a worktree session, prefix every `git` command in the blocks below with `git -C {worktree_path}` (recorded `lifecycle.worktree_path`). A reset cwd is exactly how a wrong-checkout `git push origin HEAD:main` silently reported "up-to-date" against a stale local `main` HEAD (the m-31 failure); the strand check itself (`rev-parse main` / `merge-base`) is equally meaningless run against the wrong tree. See Worktree cwd guard.
328
+
325
329
  ```bash
326
- git fetch origin main 2>/dev/null || true # refresh origin/main (no-op without a remote)
330
+ git -C {worktree_path} fetch origin main 2>/dev/null || true # refresh origin/main (no-op without a remote)
327
331
  # Publish is strand-safe only when local main is an ANCESTOR of this worktree's HEAD —
328
332
  # i.e. local main has no commits this checkpoint doesn't already contain.
329
333
  if git rev-parse --verify -q main >/dev/null 2>&1 \
@@ -334,7 +338,7 @@ fi
334
338
 
335
339
  - **Strand-safe** (local main is an ancestor of HEAD, or no local `main` ref exists) → publish **fast-forward only**:
336
340
  ```bash
337
- git push origin HEAD:main # remote present; git refuses a non-ff push (diverged origin surfaced, never forced)
341
+ git -C {worktree_path} push origin HEAD:main # worktree-pinned; git refuses a non-ff push (diverged origin surfaced, never forced)
338
342
  # no remote → fast-forward the local main ref where possible, else report ready-to-ff
339
343
  ```
340
344
  - **Success** → set the integration flag for **all** of this machine's checkouts (sibling worktrees *and* the primary main checkout): write `.git/forge/integration-pending` (in the git common dir) with the new main sha, this stream/milestone, and the shared surfaces this phase changed. Because local main was an ancestor, advancing it is a **true fast-forward** — report: *"Checkpoint published — phase {N} fast-forwarded onto main. Local main is now behind origin by this slice; it auto-fast-forwards on the next `/forge` boot, or `git merge --ff-only origin/main` from the primary checkout now."* (Here, and only here, is local main genuinely *behind* — the ff advice is correct.)
@@ -152,6 +152,12 @@ Every new accumulating artifact needs: owner, size gate, archive/compaction rule
152
152
  - Runtime/generated files (`.mcp-server/*.db*`, logs, spike DBs, and the
153
153
  `.git/forge/*` machine-local files — the id-reservation ledger + integration
154
154
  flag) are excluded from framework context and packages unless debugging them.
155
+ - The board (ADR-025): `.forge/notion/map.yml` is **derived** (owner: main/orchestrator
156
+ session; `forge-id → page-id` cache), **gitignored**, and rebuildable by query-self-heal
157
+ (ADR-020) — never committed, never hand-edited, no size gate. The board **config** is the
158
+ `board:` block in `project.yml` — **stable-shared**, governed by project.yml's 5 KB gate,
159
+ changes via `/upgrading`; migration is lazy (absent block / `enabled:false` ⇒ inert, zero
160
+ behavior change). The board itself accumulates nothing in-repo — it is a projection + inbox.
155
161
  - Template copies sync mechanically; do not treat them as separate concepts.
156
162
 
157
163
  ### Fresh Agent Pattern
@@ -198,6 +204,8 @@ models:
198
204
 
199
205
  **Subagent inheritance.** When a skill spawns subagents via the Agent tool (parallel research, fresh-context reviewers, executor fan-out, tester author/analyst, etc.), the skill MUST pass the resolved `model` param explicitly. Subagents do NOT inherit skill-level routing automatically — without the param they run on the parent session's model, defeating the routing intent. Resolution is **per-task** where the task being spawned declares a `complexity` (`by_complexity` wins per the Precedence above), falling back to the skill-level chain (`models.skills.{skill}` → `models.default` → parent) otherwise — surface it: *"Spawning {role} with model: {model} (from {source})"*. **Inline-task limitation:** a task that runs inline (no spawn) inherits the parent session's model and cannot be re-routed — per-task routing is inherently a subagent-spawn feature, not something that reaches into an already-running skill session.
200
206
 
207
+ **Spawn gate — predicate A (LOCKED, context.md § M24; refined 2026-07-20/m-33).** Executing's *mandatory* subagent spawn on a complexity-tagged task fires **only when the task's resolved model differs from the live session model** (the model the session is actually running on). Resolved == live — always the case when there is **no `models:` block** (the chain falls to parent == live) — makes the spawn a pure no-op (briefing overhead, no routing change), so the tag becomes a **context signal only**: spawn iff a context trigger (20+ files / deep subsystem / >40%) independently fires. The inline-task limitation above is real, but the leak it names only bites when a *different* model was actually available — exactly the resolved-≠-live case the gate keeps mandatory. (WHY: an unconditional spawn burned ~315k subagent tokens on m-30 for zero routing benefit.)
208
+
201
209
  ## Agents
202
210
 
203
211
  | Agent | Role | Tools | When |
@@ -275,6 +283,8 @@ git add .forge/ # scoped — respects .gitignore; never `git add .`
275
283
  git commit -m "chore(forge): sync state after {phase} — m{N} {phase-name}"
276
284
  ```
277
285
 
286
+ **In a worktree session, pin these with the recorded path** — `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit …` (`{worktree_path}` = `lifecycle.worktree_path`, authoritative over cwd). The process cwd can silently reset to the launch (main) checkout across a turn boundary; a bare `git add .forge/…` then hits the wrong checkout (worktree-only content fails to stage) and a bare `git push origin HEAD:main` can even **exit 0** against a stale local `main` HEAD — a silent wrong-tree commit/push. Cheap guard before the first write: compare `git rev-parse --show-toplevel` to the recorded path; mismatch → use `-C` (or `cd` back).
287
+
278
288
  State-sync commits are separate from per-task code commits (which stay atomic during executing).
279
289
 
280
290
  ### State Ownership (multi-worktree safety)
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env sh
2
+ # forge-board-render.sh — the board projection builder (m-32, Brief-R2 step 3; ADR-025).
3
+ #
4
+ # The board is a DELIVERY of the state the rollups already compute — not a new
5
+ # compute. This script emits ONE normalized projection (the single source of truth
6
+ # for what the board shows), computed only from existing sources:
7
+ # - units: .forge/state/milestone-*.yml (id/name/status/tier) — same derivation the rollup uses
8
+ # - release_state: .claude/hooks/forge-release-fold.sh, copied VERBATIM (ADR-024 — never re-derived)
9
+ # - deferred view: the same three surfaces the `deferred` skill aggregates
10
+ # (deferred milestones · deferred refactor-backlog items · deferred phases in active milestones)
11
+ #
12
+ # MODES:
13
+ # --emit compute + print the projection JSON on stdout (pure, offline, no side effects, no Notion).
14
+ # ALWAYS runs regardless of board config — it is just "what the board would show".
15
+ # --deliver [phase 57] compute the same projection, then upsert to Notion via curl + a scoped
16
+ # token from env; gated on board.enabled + token; non-blocking.
17
+ #
18
+ # INVARIANTS:
19
+ # - Only AGENT-OWNED fields are emitted (never Priority/Queue/Triage — those are read IN by the inbox).
20
+ # - --emit is DETERMINISTIC: same repo state → byte-identical (no wall-clock; rendered_at lives in --deliver).
21
+ # - release_state comes only from the fold binary.
22
+ # - No new runtime dependency: POSIX sh + git only (curl for --deliver). No language interpreters, no toolchains.
23
+ # - Non-blocking by construction: a config/read/fold problem logs one line and continues (fold → "unknown").
24
+ #
25
+ # A future refactor could DRY the deferred aggregation with the `deferred` skill (agent-driven, model:haiku);
26
+ # out of scope for m-32 — both read the same three authoritative sources, so there is NO stored aggregate
27
+ # (compute-don't-store). Do NOT introduce a canonical-list file.
28
+
29
+ set -u
30
+
31
+ MODE="${1:-}"
32
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
33
+ FORGE="$ROOT/.forge"
34
+ PROJECT="$FORGE/project.yml"
35
+ # Tolerant fold lookup (0.59.1): probe the TRACKED location first (.forge/checks/), then fall back
36
+ # to the client-hook location (.claude/hooks/). The lookup IS the migration path — a consumer that
37
+ # tracks a copy of the fold under .forge/checks/ gets real release_state in CI; if only the gitignored
38
+ # .claude/hooks/ copy exists (absent in CI clones), unit_release_state degrades to "unknown". See docs/board.md.
39
+ FOLD="$ROOT/.forge/checks/forge-release-fold.sh"
40
+ [ -f "$FOLD" ] || FOLD="$ROOT/.claude/hooks/forge-release-fold.sh"
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # JSON string escaping (POSIX; escapes \, ", and control chars via tab/newline).
44
+ # ---------------------------------------------------------------------------
45
+ json_escape() {
46
+ # reads $1, prints a JSON-safe string body (no surrounding quotes)
47
+ printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | tr '\n' ' '
48
+ }
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Config: read a scalar under the top-level `board:` block of project.yml.
52
+ # ---------------------------------------------------------------------------
53
+ board_cfg() { # $1 = key under board:
54
+ [ -f "$PROJECT" ] || return 0
55
+ awk -v k="$1" '
56
+ /^board:/ { inb=1; next }
57
+ inb && /^[A-Za-z]/ { inb=0 }
58
+ inb && $0 ~ "^[[:space:]]+"k":" {
59
+ sub(/^[[:space:]]*[^:]+:[[:space:]]*/, "")
60
+ sub(/[[:space:]]*#.*/, "")
61
+ gsub(/^["'"'"']|["'"'"']$/, "")
62
+ print; exit
63
+ }
64
+ ' "$PROJECT"
65
+ }
66
+
67
+ board_enabled() { # 0 = enabled, 1 = disabled
68
+ [ "${FORGE_BOARD_FORCE_DISABLED:-}" = "1" ] && return 1
69
+ [ "$(board_cfg enabled)" = "true" ] && return 0
70
+ return 1
71
+ }
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # YAML helpers (working-tree reads; scalar-only, comment/quote stripped).
75
+ # ---------------------------------------------------------------------------
76
+ yget() { # $1=file $2=parent $3=key → scalar value of parent.key
77
+ [ -f "$1" ] || return 0
78
+ awk -v p="$2" -v k="$3" '
79
+ $0 ~ "^"p":" { inp=1; next }
80
+ inp && /^[A-Za-z]/ { inp=0 }
81
+ inp && $0 ~ "^[[:space:]]+"k":" {
82
+ sub(/^[[:space:]]*[^:]+:[[:space:]]*/, "")
83
+ sub(/[[:space:]]*#.*/, "")
84
+ gsub(/^["'"'"']|["'"'"']$/, "")
85
+ print; exit
86
+ }
87
+ ' "$1"
88
+ }
89
+
90
+ # release_state via the fold, verbatim (ADR-024). fold missing/err → "unknown".
91
+ # The fold prints `release_state=<state> [reason=<code>]`; take ONLY the state token.
92
+ unit_release_state() { # $1 = unit id (e.g. m-32)
93
+ rs=""
94
+ if [ -x "$FOLD" ] || [ -f "$FOLD" ]; then
95
+ rs=$(sh "$FOLD" "$1" 2>/dev/null | sed -n 's/^release_state=\([A-Za-z_-]*\).*/\1/p' | head -1)
96
+ fi
97
+ [ -n "$rs" ] || { rs="unknown"; printf 'board-render: release fold unavailable for %s — release_state: unknown\n' "$1" >&2; }
98
+ printf '%s' "$rs"
99
+ }
100
+
101
+ # Derived progress "done/total" from roadmap phase list vs current.phase.
102
+ unit_progress() { # $1 = numeric id, $2 = current.phase, $3 = status
103
+ roadmap="$FORGE/roadmap.yml"
104
+ [ -f "$roadmap" ] || { printf ''; return 0; }
105
+ phases=$(awk -v id="$1" '
106
+ /^ milestones:/ { inm=1 }
107
+ inm && $0 ~ "^ - id: "id"[[:space:]]*$" { grab=1; next }
108
+ inm && grab && /phases:/ { sub(/^[^\[]*\[/,""); sub(/\].*/,""); gsub(/[[:space:]]/,""); print; exit }
109
+ inm && grab && /^ - id:/ { exit }
110
+ ' "$roadmap")
111
+ [ -n "$phases" ] || { printf ''; return 0; }
112
+ total=$(printf '%s' "$phases" | awk -F, '{print NF}')
113
+ if [ "$3" = "complete" ]; then done=$total; else
114
+ # done = count of phase ids strictly before current.phase in the list
115
+ done=$(printf '%s' "$phases" | tr ',' '\n' | awk -v cur="$2" '$0==cur{exit} {c++} END{print c+0}')
116
+ fi
117
+ printf '%s/%s' "$done" "$total"
118
+ }
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Deferred aggregation — the three surfaces the `deferred` skill reads.
122
+ # Emits JSON objects into the deferred[] array (no stored file — compute-don't-store).
123
+ # ---------------------------------------------------------------------------
124
+ emit_deferred() {
125
+ first=1
126
+ # Surface 1: deferred MILESTONES — lifecycle.deferred_at set, not superseded by a later
127
+ # resumed_at. Same key the `deferred` skill + boot rollup use (a milestone with
128
+ # current.status: deferred but deferred_at: null derives as ACTIVE — matched, not "fixed").
129
+ for f in "$FORGE"/state/milestone-*.yml; do
130
+ [ -f "$f" ] || continue
131
+ da=$(yget "$f" lifecycle deferred_at)
132
+ [ -n "$da" ] && [ "$da" != "null" ] || continue
133
+ ra=$(yget "$f" lifecycle resumed_at)
134
+ [ -n "$ra" ] && [ "$ra" != "null" ] && continue # resumed → not deferred
135
+ base=$(basename "$f" .yml); id="${base#milestone-}"
136
+ nm=$(yget "$f" milestone name); rs=$(yget "$f" lifecycle deferred_reason)
137
+ [ "$first" = 1 ] && first=0 || printf ',\n'
138
+ printf ' {"surface": "milestone", "id": "m-%s", "name": "%s", "deferred_at": "%s", "reason": "%s"}' \
139
+ "$id" "$(json_escape "$nm")" "$(json_escape "$da")" "$(json_escape "$rs")"
140
+ done
141
+ # Surface 2: deferred REFACTOR-backlog items (status: deferred). Block-scan: accumulate
142
+ # each `- id:` item's fields, emit when its status is deferred.
143
+ bl="$FORGE/refactor-backlog.yml"
144
+ if [ -f "$bl" ]; then
145
+ awk '
146
+ function flush() { if (id != "" && st == "deferred") print id "\t" desc "\t" da "\t" rn }
147
+ /^ - id:/ { flush(); id=$3; gsub(/["'"'"']/,"",id); desc=""; da=""; rn=""; st="" }
148
+ /^ (description|title):/ { d=$0; sub(/^[^:]*:[[:space:]]*/,"",d); gsub(/["'"'"']/,"",d); if(desc=="")desc=substr(d,1,80) }
149
+ /^ status:/ { st=$2 }
150
+ /^ deferred_at:/ { da=$2; gsub(/["'"'"']/,"",da) }
151
+ /^ deferred_reason:/ { rn=$0; sub(/^[^:]*:[[:space:]]*/,"",rn); gsub(/["'"'"']/,"",rn) }
152
+ END { flush() }
153
+ ' "$bl" 2>/dev/null | while IFS=' ' read -r rid rdesc rda rrn; do
154
+ [ -n "$rid" ] || continue
155
+ [ "$first" = 1 ] && first=0 || printf ',\n'
156
+ printf ' {"surface": "refactor", "id": "%s", "name": "%s", "deferred_at": "%s", "reason": "%s"}' \
157
+ "$(json_escape "$rid")" "$(json_escape "$rdesc")" "$(json_escape "$rda")" "$(json_escape "$rrn")"
158
+ done
159
+ fi
160
+ # Surface 3: deferred PHASES/TASKS inside ACTIVE milestones. Derived-active =
161
+ # lifecycle.deferred_at unset AND current.status not complete/not_started. The scan
162
+ # SKIPS the `current:` block so a milestone's own current.status line never false-hits.
163
+ for f in "$FORGE"/state/milestone-*.yml; do
164
+ [ -f "$f" ] || continue
165
+ st=$(yget "$f" current status)
166
+ case "$st" in complete|not_started) continue ;; esac
167
+ da_m=$(yget "$f" lifecycle deferred_at)
168
+ [ -n "$da_m" ] && [ "$da_m" != "null" ] && continue # milestone-deferred → surface 1
169
+ hit=$(awk '
170
+ /^current:/ { inc=1; next }
171
+ inc && /^[A-Za-z]/ { inc=0 }
172
+ !inc && /^[[:space:]]+(status:[[:space:]]*deferred|deferred:[[:space:]]*true)/ { print "1"; exit }
173
+ ' "$f")
174
+ [ "$hit" = "1" ] || continue
175
+ base=$(basename "$f" .yml); id="${base#milestone-}"
176
+ [ "$first" = 1 ] && first=0 || printf ',\n'
177
+ printf ' {"surface": "active-milestone", "id": "m-%s", "name": "%s", "deferred_at": "", "reason": "phase/task deferred inside active milestone"}' \
178
+ "$id" "$(json_escape "$(yget "$f" milestone name)")"
179
+ done
180
+ }
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # The shared projection builder — one source of truth for what the board shows.
184
+ # --emit prints it; --deliver (phase 57) delivers this same object.
185
+ # ---------------------------------------------------------------------------
186
+ emit_projection() {
187
+ # Repo name default: the git common dir's parent basename, so a worktree
188
+ # ("…/forge-worktrees/m-32") still reports "forge", not the worktree dir name.
189
+ repo="$(basename "$(dirname "$(git rev-parse --git-common-dir 2>/dev/null)")" 2>/dev/null)"
190
+ [ -n "$repo" ] && [ "$repo" != "." ] || repo="$(basename "$ROOT")"
191
+ db="$(board_cfg database_id)"
192
+ reponame="$(board_cfg repo)"; [ -n "$reponame" ] || reponame="$repo"
193
+ benabled=false; board_enabled && benabled=true
194
+
195
+ printf '{\n'
196
+ printf ' "repo": "%s",\n' "$(json_escape "$reponame")"
197
+ printf ' "database_id": "%s",\n' "$(json_escape "$db")"
198
+ printf ' "board_enabled": %s,\n' "$benabled"
199
+ printf ' "units": [\n'
200
+ ufirst=1
201
+ for f in "$FORGE"/state/milestone-*.yml; do
202
+ [ -f "$f" ] || continue
203
+ base=$(basename "$f" .yml); id="${base#milestone-}"; unit="m-$id"
204
+ nm=$(yget "$f" milestone name)
205
+ status=$(yget "$f" current status)
206
+ tier=$(yget "$f" current tier)
207
+ cphase=$(yget "$f" current phase)
208
+ rstate=$(unit_release_state "$unit")
209
+ prog=$(unit_progress "$id" "$cphase" "$status")
210
+ [ "$ufirst" = 1 ] && ufirst=0 || printf ',\n'
211
+ printf ' {"forge_id": "%s", "layer": "Milestone", "name": "%s", "status": "%s", "release_state": "%s", "tier": "%s", "progress": "%s", "blockers": [], "parent": null}' \
212
+ "$unit" "$(json_escape "$nm")" "$(json_escape "$status")" "$(json_escape "$rstate")" "$(json_escape "$tier")" "$(json_escape "$prog")"
213
+ done
214
+ printf '\n ],\n'
215
+ printf ' "deferred": [\n'
216
+ emit_deferred
217
+ printf '\n ]\n'
218
+ printf '}\n'
219
+ }
220
+
221
+ # ===========================================================================
222
+ # --deliver — headless transport (phase 57, ADR-025 decision 3).
223
+ #
224
+ # Computes the SAME emit_projection object, then upserts each unit to Notion via
225
+ # curl + REST, using a scoped token read from the env var NAMED by
226
+ # board.ci_token_env (default FORGE_NOTION_TOKEN). The token VALUE is never read
227
+ # from a file and never written into .forge/ (NFR-035). POSIX sh + curl + jq only,
228
+ # no language interpreter (NFR-034). NON-BLOCKING by construction: board disabled,
229
+ # token/db/tool absent, network error, or any non-2xx logs ONE line and exits 0 —
230
+ # a CI step must never fail because the board was unreachable (FR-175).
231
+ # ===========================================================================
232
+ NOTION_API="https://api.notion.com/v1"
233
+ NOTION_VERSION="2022-06-28"
234
+
235
+ log_deliver() { printf 'board-render: %s\n' "$1" >&2; }
236
+
237
+ # The token, read by DYNAMIC env-var name via awk ENVIRON — never eval, never a file.
238
+ deliver_token() {
239
+ var="$(board_cfg ci_token_env)"; [ -n "$var" ] || var="FORGE_NOTION_TOKEN"
240
+ awk -v n="$var" 'BEGIN { v = ENVIRON[n]; if (v != "") print v }'
241
+ }
242
+
243
+ # curl wrapper. Prints "<response-body>\n<http_code>" (code is the LAST line).
244
+ # All failures are the caller's to absorb (non-blocking).
245
+ notion_curl() { # $1 method $2 url $3 token $4 body(optional)
246
+ if [ -n "${4:-}" ]; then
247
+ curl -sS -X "$1" "$2" \
248
+ -H "Authorization: Bearer $3" \
249
+ -H "Notion-Version: $NOTION_VERSION" \
250
+ -H "Content-Type: application/json" \
251
+ -w '\n%{http_code}' -d "$4" 2>/dev/null
252
+ else
253
+ curl -sS -X "$1" "$2" \
254
+ -H "Authorization: Bearer $3" \
255
+ -H "Notion-Version: $NOTION_VERSION" \
256
+ -w '\n%{http_code}' 2>/dev/null
257
+ fi
258
+ }
259
+
260
+ # Derived, gitignored cache: forge-id -> notion-page-id (ADR-020). "<forge-id>: <page-id>".
261
+ MAP="$FORGE/notion/map.yml"
262
+ map_lookup() { # $1 forge_id -> page_id (empty on miss)
263
+ [ -f "$MAP" ] || return 0
264
+ awk -v id="$1" '$1 == id":" { print $2; exit }' "$MAP"
265
+ }
266
+ map_record() { # $1 forge_id $2 page_id — best-effort self-heal (ADR-020).
267
+ [ -n "$2" ] || return 0
268
+ # Derived-class rule (ADR-011): only the main/orchestrator checkout writes the map;
269
+ # a linked worktree skips the write (the query path re-binds next render).
270
+ [ "$(git rev-parse --git-dir 2>/dev/null)" = "$(git rev-parse --git-common-dir 2>/dev/null)" ] || return 0
271
+ mkdir -p "$(dirname "$MAP")" 2>/dev/null || return 0
272
+ if [ -f "$MAP" ]; then grep -v "^$1:" "$MAP" > "$MAP.tmp" 2>/dev/null && mv "$MAP.tmp" "$MAP" 2>/dev/null; fi
273
+ printf '%s: %s\n' "$1" "$2" >> "$MAP" 2>/dev/null || true
274
+ }
275
+
276
+ # Query the board for a page whose `Forge ID` == $3. Prints its page id (empty on miss/err).
277
+ query_page() { # $1 db $2 token $3 forge_id
278
+ qbody="$(jq -n --arg fid "$3" '{filter:{property:"Forge ID",rich_text:{equals:$fid}},page_size:1}')"
279
+ qresp="$(notion_curl POST "$NOTION_API/databases/$1/query" "$2" "$qbody")"
280
+ [ "$(printf '%s' "$qresp" | tail -1)" = "200" ] || return 0
281
+ printf '%s' "$qresp" | sed '$d' | jq -r '.results[0].id // empty' 2>/dev/null
282
+ }
283
+
284
+ # Agent-owned Notion properties for one unit (stdin: the unit JSON). $1 repo, $2 rendered_at,
285
+ # $3 = 1 to stamp `Forge ID` (create only — set once, never re-projected per ADR-020).
286
+ # Never emits a human-owned property (Priority/Queue/Triage) — overwrite-on-render is safe.
287
+ unit_properties() { # stdin unit $1 repo $2 ts $3 set_forge_id
288
+ jq -c --arg repo "$1" --arg ts "$2" --argjson setfid "$3" '
289
+ def sel(v): (if (v // "") == "" then {select: null} else {select: {name: v}} end);
290
+ {
291
+ "Name": {title: [{text: {content: (.name // "")}}]},
292
+ "Layer": sel(.layer),
293
+ "Status": sel(.status),
294
+ "Tier": sel(.tier),
295
+ "Release State": sel(.release_state),
296
+ "Progress": {rich_text: [{text: {content: (.progress // "")}}]},
297
+ "Blockers": {rich_text: [{text: {content: ((.blockers // []) | join(", "))}}]},
298
+ "Rollup Summary":{rich_text: [{text: {content: (.rollup_summary // "")}}]},
299
+ "Last Rendered": {date: {start: $ts}}
300
+ }
301
+ + (if ($repo | length) > 0 then {"Repo": {select: {name: $repo}}} else {} end)
302
+ + (if $setfid == 1 then {"Forge ID": {rich_text: [{text: {content: .forge_id}}]}} else {} end)
303
+ '
304
+ }
305
+
306
+ deliver_headless() {
307
+ board_enabled || { log_deliver "board disabled (board.enabled != true) — deliver skipped"; return 0; }
308
+ var="$(board_cfg ci_token_env)"; [ -n "$var" ] || var="FORGE_NOTION_TOKEN"
309
+ tok="$(deliver_token)"
310
+ [ -n "$tok" ] || { log_deliver "token env \$$var unset — deliver skipped (non-blocking)"; return 0; }
311
+ db="$(board_cfg database_id)"
312
+ [ -n "$db" ] || { log_deliver "board.database_id unset — deliver skipped (non-blocking)"; return 0; }
313
+ command -v jq >/dev/null 2>&1 || { log_deliver "jq unavailable — deliver skipped (non-blocking)"; return 0; }
314
+ command -v curl >/dev/null 2>&1 || { log_deliver "curl unavailable — deliver skipped (non-blocking)"; return 0; }
315
+
316
+ proj="$(emit_projection)" || { log_deliver "emit failed — deliver skipped (non-blocking)"; return 0; }
317
+ repo="$(printf '%s' "$proj" | jq -r '.repo // ""')"
318
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # rendered_at lives ONLY in deliver (emit stays deterministic).
319
+
320
+ ok=0; fail=0
321
+ count="$(printf '%s' "$proj" | jq '.units | length')"
322
+ i=0
323
+ while [ "$i" -lt "$count" ]; do
324
+ unit="$(printf '%s' "$proj" | jq -c ".units[$i]")"
325
+ i=$((i + 1))
326
+ fid="$(printf '%s' "$unit" | jq -r '.forge_id')"
327
+ [ -n "$fid" ] && [ "$fid" != "null" ] || continue
328
+
329
+ page="$(map_lookup "$fid")"
330
+ if [ -z "$page" ]; then
331
+ page="$(query_page "$db" "$tok" "$fid")"
332
+ [ -n "$page" ] && map_record "$fid" "$page"
333
+ fi
334
+
335
+ if [ -n "$page" ]; then
336
+ props="$(printf '%s' "$unit" | unit_properties "$repo" "$ts" 0)"
337
+ body="$(jq -n --argjson p "$props" '{properties: $p}')"
338
+ resp="$(notion_curl PATCH "$NOTION_API/pages/$page" "$tok" "$body")"
339
+ else
340
+ props="$(printf '%s' "$unit" | unit_properties "$repo" "$ts" 1)"
341
+ body="$(jq -n --arg db "$db" --argjson p "$props" '{parent: {database_id: $db}, properties: $p}')"
342
+ resp="$(notion_curl POST "$NOTION_API/pages" "$tok" "$body")"
343
+ fi
344
+
345
+ code="$(printf '%s' "$resp" | tail -1)"
346
+ case "$code" in
347
+ 2*)
348
+ ok=$((ok + 1))
349
+ [ -z "$(map_lookup "$fid")" ] && map_record "$fid" "$(printf '%s' "$resp" | sed '$d' | jq -r '.id // empty' 2>/dev/null)"
350
+ ;;
351
+ *)
352
+ fail=$((fail + 1))
353
+ log_deliver "unit $fid → HTTP ${code:-none} (non-blocking, continuing)"
354
+ ;;
355
+ esac
356
+ done
357
+ log_deliver "deliver complete: $ok upserted, $fail failed (non-blocking) — repo=$repo"
358
+ return 0
359
+ }
360
+
361
+ case "$MODE" in
362
+ --emit)
363
+ emit_projection
364
+ ;;
365
+ --deliver)
366
+ deliver_headless
367
+ exit 0
368
+ ;;
369
+ *)
370
+ printf 'usage: forge-board-render.sh --emit | --deliver\n' >&2
371
+ exit 2
372
+ ;;
373
+ esac
@@ -41,3 +41,7 @@ dev-source
41
41
  # entries a fresh install would re-commit them.
42
42
  state/index.yml
43
43
  streams/active.yml
44
+
45
+ # Notion board id-binding cache (ADR-020 derived class) — forge-id → page-id, rebuilt
46
+ # by query-self-heal; never committed (a committed copy would leak private Notion page IDs).
47
+ notion/map.yml
@@ -121,27 +121,22 @@ models:
121
121
  debugging: sonnet # Investigation needs solid reasoning
122
122
  discussing: sonnet # Conversation needs natural fluency
123
123
 
124
- # notion: # OPT-IN work-management adapter — INERT by default. Projects Forge
125
- # # milestones/streams/packages onto a Notion board and reads human triage/
126
- # # priority proposals back. Activates only with BOTH this block (enabled: true)
127
- # # AND a Notion MCP server in .mcp.json; absent either fully inert, zero
128
- # # behavior change. Git (.forge/) stays source of truth Notion is a
129
- # # projection + inbox, never symmetric sync (ADR-019). Mirrors the opt-in
130
- # # shape of forge.beads_integration.
131
- # enabled: false # Master switch. false/absent → adapter never runs.
132
- # database_id: "" # Id of the single "Forge Work Items" Notion database (all layers as rows).
133
- # properties: # OPTIONAL rename map: Forge property your Notion column name. Omit defaults.
134
- # Forge ID: "Forge ID" # Binding key agent-owned, set once, never overwritten.
135
- # Status: "Status" # Agent-owned, overwritten each projection.
136
- # Priority: "Priority" # Human-owned read as proposal, never overwritten.
137
- # intake_filter: # OPTIONAL (FR-116): gate which Forge-ID-less pages pull_inbox() treats as
138
- # # triage requests, so one board can mix agent items + arbitrary human pages.
139
- # # Absent every Forge-ID-less page is a candidate (FR-114 base). One of:
140
- # property: "Type" # {property, equals} — page's <property> equals <value>
141
- # equals: "Request"
142
- # # property: "Type" # {property, in} — page's <property> is one of <values>
143
- # # in: ["Request", "Bug"]
144
- # # view: "Triage" # {view} — pages in the named saved Notion view
124
+ # board: # OPT-IN Notion board (ADR-025) — INERT by default. A rendered STATUS window
125
+ # # + a backlog INBOX projecting Forge state onto Notion; NEVER an approval
126
+ # # surface (D-D no board field feeds any merge/deploy/promotion/fold path).
127
+ # # Activates only with BOTH this block (enabled: true) AND a Notion MCP server
128
+ # # (attended render) or a scoped token (headless CI render); absent either
129
+ # # fully inert, zero behavior change at boot/handoff/commit. Git (.forge/)
130
+ # # stays source of truth. See docs/board.md for setup + the CI wiring snippet.
131
+ # enabled: false # Master switch. false/absent → the board never runs.
132
+ # provider: notion # the only adapter today
133
+ # database_id: "" # Notion database the renderer upserts into (a per-row Repo prop scopes shared workspaces)
134
+ # repo: "" # value stamped on each row's Repo property; empty → derived from the repo basename
135
+ # ci_token_env: FORGE_NOTION_TOKEN # env-var NAME the headless --deliver reads the token from (never the value; NFR-035)
136
+ # lanes: # DESCRIPTIVE-ONLY (renderer does not read these; --emit always emits all
137
+ # status: true # three). The framework ships these three lanes ONLY a bespoke outbound
138
+ # backlog: true # lane is PROJECT-LOCAL config composing these; the framework carries none.
139
+ # deferred: true # status / backlog (explicit-intake, M21) / deferred view.
145
140
 
146
141
  risks: # What could go wrong?
147
142
  - risk: ""