forge-orkes 0.58.3 → 0.59.1

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.59.1",
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"
@@ -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.
@@ -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.
@@ -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
@@ -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: ""