forge-orkes 0.58.2 → 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 +1 -1
- package/template/.claude/settings.json +1 -1
- package/template/.claude/skills/chief-of-staff/SKILL.md +16 -0
- package/template/.claude/skills/forge/SKILL.md +1 -1
- package/template/.claude/skills/notion-integration/SKILL.md +103 -0
- package/template/.forge/FORGE.md +6 -0
- package/template/.forge/checks/forge-board-render.sh +373 -0
- package/template/.forge/gitignore +4 -0
- package/template/.forge/migrations/0.53.0-render-on-read-registries.md +103 -0
- package/template/.forge/migrations/0.58.3-render-on-read-registries-catchup.md +42 -0
- package/template/.forge/templates/project.yml +16 -21
package/package.json
CHANGED
|
@@ -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): `
|
|
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.
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Migration Guide: Render-on-read state registries (Forge 0.53.0)
|
|
2
|
+
|
|
3
|
+
Applies to projects whose derived registries — `.forge/state/index.yml` and/or
|
|
4
|
+
`.forge/streams/active.yml` — are still **git-tracked**. Both files were already
|
|
5
|
+
`DERIVED / DO NOT HAND-EDIT` projections; 0.53.0 makes them **git-ignored
|
|
6
|
+
render-on-read caches**: regenerated at every `forge` boot from their sources and
|
|
7
|
+
**never committed**. Because the value is computed on read and never stored in
|
|
8
|
+
git, the two-registry drift class — a committed projection going stale against
|
|
9
|
+
its source (e.g. a committed `active.yml` phase reading `researching` while the
|
|
10
|
+
milestone had moved to `discussing`) — can no longer occur.
|
|
11
|
+
|
|
12
|
+
Sources are unchanged: `index.yml` ← `.forge/state/milestone-*.yml` (Rollup 1.0);
|
|
13
|
+
`active.yml` ← `.forge/streams/*.yml` + active milestones (Stream Rollup). Only
|
|
14
|
+
the *storage* changes — tracked file → local cache.
|
|
15
|
+
|
|
16
|
+
Projects that never committed these files (fresh 0.53.0+ installs) need nothing.
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
1. On the new version's framework files (`npx forge-orkes upgrade`, or the
|
|
21
|
+
`upgrading` skill) — so the updated `.gitignore` entries and the render-then-read
|
|
22
|
+
`deferred`/`planning` skills are present.
|
|
23
|
+
2. Working tree clean or changes committed — the migration stops tracking two
|
|
24
|
+
files and edits `.gitignore`.
|
|
25
|
+
|
|
26
|
+
## Detection
|
|
27
|
+
|
|
28
|
+
Keyed on **git-tracking state**, not a content grep: the `DERIVED / DO NOT
|
|
29
|
+
HAND-EDIT` header is identical before and after, so only `git ls-files` can tell
|
|
30
|
+
a committed-pre-0.53.0 registry from a git-ignored-post-0.53.0 one. Fires while
|
|
31
|
+
either registry is still tracked; silent once both are untracked (already
|
|
32
|
+
migrated), and silent in a non-Forge / non-git tree:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
36
|
+
migrate=0
|
|
37
|
+
for f in .forge/state/index.yml .forge/streams/active.yml; do
|
|
38
|
+
if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
|
|
39
|
+
echo "MIGRATE — $f is still git-tracked (pre-0.53.0); render-on-read makes it a git-ignored cache regenerated at boot"
|
|
40
|
+
migrate=1
|
|
41
|
+
fi
|
|
42
|
+
done
|
|
43
|
+
[ "$migrate" -eq 0 ] && exit 0
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Migration steps
|
|
47
|
+
|
|
48
|
+
### 1. Stop tracking the two registries (keep them on disk)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git rm --cached .forge/state/index.yml .forge/streams/active.yml 2>/dev/null
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`--cached` removes them from the index only — the working-copy files stay as a
|
|
55
|
+
local cache and are re-rendered on the next boot. (If either was never tracked,
|
|
56
|
+
the command is a harmless no-op for that path.)
|
|
57
|
+
|
|
58
|
+
### 2. Git-ignore both derived registries
|
|
59
|
+
|
|
60
|
+
Add to the project's root `.gitignore` (paths relative to the repo root):
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
.forge/state/index.yml
|
|
64
|
+
.forge/streams/active.yml
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Consumer projects created from `create-forge` instead gain these under
|
|
68
|
+
`.forge/.gitignore` (paths relative to `.forge/`):
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
state/index.yml
|
|
72
|
+
streams/active.yml
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Without this, the next `git add .forge/` would re-commit the caches and reopen
|
|
76
|
+
the drift class.
|
|
77
|
+
|
|
78
|
+
### 3. Confirm the readers render-then-read
|
|
79
|
+
|
|
80
|
+
`forge` boot and `chief-of-staff` already render before reading. In 0.53.0 the
|
|
81
|
+
two direct readers are converted too: `deferred` and `planning` no longer treat a
|
|
82
|
+
missing `index.yml` as "not a Forge project" — the project sentinel moved to the
|
|
83
|
+
committed `.forge/project.yml`, and they derive milestone state from
|
|
84
|
+
`.forge/state/milestone-*.yml` (rendering the cache first) when `index.yml` is
|
|
85
|
+
absent. These ship with the framework upgrade; no per-project edit is needed.
|
|
86
|
+
|
|
87
|
+
### 4. Regenerate the caches
|
|
88
|
+
|
|
89
|
+
Run `/forge` (boot) — it runs Rollup 1.0 and the Stream Rollup and rewrites both
|
|
90
|
+
files from their sources. From here they are regenerated on read, never committed.
|
|
91
|
+
|
|
92
|
+
## Validation
|
|
93
|
+
|
|
94
|
+
- `git ls-files --error-unmatch .forge/state/index.yml` exits **non-zero**
|
|
95
|
+
(untracked); same for `.forge/streams/active.yml`.
|
|
96
|
+
- `git check-ignore .forge/state/index.yml` and `git check-ignore
|
|
97
|
+
.forge/streams/active.yml` each exit **0** (ignored).
|
|
98
|
+
- Both files still exist on disk after a `/forge` boot (rendered cache).
|
|
99
|
+
- A fresh clone with no prior boot: `deferred` and `planning` do **not** print
|
|
100
|
+
"No forge state found" (they key the sentinel on `project.yml`).
|
|
101
|
+
- Editing a milestone's phase and booting yields an `active.yml` whose row phase
|
|
102
|
+
matches the milestone — the projection can no longer be stale, because it is
|
|
103
|
+
never stored.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Migration Guide: Render-on-read registries catch-up (Forge 0.58.3)
|
|
2
|
+
|
|
3
|
+
Applies to projects that upgraded **across 0.53.0 while the 0.53.0 migration
|
|
4
|
+
guide was missing from the shipped template** (missing 0.53.0–0.58.2; restored
|
|
5
|
+
in 0.58.3). For those projects, `/upgrading` Step 7's version-range detection
|
|
6
|
+
(`installed < v <= source`) fired for the crossed range but had no
|
|
7
|
+
`0.53.0-*.md` guide on disk to surface — so the render-on-read registry
|
|
8
|
+
migration (untrack `.forge/state/index.yml` + `.forge/streams/active.yml`) was
|
|
9
|
+
silently skipped, and range detection never looks backward at an
|
|
10
|
+
already-crossed version. Host-verified on ptnrkit: stamped 0.58.2 with both
|
|
11
|
+
derived registries still git-tracked and actively committed.
|
|
12
|
+
|
|
13
|
+
This guide exists solely to re-surface that skipped migration. It re-runs the
|
|
14
|
+
same detection; the actual steps live in the (now-delivered)
|
|
15
|
+
`0.53.0-render-on-read-registries.md` alongside this file.
|
|
16
|
+
|
|
17
|
+
## Detection
|
|
18
|
+
|
|
19
|
+
Identical to the 0.53.0 guide — keyed on git-tracking state. Silent once both
|
|
20
|
+
registries are untracked (migrated, or a fresh ≥0.53.0 install):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
24
|
+
migrate=0
|
|
25
|
+
for f in .forge/state/index.yml .forge/streams/active.yml; do
|
|
26
|
+
if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
|
|
27
|
+
echo "MIGRATE — $f is still git-tracked; the 0.53.0 render-on-read migration was skipped"
|
|
28
|
+
migrate=1
|
|
29
|
+
fi
|
|
30
|
+
done
|
|
31
|
+
[ "$migrate" -eq 0 ] && echo "OK — already migrated (or never tracked the registries)"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Steps
|
|
35
|
+
|
|
36
|
+
Detection fired → follow `0.53.0-render-on-read-registries.md` (same
|
|
37
|
+
directory) end to end: `git rm --cached` the two registries, gitignore them
|
|
38
|
+
(root `.gitignore`, or `state/index.yml` + `streams/active.yml` in
|
|
39
|
+
`.forge/.gitignore` for create-forge installs), then boot `/forge` to
|
|
40
|
+
regenerate the caches. Its Validation section applies unchanged.
|
|
41
|
+
|
|
42
|
+
Detection silent → nothing to do; this guide is a no-op.
|
|
@@ -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: ""
|