forgedock 1.1.7 → 1.1.8

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.
Files changed (37) hide show
  1. package/bin/engine/invariants.mjs +361 -0
  2. package/bin/hooks/pre-tool-use.mjs +182 -0
  3. package/bin/labels.json +10 -0
  4. package/bin/recall.mjs +540 -0
  5. package/commands/autopilot.md +957 -76
  6. package/commands/cleanup.md +52 -3
  7. package/commands/optimize.md +585 -0
  8. package/commands/orchestrate/phase-1-resolve.md +35 -25
  9. package/commands/orchestrate/phase-3-dependency.md +465 -34
  10. package/commands/orchestrate/phase-4-execution.md +447 -28
  11. package/commands/orchestrate/phase-6-report.md +198 -9
  12. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  13. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  14. package/commands/quality-gate.md +417 -0
  15. package/commands/review-pr.md +391 -13
  16. package/commands/spec-doctor.md +429 -0
  17. package/commands/validate.md +52 -0
  18. package/commands/work-on/build/architect.md +77 -0
  19. package/commands/work-on/build/context.md +357 -22
  20. package/commands/work-on/build/implement.md +2 -0
  21. package/commands/work-on/build/validate.md +28 -0
  22. package/commands/work-on/build.md +44 -0
  23. package/commands/work-on/close.md +621 -8
  24. package/commands/work-on/investigate.md +221 -0
  25. package/commands/work-on.md +15 -14
  26. package/docs/FORGE-PROTOCOL.md +101 -0
  27. package/docs/spec/forge-protocol-v1.md +97 -5
  28. package/docs/spec/label-state-machine.md +6 -1
  29. package/package.json +2 -2
  30. package/packages/protocol/src/cli.js +374 -84
  31. package/packages/protocol/src/types.js +75 -1
  32. package/scripts/build-knowledge-index.mjs +1761 -0
  33. package/scripts/calibration.mjs +996 -0
  34. package/scripts/danger-zones.mjs +782 -0
  35. package/scripts/doctor-pipeline-state.sh +88 -2
  36. package/scripts/select-fix-targets.sh +234 -0
  37. package/scripts/transition-label.sh +157 -6
@@ -59,6 +59,77 @@ Extract from agent comments:
59
59
 
60
60
  ---
61
61
 
62
+ ## Phase C0.5: Close-Scope Invariant Assertions (MANDATORY)
63
+
64
+ Run before any close actions. Evaluates `close`-scope invariants declared in
65
+ `forge-invariants.yaml` via `bin/engine/invariants.mjs`. A failed assertion
66
+ logs the violated proposition by name and flags the anomaly in the trajectory
67
+ log — it does NOT abort the close phase (advisory enforcement: flag, then continue).
68
+
69
+ **Skip if**: `forge-invariants.yaml` is absent or `bin/engine/invariants.mjs`
70
+ is unavailable (e.g. fresh install before this file ships). Fail-open.
71
+
72
+ ```bash
73
+ # Read local run-log for this issue (absolute path matches engine run-log dir)
74
+ RUN_LOG_DIR="${HOME}/.forge/runs"
75
+ RUN_LOG_FILE="${RUN_LOG_DIR}/{NUMBER}.jsonl"
76
+
77
+ INVARIANT_ANOMALIES=""
78
+
79
+ if [ -f "${RUN_LOG_FILE}" ] && [ -f "$(dirname "$(which node)")/node" ] 2>/dev/null; then
80
+ # Check close-scope invariants via the evaluator
81
+ INVARIANT_RESULT=$(node -e "
82
+ import(new URL('file://$(pwd)/bin/engine/invariants.mjs'))
83
+ .then(m => {
84
+ const decls = m.loadInvariants('$(pwd)/forge-invariants.yaml');
85
+ const fs = require('fs');
86
+ let events = [];
87
+ try {
88
+ const lines = fs.readFileSync('${RUN_LOG_FILE}', 'utf-8').split('\n').filter(Boolean);
89
+ events = lines.flatMap(l => { try { return [JSON.parse(l)]; } catch { return []; } });
90
+ } catch {}
91
+ const results = m.assertCloseInvariants(decls, events);
92
+ const failed = results.filter(r => !r.ok);
93
+ if (failed.length) {
94
+ failed.forEach(r => process.stderr.write(m.formatViolation(r) + '\n'));
95
+ process.exit(1);
96
+ }
97
+ })
98
+ .catch(() => process.exit(0)); // fail-open on any error
99
+ " 2>&1) || INVARIANT_ANOMALIES="${INVARIANT_RESULT}"
100
+
101
+ if [ -n "$INVARIANT_ANOMALIES" ]; then
102
+ echo "CLOSE-SCOPE INVARIANT ANOMALY (flagging — close continues):"
103
+ echo "$INVARIANT_ANOMALIES"
104
+ # The anomaly will be recorded in the trajectory log Anomalies field.
105
+ # It does NOT block the close phase.
106
+ fi
107
+ fi
108
+
109
+ # Also check: issue must be in CLOSED state after close attempt.
110
+ # This check runs AFTER Phase C2 (ensure issue is closed). Set a sentinel
111
+ # here to be evaluated post-C2:
112
+ CLOSE_INVARIANT_ISSUE_CHECK=true
113
+ ```
114
+
115
+ **Post-C2 check** (evaluate after Phase C2 runs the `gh issue close` command):
116
+
117
+ ```bash
118
+ if [ "${CLOSE_INVARIANT_ISSUE_CHECK:-false}" = "true" ]; then
119
+ ISSUE_STATE=$(gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state' 2>/dev/null || echo "UNKNOWN")
120
+ if [ "$ISSUE_STATE" != "CLOSED" ]; then
121
+ INVARIANT_ANOMALIES="${INVARIANT_ANOMALIES:+$INVARIANT_ANOMALIES; }issue_closed_at_terminal: issue state is ${ISSUE_STATE} (not CLOSED) after close attempt"
122
+ echo "INVARIANT ANOMALY: issue_closed_at_terminal — issue is ${ISSUE_STATE}, not CLOSED"
123
+ # Flag but continue — trajectory Anomalies field will surface this.
124
+ fi
125
+ fi
126
+ ```
127
+
128
+ The `INVARIANT_ANOMALIES` variable is read in Phase C4.5 (trajectory post) and written to the **Anomalies** field.
129
+
130
+ ---
131
+
132
+
62
133
  ## Phase C1: Final Issue Body Update
