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 +1 -1
- package/template/.claude/hooks/forge-slice-runner.sh +38 -1
- package/template/.claude/skills/architecting/SKILL.md +1 -1
- package/template/.claude/skills/chief-of-staff/SKILL.md +16 -0
- package/template/.claude/skills/discussing/SKILL.md +1 -1
- package/template/.claude/skills/executing/SKILL.md +12 -4
- package/template/.claude/skills/forge/SKILL.md +1 -1
- package/template/.claude/skills/notion-integration/SKILL.md +103 -0
- package/template/.claude/skills/planning/SKILL.md +1 -1
- package/template/.claude/skills/quick-tasking/SKILL.md +1 -1
- package/template/.claude/skills/researching/SKILL.md +1 -1
- package/template/.claude/skills/reviewing/SKILL.md +3 -1
- package/template/.claude/skills/verifying/SKILL.md +7 -3
- package/template/.forge/FORGE.md +10 -0
- package/template/.forge/checks/forge-board-render.sh +373 -0
- package/template/.forge/gitignore +4 -0
- package/template/.forge/templates/project.yml +16 -21
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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}"` (
|
|
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}"` (
|
|
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
|
|
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 (
|
|
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}"
|
|
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): `
|
|
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}"` (
|
|
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}"` (
|
|
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}"` (
|
|
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"` (
|
|
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}"` (
|
|
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
|
|
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
|
|
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.)
|
package/template/.forge/FORGE.md
CHANGED
|
@@ -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
|
-
#
|
|
125
|
-
# #
|
|
126
|
-
# #
|
|
127
|
-
# #
|
|
128
|
-
# #
|
|
129
|
-
# #
|
|
130
|
-
# #
|
|
131
|
-
# enabled: false # Master switch. false/absent →
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
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: ""
|