63
134
 
64
135
  **Multi-phase guard**: Before checking off items, detect whether the issue has multiple phases. Only check off items belonging to the current completed phase — not all remaining items across future phases.
@@ -106,6 +177,194 @@ The `REMAINING_AFTER` variable is passed to Phase C2 to decide whether to close.
106
177
 
107
178
  ---
108
179
 
180
+ ## Phase C1.7: Module Dossier Append (MANDATORY when PR exists) <!-- Added: forge#1733 -->
181
+
182
+ **Goal**: After each merge that touches a module covered by `devdocs/modules/`, append a dated entry so future agents working on that module receive current institutional knowledge through the binding devdocs channel.
183
+
184
+ **This phase is non-blocking** — if the dossier write fails, log the reason and continue to Phase C1.5. Never stall close for dossier maintenance.
185
+
186
+ **Skip if**: `{PR_NUMBER}` is empty (investigation-only tasks) OR `{REPO_PATH}` is unset OR `devdocs/index.yaml` does not contain a `modules:` section OR no PR files match any module glob.
187
+
188
+ ### Step 1: Resolve affected files from FORGE:BUILDER comment
189
+
190
+ ```bash
191
+ # Read FORGE:BUILDER comment to get the list of changed files
192
+ BUILDER_COMMENT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
193
+ --jq '[.[] | select(.body | contains("FORGE:BUILDER"))] | last | .body // ""' 2>/dev/null || echo "")
194
+
195
+ # Extract file paths from the Changes section (lines starting with `- \`filepath\``)
196
+ CHANGED_FILES_RAW=$(echo "$BUILDER_COMMENT" \
197
+ | sed -n '/^### Changes/,/^###/p' \
198
+ | grep -oE '`[^`]+`' \
199
+ | tr -d '`' \
200
+ | grep -E '\.' \
201
+ | head -20)
202
+
203
+ # Fallback: try the FORGE:INVESTIGATOR affected files list
204
+ if [ -z "$CHANGED_FILES_RAW" ]; then
205
+ CHANGED_FILES_RAW=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
206
+ --jq '[.[] | select(.body | contains("FORGE:INVESTIGATOR"))] | last | .body // ""' 2>/dev/null \
207
+ | sed -n '/### Affected Files/,/###/p' \
208
+ | grep -oE '`[^`]+`' \
209
+ | tr -d '`' \
210
+ | grep -E '\.' \
211
+ | head -20)
212
+ fi
213
+
214
+ if [ -z "$CHANGED_FILES_RAW" ]; then
215
+ echo "Phase C1.7: No changed files found from FORGE:BUILDER or FORGE:INVESTIGATOR — skipping dossier append"
216
+ # → continue to Phase C1.5
217
+ fi
218
+ ```
219
+
220
+ ### Step 2: Match against module globs and append entries
221
+
222
+ ```bash
223
+ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
224
+ DEVDOCS_REL=$(yq '.devdocs.path // "devdocs"' "$CONFIG_FILE" 2>/dev/null || echo "devdocs")
225
+ DEVDOCS_PATH="${REPO_PATH:-{REPO_PATH}}/${DEVDOCS_REL}"
226
+ INDEX_PATH="${DEVDOCS_PATH}/index.yaml"
227
+
228
+ if [ ! -f "$INDEX_PATH" ]; then
229
+ echo "Phase C1.7: ${INDEX_PATH} not found — skipping dossier append"
230
+ # → continue to Phase C1.5
231
+ fi
232
+
233
+ # Extract modules entries: "name|glob|path"
234
+ MODULE_ENTRIES=$(yq '.modules[]? | .name + "|" + .glob + "|" + .path' "$INDEX_PATH" 2>/dev/null || echo "")
235
+
236
+ if [ -z "$MODULE_ENTRIES" ]; then
237
+ echo "Phase C1.7: No modules[] section in index.yaml — skipping dossier append"
238
+ # → continue to Phase C1.5
239
+ fi
240
+
241
+ DOSSIER_TIMESTAMP=$(date -u +"%Y-%m-%d")
242
+ DOSSIER_UPDATED_MODULES=""
243
+
244
+ # Iterate module entries; for each: check if any changed file matches the glob
245
+ while IFS='|' read -r MOD_NAME MOD_GLOB MOD_PATH; do
246
+ [ -z "$MOD_GLOB" ] || [ -z "$MOD_PATH" ] && continue
247
+ DOSSIER_ABS="${DEVDOCS_PATH}/${MOD_PATH}"
248
+
249
+ MATCHED=0
250
+ # Iterate changed files using while read — not bare for-in (IFS word-split guard per c39758d)
251
+ while IFS= read -r af; do
252
+ [ -z "$af" ] && continue
253
+ AF_BASENAME=$(basename "$af")
254
+ case "$AF_BASENAME" in
255
+ $MOD_GLOB) MATCHED=1; break ;;
256
+ esac
257
+ case "$af" in
258
+ $MOD_GLOB) MATCHED=1; break ;;
259
+ esac
260
+ done <<< "$CHANGED_FILES_RAW"
261
+
262
+ if [ "$MATCHED" -eq 0 ]; then
263
+ continue
264
+ fi
265
+
266
+ echo "Phase C1.7: Module '${MOD_NAME}' matched (glob '${MOD_GLOB}') — appending entry to ${MOD_PATH}"
267
+
268
+ # Build entry text
269
+ # One-line summary from the PR title + issue number
270
+ PR_TITLE=$(gh pr view {PR_NUMBER} {GH_FLAG} --json title --jq '.title' 2>/dev/null || echo "untitled")
271
+ DOSSIER_ENTRY="## Entry ${DOSSIER_TIMESTAMP} — ${PR_TITLE} (#{NUMBER})
272
+
273
+ PR #{PR_NUMBER} touched \`${MOD_NAME}\`. See FORGE:BUILDER comment on issue #{NUMBER} for full change list.
274
+ Key gotcha recorded: (update this entry by editing \`${MOD_PATH}\` in a follow-up PR if the change revealed a new failure mode).
275
+ Cite: #${NUMBER} / PR #{PR_NUMBER}."
276
+
277
+ # Ensure dossier file exists (create skeleton if missing — allows operator to create
278
+ # a new module entry in index.yaml before the dossier file is seeded)
279
+ if [ ! -f "$DOSSIER_ABS" ]; then
280
+ mkdir -p "$(dirname "$DOSSIER_ABS")"
281
+ cat > "$DOSSIER_ABS" <<DOSSIER_INIT_EOF
282
+ ---
283
+ module: ${MOD_NAME}
284
+ glob: "${MOD_GLOB}"
285
+ authority: required
286
+ token_cost: 200
287
+ last_compacted: "${DOSSIER_TIMESTAMP}"
288
+ ---
289
+
290
+ # Module Dossier: ${MOD_NAME}
291
+
292
+ Rolling per-module knowledge log. Each entry is 3–5 lines with a citation.
293
+ Hard cap: 150 lines. Entries are appended by close.md Phase C1.7 after each
294
+ PR that touches this module. When the file exceeds 150 lines, oldest entries
295
+ are compacted into the Summary block (LLM compaction, in-run).
296
+
297
+ ## Summary
298
+
299
+ _No compacted history yet. Dossier was auto-created on ${DOSSIER_TIMESTAMP} by close.md Phase C1.7._
300
+ DOSSIER_INIT_EOF
301
+ echo "Phase C1.7: Created new dossier skeleton at ${DOSSIER_ABS}"
302
+ fi
303
+
304
+ # Append entry (avoid subshell — use file redirect directly)
305
+ printf '\n%s\n' "$DOSSIER_ENTRY" >> "$DOSSIER_ABS"
306
+
307
+ # Compact if over 150 lines
308
+ DOSSIER_LINE_COUNT=$(wc -l < "$DOSSIER_ABS" 2>/dev/null || echo 0)
309
+ if [ "$DOSSIER_LINE_COUNT" -gt 150 ]; then
310
+ echo "Phase C1.7: Dossier ${MOD_PATH} has ${DOSSIER_LINE_COUNT} lines (>150) — compacting oldest entries"
311
+ # LLM compaction: read the dossier, summarize oldest Entry blocks into the ## Summary
312
+ # section, keeping the most recent 3 entries intact.
313
+ # Implementation note: this is prose-instruction compaction (LLM reads the file and
314
+ # rewrites it). The compacted file must preserve the frontmatter and ## Summary block;
315
+ # it may replace old ## Entry blocks with a single "## Archived Summary (compacted)"
316
+ # block. After compaction, update frontmatter last_compacted to today's date.
317
+ # The compacted file MUST be ≤ 150 lines. If compaction fails (e.g. LLM context
318
+ # overflow), log a warning and leave the file as-is — never delete entries silently.
319
+ echo "COMPACT INSTRUCTION: Read ${DOSSIER_ABS}. Keep the frontmatter (lines between ---), keep the ## Summary section, keep the 3 most recent ## Entry blocks, and replace all older ## Entry blocks with a single '## Archived Summary (compacted — ${DOSSIER_TIMESTAMP})' block containing a 5–8 line distillation of the key failure modes, gotchas, and citations from the archived entries. Write the result back to ${DOSSIER_ABS}. The output MUST be ≤ 150 lines. Update frontmatter last_compacted to ${DOSSIER_TIMESTAMP}."
320
+ fi
321
+
322
+ DOSSIER_UPDATED_MODULES="${DOSSIER_UPDATED_MODULES} ${MOD_NAME}"
323
+
324
+ done <<< "$MODULE_ENTRIES"
325
+ ```
326
+
327
+ ### Step 3: Commit dossier changes and post annotation
328
+
329
+ ```bash
330
+ if [ -n "$DOSSIER_UPDATED_MODULES" ]; then
331
+ # Commit the updated dossier files
332
+ cd "{REPO_PATH}"
333
+ CHANGED_DOSSIER_FILES=$(echo "$DOSSIER_UPDATED_MODULES" | tr ' ' '\n' | while IFS= read -r mod; do
334
+ yq ".modules[]? | select(.name == \"${mod}\") | \"${DEVDOCS_REL}/\" + .path" "$INDEX_PATH" 2>/dev/null
335
+ done | grep -v '^$')
336
+
337
+ if [ -n "$CHANGED_DOSSIER_FILES" ]; then
338
+ # CHANGED_DOSSIER_FILES is newline-separated — iterate so paths containing
339
+ # spaces are staged individually rather than word-split by the shell.
340
+ while IFS= read -r dossier_file; do
341
+ [ -n "$dossier_file" ] || continue
342
+ git -C "{REPO_PATH}" add "$dossier_file" 2>/dev/null || true
343
+ done <<< "$CHANGED_DOSSIER_FILES"
344
+ # Only commit if there are staged changes (new or modified dossier files)
345
+ if ! git -C "{REPO_PATH}" diff --cached --quiet 2>/dev/null; then
346
+ git -C "{REPO_PATH}" commit -s -m "docs(dossier): append entry for PR #{PR_NUMBER} (#${NUMBER})" 2>/dev/null || true
347
+ echo "Phase C1.7: Dossier commit created for modules:${DOSSIER_UPDATED_MODULES}"
348
+ else
349
+ echo "Phase C1.7: No staged dossier changes — skipping commit"
350
+ fi
351
+ fi
352
+
353
+ # Post annotation on the issue
354
+ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DOSSIER_UPDATED -->
355
+ Module dossier(s) updated:${DOSSIER_UPDATED_MODULES}
356
+
357
+ Entries appended to \`devdocs/modules/\` after PR #{PR_NUMBER} merged. Future agents working
358
+ on these modules will receive the updated knowledge through the devdocs channel (context.md Phase C-1).
359
+
360
+ <!-- FORGE:DOSSIER_UPDATED:COMPLETE -->" 2>/dev/null || true
361
+ else
362
+ echo "Phase C1.7: No module dossiers matched changed files — skipping"
363
+ fi
364
+ ```
365
+
366
+ ---
367
+
109
368
  ## Phase C1.5: Project Board Update (Status=Done, Workflow=Merged)
110
369
 
111
370
  Update the project board to reflect the merged state. This replaces the old Phase 5E project board update that existed before the modular refactor.
@@ -404,7 +663,58 @@ CARD_JSON=$(jq -nc \
404
663
 
405
664
  ## Phase C5: Trajectory Log (MANDATORY)
406
665
 
407
- Post the `<!-- FORGE:TRAJECTORY -->` comment as the final pipeline record:
666
+ **CODEC PATH (forge#1727)**: Post the `<!-- FORGE:TRAJECTORY -->` comment via the protocol codec — do NOT hand-roll the opening tag. Use `forge-annotation.sh write TRAJECTORY --field ...` or `node packages/protocol/src/cli.js emit TRAJECTORY` to produce the opening tag. The codec handles any field escaping automatically.
667
+
668
+ ```bash
669
+ # Codec produces the opening <!-- FORGE:TRAJECTORY --> tag
670
+ TRAJECTORY_HEADER=$(node packages/protocol/src/cli.js emit TRAJECTORY)
671
+ # $TRAJECTORY_HEADER = "<!-- FORGE:TRAJECTORY -->"
672
+ # Append the Markdown body sections, then post via gh issue comment.
673
+ ```
674
+
675
+ Post the `<!-- FORGE:TRAJECTORY -->` comment as the final pipeline record.
676
+
677
+ **Prior delta computation** — read cost-prior for this issue's task_type × module before posting (forge#1743):
678
+
679
+ ```bash
680
+ # Compute actual vs prior cost delta for self-correction of cost priors
681
+ COST_PRIORS_PATH="${HOME}/.forge/index/cost-priors.json"
682
+ ACTUAL_TOTAL_USD=""
683
+ PRIOR_EST_USD=""
684
+ COST_DELTA_NOTE=""
685
+
686
+ # Read actual spend from FORGE:BUILDER / FORGE:TRAJECTORY best-effort telemetry
687
+ # (same extraction used in work-on.md Phase 7C DECISION_RECORD cost block)
688
+ ACTUAL_TOTAL_USD=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
689
+ --jq '[.[] | select(.body | contains("FORGE:BUILDER")) | .body] | last // ""' 2>/dev/null \
690
+ | grep -oP '(?<=cost_usd: )\S+' | head -1 || echo "")
691
+
692
+ if [ -n "$ACTUAL_TOTAL_USD" ] && [ -f "$COST_PRIORS_PATH" ]; then
693
+ # Derive task_type:module key (same logic as Step 3E.5)
694
+ TASK_TYPE=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
695
+ --jq '[.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body] | last // ""' 2>/dev/null \
696
+ | grep -oP '(?<=\*\*Task Type\*\*: )\S+' | head -1 | tr '[:upper:]' '[:lower:]' | tr ' ' '-' || echo 'unknown')
697
+ PRIMARY_FILE=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
698
+ --jq '[.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body] | last // ""' 2>/dev/null \
699
+ | grep -oP '`[^`]+\.(py|mjs|ts|md|sh|yaml|yml)`' | tr -d '`' | head -1 || echo '')
700
+ MODULE=$(basename "${PRIMARY_FILE:-_unknown}" | sed 's/\.[^.]*$//' | tr '[:upper:]' '[:lower:]')
701
+ [ -z "$MODULE" ] && MODULE="_unknown"
702
+ PRIOR_KEY="${TASK_TYPE}:${MODULE}"
703
+
704
+ PRIOR_EST_USD=$(jq -r --arg k "$PRIOR_KEY" '.priors[$k].mean // empty' "$COST_PRIORS_PATH" 2>/dev/null || echo '')
705
+
706
+ if [ -n "$PRIOR_EST_USD" ]; then
707
+ DELTA=$(echo "scale=4; $ACTUAL_TOTAL_USD - $PRIOR_EST_USD" | bc 2>/dev/null || echo "?")
708
+ COST_DELTA_NOTE="actual=\$${ACTUAL_TOTAL_USD} prior=\$${PRIOR_EST_USD} delta=${DELTA} key=${PRIOR_KEY}"
709
+ else
710
+ COST_DELTA_NOTE="actual=\$${ACTUAL_TOTAL_USD} prior=absent (no prior for key ${PRIOR_KEY})"
711
+ fi
712
+ elif [ -n "$ACTUAL_TOTAL_USD" ]; then
713
+ COST_DELTA_NOTE="actual=\$${ACTUAL_TOTAL_USD} prior=index-absent"
714
+ else
715
+ COST_DELTA_NOTE="no-telemetry"
716
+ fi
717
+ ```
408
718
 
409
719
  ```bash
410
720
  gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:TRAJECTORY -->
@@ -426,19 +736,39 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:TRAJECTORY -->
426
736
  - Decomposition skipped: {DECOMPOSE_REASON}
427
737
  - PR merged to: \`{PR_BASE}\` (feature lane, milestone branch)
428
738
 
739
+ **Cost (economic scheduling)**: ${COST_DELTA_NOTE}
740
+
429
741
  **Anomalies**: None
430
742
 
431
743
  **Pipeline completed**: {TIMESTAMP}
432
744
 
433
- <!-- FORGE:CARD ${CARD_JSON} -->"
745
+ $(node packages/protocol/src/cli.js emit CARD --b64 \
746
+ --field issue={NUMBER} \
747
+ --field status={CARD_STATUS} \
748
+ --field pipeline="${PIPELINE_LINE}" \
749
+ --field pr={PR_NUMBER} \
750
+ --field pr_target="${PR_TARGET}" \
751
+ --field commits="${COMMITS}" \
752
+ --field additions="${ADDITIONS}" \
753
+ --field deletions="${DELETIONS}" \
754
+ --field review="${REVIEW_SUMMARY}" \
755
+ --field elapsed="${ELAPSED_SECS:-0}")"
434
756
  ```
435
757
 
436
- The `<!-- FORGE:CARD {...} -->` block carries the machine-readable summary computed in
437
- Phase C4.5c. It is wrapped in an HTML comment so it stays hidden in GitHub's rendered view
438
- (keeping the trajectory comment clean) while remaining greppable in the raw body for platform
439
- consumption — `/orchestrate` reads it to build per-issue cards. This block is **additive**:
440
- all existing `FORGE:TRAJECTORY` consumers select via `contains("FORGE:TRAJECTORY")` and parse
441
- the markdown table, so the embedded JSON does not affect them.
758
+ The `<!-- FORGE:CARD: v1 sha:... b64:... -->` line carries the machine-readable summary computed in
759
+ Phase C4.5c, encoded as Base64url (design decision 2026-07-08: encoding beats escaping — the
760
+ Base64url alphabet cannot contain HTML comment delimiters by construction). It is wrapped in
761
+ the inline-value annotation form `<!-- FORGE:CARD: ... -->` so parse() extracts the encoded
762
+ payload. Platform consumers (e.g., `/orchestrate`) decode via `node packages/protocol/src/cli.js
763
+ parse --type CARD --field status`. This block is **additive**: all existing `FORGE:TRAJECTORY`
764
+ consumers select via `contains("FORGE:TRAJECTORY")` and parse the markdown table, so the
765
+ embedded CARD line does not affect them.
766
+
767
+ **CODEC PATH (forge#1727)**: The `$(node packages/protocol/src/cli.js emit CARD --b64 ...)` call
768
+ above replaces the previous `<!-- FORGE:CARD ${CARD_JSON} -->` inline-JSON form. The Base64url
769
+ form is safe against all HTML comment injection vectors and includes a sha8 integrity prefix for
770
+ truncation detection. Consumers that parsed the old inline-JSON form must migrate to the codec
771
+ parse path: `echo '...' | node packages/protocol/src/cli.js parse --type CARD --field <key>`.
442
772
 
443
773
  Where:
444
774
  - `{PARENT_STATUS}` = `⏭ Skipped` (if no parent) or `✅ Complete` (if parent updated)
@@ -448,6 +778,29 @@ Where:
448
778
 
449
779
  ---
450
780
 
781
+ ## Phase C5.1: Knowledge Index + Cost Prior Update (forge#1743) <!-- Added: forge#1743 -->
782
+
783
+ **Goal**: Re-index this issue's knowledge cards and regenerate cost-priors.json so that economic scheduling (orchestrate Step 3E.5) has up-to-date data for future runs. The actual-vs-prior delta recorded in the TRAJECTORY above is the write side of the self-correction loop — this step performs the read/recompute.
784
+
785
+ **This phase is non-blocking** — if the indexer fails, log the reason and continue to Phase C5.2. Never stall close for the cost-prior update.
786
+
787
+ **Skip if**: Terminal state is `INVALID` (no useful cost data from invalid issues).
788
+
789
+ ```bash
790
+ # Re-index this issue and regenerate cost priors — non-blocking
791
+ INDEXER_PATH=$(dirname "$(realpath "$0" 2>/dev/null || echo '.')")/../../scripts/build-knowledge-index.mjs
792
+ if node --version >/dev/null 2>&1 && [ -f "$INDEXER_PATH" ]; then
793
+ echo "[cost-prior] Re-indexing issue #${NUMBER} and regenerating cost priors..."
794
+ node "$INDEXER_PATH" --issue {NUMBER} --no-mirror 2>&1 | tail -5 \
795
+ && echo "[cost-prior] Cost priors updated" \
796
+ || echo "WARNING: Cost prior update failed — continuing (non-blocking)"
797
+ else
798
+ echo "[cost-prior] Indexer not available — skipping cost prior update (non-blocking)"
799
+ fi
800
+ ```
801
+
802
+ ---
803
+
451
804
  ## Phase C5.2: Memory Index Update <!-- Added: forge#1316 -->
452
805
 
453
806
  **Goal**: Append this run's learnings to the per-repo memory index so future `investigate` runs can retrieve relevant priors. This is the write side of the compounding intelligence loop.
@@ -571,6 +924,266 @@ fi
571
924
 
572
925
  ---
573
926
 
927
+ ## Phase C5.3: Knowledge Ledger Index <!-- Added: forge#1732 -->
928
+
929
+ **Goal**: Index the just-closed issue into the Forge Ledger so future context phases can retrieve
930
+ its knowledge cards by file path or symbol without making live GitHub API calls.
931
+
932
+ **This phase is non-blocking** — if the indexer fails, log the reason and continue to Phase C5.5.
933
+ Never stall close for ledger indexing.
934
+
935
+ **Skip if**: Terminal state is `INVALID` (no confirmed findings to index) OR a `<!-- FORGE:LEDGER_INDEXED -->` comment already exists on the issue (idempotency guard).
936
+
937
+ **Requires**: `scripts/build-knowledge-index.mjs` present in the repository root. If absent, skip
938
+ with a warning — the feature may not be installed on this version.
939
+
940
+ ### Step 1: Idempotency check
941
+
942
+ ```bash
943
+ LEDGER_INDEXED=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
944
+ --jq '[.[] | select(.body | contains("FORGE:LEDGER_INDEXED"))] | length > 0' 2>/dev/null || echo "false")
945
+ ```
946
+
947
+ **Skip to Phase C5.5 if `$LEDGER_INDEXED == "true"`**.
948
+
949
+ ### Step 2: Run incremental indexer
950
+
951
+ Resolve the indexer script path relative to the repository root:
952
+
953
+ ```bash
954
+ INDEXER_PATH="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null)}/scripts/build-knowledge-index.mjs"
955
+
956
+ if [ ! -f "$INDEXER_PATH" ]; then
957
+ echo "[LEDGER] scripts/build-knowledge-index.mjs not found — skipping Phase C5.3"
958
+ echo "[LEDGER] Install: update ForgeDock to a version that ships this file"
959
+ else
960
+ # Run incremental indexer for this issue only — ~2 API calls
961
+ LEDGER_EXIT=0
962
+ LEDGER_OUTPUT=$(node "$INDEXER_PATH" \
963
+ --issue {NUMBER} \
964
+ --repo {GH_REPO} \
965
+ --no-mirror \
966
+ 2>&1) || LEDGER_EXIT=$?
967
+
968
+ if [ $LEDGER_EXIT -eq 0 ]; then
969
+ echo "[LEDGER] Issue #{NUMBER} indexed into Forge Ledger"
970
+
971
+ # Mirror update: run separately after single-issue index so mirror has up-to-date postings
972
+ node "$INDEXER_PATH" --issue {NUMBER} --repo {GH_REPO} \
973
+ 2>/dev/null || true # Non-blocking: mirror failure does not affect local index
974
+
975
+ # Post audit annotation (idempotency guard for future close runs)
976
+ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:LEDGER_INDEXED -->
977
+ Issue #{NUMBER} has been indexed into the Forge Ledger.
978
+
979
+ Knowledge cards extracted from FORGE:INVESTIGATOR and FORGE:TRAJECTORY annotations are now
980
+ queryable via \`forge recall\` (exact file/symbol lookup or free-text BM25 search).
981
+
982
+ \`\`\`
983
+ forge recall --file {PRIMARY_AFFECTED_FILE} --json
984
+ \`\`\`
985
+
986
+ <!-- FORGE:LEDGER_INDEXED:COMPLETE -->" 2>/dev/null || true
987
+
988
+ else
989
+ echo "[LEDGER] WARNING: Indexer exited with code ${LEDGER_EXIT} — continuing"
990
+ echo "[LEDGER] Output: ${LEDGER_OUTPUT}"
991
+ fi
992
+ fi
993
+ ```
994
+
995
+ ### Watermark semantics
996
+
997
+ The indexer's watermark is `max(issue.updated_at)` across all indexed issues. Indexing a
998
+ single issue via `--issue` advances the watermark only if this issue's `updated_at` is newer
999
+ than the stored watermark — ensuring the next full incremental run starts from the right
1000
+ position and does not re-scan already-indexed history.
1001
+
1002
+ ---
1003
+
1004
+ ## Phase C5.4: Auto-ADR Extraction from TRAJECTORY Decisions <!-- Added: forge#1737 -->
1005
+
1006
+ **Goal**: Promote tradeoff-shaped Decisions bullets from the FORGE:TRAJECTORY comment into
1007
+ human-readable, git-tracked ADR markdown files at `devdocs/decisions/NNN-{slug}.md`. Architect
1008
+ plans on future runs will load matching ADRs as constraints before writing any code.
1009
+
1010
+ **This phase is non-blocking** — if ADR extraction, file write, or commit fails at any step,
1011
+ log the reason and continue to Phase C5.5. Never stall close for ADR generation.
1012
+
1013
+ **Skip if**: Terminal state is `INVALID` (no useful decisions to record) OR a
1014
+ `<!-- FORGE:ADR_EXTRACTED -->` comment already exists on the issue (idempotency guard) OR
1015
+ `devdocs/decisions/` directory does not exist in the repository root (feature not installed).
1016
+
1017
+ ### What is a "tradeoff-shaped" decision?
1018
+
1019
+ A Decisions bullet is extractable if it contains BOTH:
1020
+ 1. A **choice indicator**: words like `chose`, `use`, `prefer`, `process substitution`, `over`,
1021
+ `instead`, `rather than`, `not X`
1022
+ 2. A **rationale connector**: `because`, `since`, `so`, `to avoid`, `prevents`, `due to`
1023
+
1024
+ Generic bullets like `- Decomposition skipped: single-concern change` do NOT match and are ignored.
1025
+
1026
+ ### Step 1: Idempotency check
1027
+
1028
+ ```bash
1029
+ ADR_EXTRACTED=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
1030
+ --jq '[.[] | select(.body | contains("FORGE:ADR_EXTRACTED"))] | length > 0' 2>/dev/null || echo "false")
1031
+ ```
1032
+
1033
+ **Skip to Phase C5.5 if `$ADR_EXTRACTED == "true"`**.
1034
+
1035
+ ### Step 2: Extract TRAJECTORY Decisions
1036
+
1037
+ ```bash
1038
+ # Read the TRAJECTORY comment posted in Phase C5
1039
+ TRAJECTORY_BODY=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
1040
+ --jq '[.[] | select(.body | contains("FORGE:TRAJECTORY"))] | last | .body // ""' 2>/dev/null || echo '')
1041
+
1042
+ # Extract the Decisions section (lines between **Decisions**: and **Anomalies**:)
1043
+ DECISIONS_RAW=$(echo "$TRAJECTORY_BODY" \
1044
+ | awk '/^\*\*Decisions\*\*:/{found=1; next} /^\*\*Anomalies\*\*:/{found=0} found{print}' \
1045
+ | grep -v '^\s*$' \
1046
+ | head -20) # Cap at 20 bullets to prevent runaway parsing
1047
+
1048
+ if [ -z "$DECISIONS_RAW" ] || echo "$DECISIONS_RAW" | grep -qi "^- None$\|^None$"; then
1049
+ echo "[ADR] No Decisions section found in TRAJECTORY — skipping ADR extraction"
1050
+ ADR_FILES_WRITTEN=0
1051
+ fi
1052
+ ```
1053
+
1054
+ ### Step 3: Parse and filter for tradeoff shape
1055
+
1056
+ ```bash
1057
+ ADR_FILES_WRITTEN=0
1058
+ DECISIONS_DIR="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null)}/devdocs/decisions"
1059
+
1060
+ if [ -z "$DECISIONS_RAW" ] || ! [ -d "$DECISIONS_DIR" ]; then
1061
+ echo "[ADR] Skipping: no decisions or devdocs/decisions/ absent"
1062
+ else
1063
+ # Get commit SHA for anchor citation
1064
+ COMMIT_SHA=$(git -C "${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null)}" \
1065
+ rev-parse HEAD 2>/dev/null | head -c 12 || echo "unknown")
1066
+
1067
+ while IFS= read -r bullet; do
1068
+ # Strip leading "- " or "* "
1069
+ text=$(echo "$bullet" | sed 's/^[-*]\s*//')
1070
+ [ -z "$text" ] && continue
1071
+
1072
+ # Tradeoff shape filter: must have a choice indicator + rationale connector
1073
+ CHOICE_MATCH=$(echo "$text" | grep -iE 'chose|use |prefer|instead|rather than|over |not [a-z]|process substitution|branched from|avoided' || true)
1074
+ RATIONALE_MATCH=$(echo "$text" | grep -iE 'because|since[[:space:]]|so[[:space:]]|to avoid|prevents|due to' || true)
1075
+
1076
+ if [ -z "$CHOICE_MATCH" ] || [ -z "$RATIONALE_MATCH" ]; then
1077
+ echo "[ADR] Skipped (not a tradeoff): $text"
1078
+ continue
1079
+ fi
1080
+
1081
+ # Extract anchor: first backtick-quoted path-like string in the decision text
1082
+ ANCHOR_PATH=$(echo "$text" | grep -oE '`[a-zA-Z][^`]*/[^`]+`' | head -1 | tr -d '`' || true)
1083
+
1084
+ # Build slug from first 6 words of decision (lowercase, hyphenated)
1085
+ SLUG=$(echo "$text" | tr '[:upper:]' '[:lower:]' | \
1086
+ sed 's/[^a-z0-9 ]/ /g' | tr -s ' ' '-' | \
1087
+ cut -c1-40 | sed 's/-$//')
1088
+ ADR_FILENAME="${NUMBER}-${SLUG}.md"
1089
+ ADR_PATH="$DECISIONS_DIR/$ADR_FILENAME"
1090
+
1091
+ # Idempotency: skip if file already exists
1092
+ if [ -f "$ADR_PATH" ]; then
1093
+ echo "[ADR] Already exists — skipping: $ADR_FILENAME"
1094
+ ADR_FILES_WRITTEN=$((ADR_FILES_WRITTEN + 1))
1095
+ continue
1096
+ fi
1097
+
1098
+ # Write ADR file
1099
+ PR_REF="${PR_NUMBER:-unknown}"
1100
+ cat > "$ADR_PATH" <<ADR_EOF
1101
+ ---
1102
+ issue: {NUMBER}
1103
+ pr: ${PR_REF}
1104
+ commit: ${COMMIT_SHA}
1105
+ status: fresh
1106
+ anchor: ${ANCHOR_PATH:-unknown}
1107
+ created: $(date -u +%Y-%m-%d)
1108
+ ---
1109
+
1110
+ # ADR — ${text}
1111
+
1112
+ ## Decision
1113
+
1114
+ ${text}
1115
+
1116
+ ## Context
1117
+
1118
+ Auto-extracted from FORGE:TRAJECTORY Decisions section on issue #{NUMBER}.
1119
+
1120
+ **Citations**:
1121
+ - Issue: https://github.com/{GH_REPO}/issues/{NUMBER}
1122
+ - PR: https://github.com/{GH_REPO}/pull/${PR_REF}
1123
+ - Commit: ${COMMIT_SHA}
1124
+ - Anchor: \`${ANCHOR_PATH:-no file anchor found}\`
1125
+
1126
+ ## Status
1127
+
1128
+ \`fresh\` — anchor is active. Architect plans on future runs will inject this ADR as a constraint
1129
+ when the anchor path overlaps the contract files.
1130
+
1131
+ Set \`status: needs-review\` manually (or the staleness pass in \`build-knowledge-index.mjs\` will
1132
+ flip it automatically) when the anchored code region no longer exists.
1133
+ ADR_EOF
1134
+
1135
+ echo "[ADR] Written: $ADR_FILENAME"
1136
+ ADR_FILES_WRITTEN=$((ADR_FILES_WRITTEN + 1))
1137
+ done <<< "$DECISIONS_RAW"
1138
+ fi
1139
+ ```
1140
+
1141
+ ### Step 4: Commit and push ADR files (non-blocking)
1142
+
1143
+ ```bash
1144
+ if [ "$ADR_FILES_WRITTEN" -gt 0 ] && [ -n "{WORKTREE_PATH}" ] && [ -d "{WORKTREE_PATH}" ]; then
1145
+ # Commit ADR files in the worktree so they ride the existing PR
1146
+ (
1147
+ cd "{WORKTREE_PATH}"
1148
+ # Stage only the new/updated ADR files (not the whole tree)
1149
+ git add devdocs/decisions/*.md 2>/dev/null || true
1150
+ # Check if there's anything to commit
1151
+ if ! git diff --cached --quiet 2>/dev/null; then
1152
+ git commit -s -m "docs(decisions): auto-ADRs from TRAJECTORY decisions (#{NUMBER})" \
1153
+ --no-verify 2>/dev/null && echo "[ADR] Committed ${ADR_FILES_WRITTEN} ADR file(s)" || \
1154
+ echo "[ADR] WARNING: commit failed — ADR files on disk but not in git"
1155
+ else
1156
+ echo "[ADR] No staged changes — ADR files may already be committed"
1157
+ fi
1158
+ ) || echo "[ADR] WARNING: worktree operations failed — ADR files written but not committed"
1159
+ else
1160
+ if [ "$ADR_FILES_WRITTEN" -gt 0 ]; then
1161
+ echo "[ADR] No worktree available — ADR files written to repo root devdocs/decisions/ only"
1162
+ echo "[ADR] Manually commit devdocs/decisions/*.md if needed"
1163
+ fi
1164
+ fi
1165
+ ```
1166
+
1167
+ ### Step 5: Post audit annotation
1168
+
1169
+ ```bash
1170
+ if [ "${ADR_FILES_WRITTEN:-0}" -gt 0 ]; then
1171
+ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ADR_EXTRACTED -->
1172
+ ${ADR_FILES_WRITTEN} ADR file(s) auto-extracted from TRAJECTORY decisions and written to \`devdocs/decisions/\`.
1173
+
1174
+ Future architect runs will load matching ADRs as constraints when anchor paths overlap contract files.
1175
+
1176
+ ADRs are human-editable — update or remove them as the codebase evolves. The staleness pass in
1177
+ \`build-knowledge-index.mjs\` automatically flips \`status: needs-review\` when an anchor is dead.
1178
+
1179
+ <!-- FORGE:ADR_EXTRACTED:COMPLETE -->" 2>/dev/null || true
1180
+ else
1181
+ echo "[ADR] No tradeoff-shaped decisions found — no ADR files written"
1182
+ fi
1183
+ ```
1184
+
1185
+ ---
1186
+
574
1187
  ## Phase C5.5: Graph Decision Record (MANDATORY when PR exists)
575
1188
 
576
1189
  **Skip if**: `{PR_NUMBER}` is empty OR `<!-- FORGE:DECISION_RECORD -->` already posted on the PR.