@jamie-tam/forge 6.0.0 → 6.2.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.
Files changed (69) hide show
  1. package/README.md +77 -59
  2. package/agents/dreamer.md +10 -7
  3. package/agents/gotcha-hunter.md +1 -1
  4. package/agents/prototype-codifier.md +2 -2
  5. package/commands/{forge.md → discover.md} +13 -9
  6. package/commands/dream.md +71 -0
  7. package/commands/feature.md +57 -10
  8. package/commands/{evolve.md → forge-evolve.md} +3 -3
  9. package/commands/greenfield.md +5 -5
  10. package/commands/note.md +64 -0
  11. package/commands/{task-force.md → parallel.md} +15 -15
  12. package/commands/resume.md +2 -2
  13. package/commands/setup.md +18 -17
  14. package/commands/status.md +2 -2
  15. package/commands/wrap.md +130 -0
  16. package/dist/__tests__/hooks.test.js +334 -0
  17. package/dist/__tests__/init.test.js +110 -0
  18. package/dist/__tests__/work-manifest.test.js +48 -14
  19. package/dist/cli.js +0 -0
  20. package/dist/hooks.js +88 -6
  21. package/dist/init.js +39 -1
  22. package/dist/uninstall.js +11 -5
  23. package/dist/work-manifest.js +63 -24
  24. package/hooks/config/gate-requirements.json +1 -1
  25. package/hooks/hooks.json +14 -1
  26. package/hooks/scripts/gate-enforcer.sh +51 -6
  27. package/hooks/scripts/pre-compact.sh +120 -55
  28. package/hooks/scripts/session-start.sh +43 -4
  29. package/hooks/scripts/telemetry.sh +32 -2
  30. package/hooks/templates/CLAUDE.md.template +6 -3
  31. package/package.json +1 -1
  32. package/references/common/phases.md +8 -6
  33. package/references/common/skill-authoring.md +1 -1
  34. package/rules/common/forge-system.md +64 -6
  35. package/rules/common/quality-gates.md +2 -0
  36. package/skills/build-prototype/SKILL.md +4 -4
  37. package/skills/build-tdd/SKILL.md +14 -0
  38. package/skills/concept-slides/SKILL.md +11 -11
  39. package/skills/deliver-deploy/SKILL.md +10 -1
  40. package/skills/harden/SKILL.md +22 -8
  41. package/skills/iterate-prototype/SKILL.md +22 -0
  42. package/skills/quality-test-execution/SKILL.md +26 -1
  43. package/skills/quality-test-plan/SKILL.md +21 -1
  44. package/skills/support-debug/SKILL.md +1 -1
  45. package/skills/support-dream/SKILL.md +8 -7
  46. package/skills/support-gotcha/SKILL.md +3 -3
  47. package/skills/{support-task-force → support-parallel}/SKILL.md +22 -22
  48. package/skills/{support-task-force → support-parallel}/references/dispatch-pattern.md +10 -10
  49. package/skills/{support-task-force → support-parallel}/references/synthesis-template.md +10 -10
  50. package/skills/support-skill-validator/SKILL.md +5 -5
  51. package/skills/support-skill-validator/references/validation-checks.md +1 -1
  52. package/skills/support-system-guide/SKILL.md +4 -3
  53. package/skills/support-wiki-lint/scripts/lint.mjs +52 -0
  54. package/templates/README.md +1 -1
  55. package/templates/aiwiki/CLAUDE.md.template +48 -22
  56. package/templates/aiwiki/schemas/session.md +134 -49
  57. package/templates/manifests/bugfix.yaml +1 -1
  58. package/templates/manifests/feature.yaml +1 -1
  59. package/templates/manifests/greenfield.yaml +1 -1
  60. package/templates/manifests/hotfix.yaml +1 -1
  61. package/templates/manifests/refactor.yaml +1 -1
  62. package/templates/manifests/v5/SCHEMA.md +14 -17
  63. package/templates/manifests/v5/feature.yaml +1 -1
  64. package/templates/manifests/v6/SCHEMA.md +14 -10
  65. package/commands/abort.md +0 -25
  66. package/dist/__tests__/active-manifest.test.js +0 -272
  67. package/dist/__tests__/gate-check.test.js +0 -384
  68. package/dist/active-manifest.js +0 -229
  69. package/dist/gate-check.js +0 -326
package/dist/init.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { cwd } from "node:process";
4
4
  import { SOURCE, targets } from "./paths.js";
5
5
  import { copyFlatDir, copyRules, copyTreeDir } from "./copy.js";
@@ -7,6 +7,38 @@ import { installHooks } from "./hooks.js";
7
7
  import { verify } from "./verify.js";
8
8
  import { collectHashes, writeManifest } from "./manifest.js";
9
9
  import { hasForgeMarkers, mergeForgeBlock } from "./merge.js";
10
+ /**
11
+ * Ensure runtime-only forge paths are gitignored. Idempotent: appends only
12
+ * entries the user does not already have. Creates .gitignore if absent.
13
+ *
14
+ * Entries:
15
+ * .forge/state/ — operational state (telemetry, dream history; session handoff lives in aiwiki/sessions/)
16
+ * .forge/local.yaml — per-user preferences (Codex/Graphify consent, etc.)
17
+ *
18
+ * Why at install time: pre-compact.sh used to add `.forge/state/` from the
19
+ * runtime hook, which surprised users (writes to a tracked file from a hook).
20
+ * Moving this to init means a single intentional touch at user-invoked
21
+ * install time instead of an unexpected mutation mid-session.
22
+ *
23
+ * Returns the list of entries that were added (empty if all already present).
24
+ */
25
+ export function ensureGitignoreEntries(projectRoot) {
26
+ const entries = [".forge/state/", ".forge/local.yaml"];
27
+ const path = join(projectRoot, ".gitignore");
28
+ const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
29
+ const lines = new Set(existing.split("\n").map((l) => l.trim()));
30
+ // Anything matching `.forge/` (full-dir ignore) covers both entries already.
31
+ if (lines.has(".forge/") || lines.has(".forge"))
32
+ return [];
33
+ const toAdd = entries.filter((e) => !lines.has(e));
34
+ if (toAdd.length === 0)
35
+ return [];
36
+ // Preserve trailing newline discipline of the existing file.
37
+ const needsLeadingNewline = existing.length > 0 && !existing.endsWith("\n");
38
+ const block = (needsLeadingNewline ? "\n" : "") + toAdd.join("\n") + "\n";
39
+ writeFileSync(path, existing + block);
40
+ return toAdd;
41
+ }
10
42
  function summarize(label, r) {
11
43
  const parts = [];
12
44
  if (r.added.length)
@@ -52,6 +84,12 @@ export async function init(opts) {
52
84
  }
53
85
  }
54
86
  mkdirSync(t.claude, { recursive: true });
87
+ // Ensure forge runtime paths are gitignored. Done here (not from a runtime
88
+ // hook) so the mutation of a tracked file is intentional and user-visible.
89
+ const added = ensureGitignoreEntries(projectRoot);
90
+ if (added.length > 0) {
91
+ console.log(` gitignore: added ${added.join(", ")}`);
92
+ }
55
93
  // Copy assets
56
94
  const agents = copyFlatDir(SOURCE.agents, t.agents, opts);
57
95
  summarize("agents:", agents);
package/dist/uninstall.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { cwd } from "node:process";
4
4
  import { SOURCE, targets } from "./paths.js";
5
5
  import { readManifest } from "./manifest.js";
6
+ import { uninstallHooks } from "./hooks.js";
6
7
  /**
7
8
  * Remove only forge-managed files from a project.
8
9
  * Uses the installed manifest (forge.json) when available for precision.
@@ -160,11 +161,16 @@ export async function uninstall(opts) {
160
161
  rmSync(hooksDir, { recursive: true });
161
162
  console.log(` ${prefix}: hooks/`);
162
163
  }
163
- const settingsLocal = join(t.claude, "settings.local.json");
164
- if (existsSync(settingsLocal)) {
165
- if (!opts.dryRun)
166
- rmSync(settingsLocal);
167
- console.log(` ${prefix}: settings.local.json`);
164
+ // settings.local.json: strip forge-installed hook entries via reverse-merge,
165
+ // preserve user-added hooks/permissions/env. Delete the file only if nothing
166
+ // user-owned remains.
167
+ const settingsResult = uninstallHooks(projectRoot, { dryRun: opts.dryRun });
168
+ if (settingsResult.action === "deleted") {
169
+ console.log(` ${prefix}: settings.local.json (no user content remained)`);
170
+ }
171
+ else if (settingsResult.action === "updated") {
172
+ const updatePrefix = opts.dryRun ? "Would update" : "Updated";
173
+ console.log(` ${updatePrefix}: settings.local.json (forge hooks removed; user content preserved)`);
168
174
  }
169
175
  // CLAUDE.md and AGENTS.md — only with --force
170
176
  if (opts.force) {
@@ -49,7 +49,6 @@ const VALID_SLICE_STATUSES = [
49
49
  "in-progress",
50
50
  "gated",
51
51
  "complete",
52
- "abandoned",
53
52
  ];
54
53
  const VALID_GATE_STATUSES = [
55
54
  "pending",
@@ -70,14 +69,13 @@ const VALID_MANIFEST_STATUSES = [
70
69
  "paused",
71
70
  "completed",
72
71
  "escalated",
73
- "abandoned",
74
72
  ];
75
- // Per-work-type recommended phase_plan keys, sourced from v6 SCHEMA.md §3.2's
76
- // recommended-keys table. Keys NOT in this set produce W_UNKNOWN_PHASE_PLAN_KEY
77
- // warnings (advisory only typos are silent failures by spec, so a warning
78
- // gives commands a chance to catch them at preflight without breaking workflows
79
- // that legitimately extend their plan with custom keys).
80
- const RECOMMENDED_PHASE_PLAN_KEYS = new Map([
73
+ // Per-work-type allowed phase_plan keys, sourced from v6 SCHEMA.md §3.2's
74
+ // recommended-keys table. Keys NOT in this set produce E_UNKNOWN_PHASE_PLAN_KEY
75
+ // errorsstrict validation per the v6 follow-up decision (2026-05-17) after
76
+ // dogfood signal showed silent typos breaking downstream routing. To add a
77
+ // workflow-specific key, extend the set here AND update SCHEMA.md §3.2.
78
+ const ALLOWED_PHASE_PLAN_KEYS = new Map([
81
79
  [
82
80
  "feature",
83
81
  new Set([
@@ -221,7 +219,7 @@ export function parseManifest(yaml) {
221
219
  validatePhaseGatePremature(manifest, errors);
222
220
  if (detectedSchema === "v6") {
223
221
  validateAndNormalizePhasePlan(manifest, errors);
224
- validatePhasePlanKeysForType(manifest, warnings);
222
+ validatePhasePlanKeysForType(manifest, errors);
225
223
  }
226
224
  if (errors.length > 0) {
227
225
  return { ok: false, errors, warnings };
@@ -621,33 +619,74 @@ function validateAndNormalizePhasePlan(manifest, errors) {
621
619
  manifest.phase_plan = normalized;
622
620
  }
623
621
  /**
624
- * Advisory check: warn on phase_plan keys not in the recommended-keys table for
625
- * this manifest's work type (v6 SCHEMA.md §3.2). Typos in keys are silent
626
- * failures by spec (the parser does not validate against a canonical
627
- * vocabulary), so this warning gives commands a chance to catch them at
628
- * preflight without breaking workflows that legitimately extend the plan with
629
- * custom keys.
622
+ * Strict check: phase_plan keys MUST be in the allowed-keys table for this
623
+ * manifest's work type (v6 SCHEMA.md §3.2). Was a warning until the 2026-05-17
624
+ * follow-up decision promoted to error because dogfood evidence showed
625
+ * silent typos breaking downstream routing (e.g. `phase_plan.prototpe` parses
626
+ * fine but `rules/common/skill-selection.md` reads `phase_plan.prototype`).
630
627
  *
631
- * Implemented as a warning, not an error: SCHEMA.md §3.2 reserves promotion to
632
- * a hard enum if real usage shows typos causing failures. Until then, command
633
- * authors can read `result.warnings` and surface them however they want.
628
+ * To extend a workflow with a new phase_plan key, add it to
629
+ * ALLOWED_PHASE_PLAN_KEYS for the relevant work type AND update SCHEMA.md §3.2.
634
630
  */
635
- function validatePhasePlanKeysForType(manifest, warnings) {
631
+ function validatePhasePlanKeysForType(manifest, errors) {
636
632
  if (!manifest.phase_plan)
637
633
  return;
638
- const allowed = RECOMMENDED_PHASE_PLAN_KEYS.get(manifest.type);
634
+ const allowed = ALLOWED_PHASE_PLAN_KEYS.get(manifest.type);
639
635
  if (!allowed)
640
636
  return; // unknown work type — E_BAD_ENUM_VALUE already caught that
641
637
  for (const key of Object.keys(manifest.phase_plan)) {
642
638
  if (!allowed.has(key)) {
643
- warnings.push({
644
- code: "W_UNKNOWN_PHASE_PLAN_KEY",
645
- message: `phase_plan.${key} is not a recommended key for work type "${manifest.type}" (likely typo; SCHEMA.md §3.2 lists allowed keys)`,
639
+ const suggestion = nearestAllowedKey(key, allowed);
640
+ const suggestionHint = suggestion ? ` (did you mean "${suggestion}"?)` : "";
641
+ errors.push({
642
+ code: "E_UNKNOWN_PHASE_PLAN_KEY",
643
+ message: `phase_plan.${key} is not an allowed key for work type "${manifest.type}"${suggestionHint}. Allowed keys: ${[...allowed].sort().join(", ")}`,
646
644
  path: `phase_plan.${key}`,
647
645
  });
648
646
  }
649
647
  }
650
648
  }
649
+ /**
650
+ * Return the closest allowed key by Levenshtein distance, but only if it's
651
+ * within a small distance (likely typo, not a different word entirely). Helps
652
+ * the "did you mean?" hint in E_UNKNOWN_PHASE_PLAN_KEY messages.
653
+ */
654
+ function nearestAllowedKey(typo, allowed) {
655
+ let best = null;
656
+ for (const k of allowed) {
657
+ const d = levenshtein(typo, k);
658
+ if (best === null || d < best.dist)
659
+ best = { key: k, dist: d };
660
+ }
661
+ // Only suggest if the edit distance is small relative to key length.
662
+ if (!best)
663
+ return null;
664
+ const threshold = Math.max(2, Math.floor(best.key.length / 3));
665
+ return best.dist <= threshold ? best.key : null;
666
+ }
667
+ function levenshtein(a, b) {
668
+ if (a === b)
669
+ return 0;
670
+ if (a.length === 0)
671
+ return b.length;
672
+ if (b.length === 0)
673
+ return a.length;
674
+ let prev = new Array(b.length + 1);
675
+ let curr = new Array(b.length + 1);
676
+ for (let j = 0; j <= b.length; j++)
677
+ prev[j] = j;
678
+ for (let i = 1; i <= a.length; i++) {
679
+ curr[0] = i;
680
+ for (let j = 1; j <= b.length; j++) {
681
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
682
+ curr[j] = Math.min(curr[j - 1] + 1, // insertion
683
+ prev[j] + 1, // deletion
684
+ prev[j - 1] + cost);
685
+ }
686
+ [prev, curr] = [curr, prev];
687
+ }
688
+ return prev[b.length];
689
+ }
651
690
  function validatePhaseGatePremature(manifest, errors) {
652
691
  // §3.6: code-review-final cannot be passed until all slices are terminal.
653
692
  // Only applies when a slice_graph exists — v6 allows manifests without one
@@ -660,7 +699,7 @@ function validatePhaseGatePremature(manifest, errors) {
660
699
  if (!manifest.slice_graph)
661
700
  return;
662
701
  const slices = manifest.slice_graph.slices;
663
- const nonTerminal = Object.entries(slices).filter(([, s]) => s.status !== "complete" && s.status !== "abandoned");
702
+ const nonTerminal = Object.entries(slices).filter(([, s]) => s.status !== "complete");
664
703
  if (nonTerminal.length > 0) {
665
704
  errors.push({
666
705
  code: "E_PHASE_GATE_PREMATURE",
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Maps manifest gate names to required skill and agent invocations. Used by gate-enforcer.sh to verify prerequisites before allowing gate-passed: true. Agents are dispatched by skills internally — listed here so the enforcer can verify they were invoked via telemetry.",
2
+ "_comment": "Maps manifest gate names to required skill and agent invocations. Used by gate-enforcer.sh to verify prerequisites before allowing gate-passed: true. Enforcement is work_id-scoped (v6.2+): the enforcer filters .forge/state/telemetry.jsonl to records whose work_id matches the manifest being edited (extracted from .forge/work/{type}/{name}/manifest.yaml). A stale invocation from another work item, or a legacy record written before work_id was added, does NOT satisfy a fresh gate. Agents are dispatched by skills internally — listed here so the enforcer can verify they were invoked via telemetry.",
3
3
  "requirements": {
4
4
  "skill": "discover-requirements",
5
5
  "agent": null
package/hooks/hooks.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
- "description": "Guardrails for safe git operations and context preservation",
2
+ "description": "Guardrails for safe git operations and context preservation. Every entry carries forge_owned: true so installHooks/uninstallHooks can identify forge-managed entries by provenance marker rather than exact string match — this lets future forge versions change command strings (paths, flags, timeouts) without leaving stale entries in user settings.local.json.",
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
6
+ "forge_owned": true,
6
7
  "matcher": "Bash",
7
8
  "hooks": [
8
9
  {
@@ -13,6 +14,7 @@
13
14
  ]
14
15
  },
15
16
  {
17
+ "forge_owned": true,
16
18
  "matcher": "Bash",
17
19
  "hooks": [
18
20
  {
@@ -23,6 +25,7 @@
23
25
  ]
24
26
  },
25
27
  {
28
+ "forge_owned": true,
26
29
  "matcher": "Edit",
27
30
  "hooks": [
28
31
  {
@@ -33,6 +36,7 @@
33
36
  ]
34
37
  },
35
38
  {
39
+ "forge_owned": true,
36
40
  "matcher": "Write",
37
41
  "hooks": [
38
42
  {
@@ -43,6 +47,7 @@
43
47
  ]
44
48
  },
45
49
  {
50
+ "forge_owned": true,
46
51
  "matcher": "MultiEdit",
47
52
  "hooks": [
48
53
  {
@@ -55,6 +60,7 @@
55
60
  ],
56
61
  "PostToolUse": [
57
62
  {
63
+ "forge_owned": true,
58
64
  "matcher": "Bash",
59
65
  "hooks": [
60
66
  {
@@ -65,6 +71,7 @@
65
71
  ]
66
72
  },
67
73
  {
74
+ "forge_owned": true,
68
75
  "matcher": "Skill",
69
76
  "hooks": [
70
77
  {
@@ -75,6 +82,7 @@
75
82
  ]
76
83
  },
77
84
  {
85
+ "forge_owned": true,
78
86
  "matcher": "Task",
79
87
  "hooks": [
80
88
  {
@@ -85,6 +93,7 @@
85
93
  ]
86
94
  },
87
95
  {
96
+ "forge_owned": true,
88
97
  "matcher": "Edit",
89
98
  "hooks": [
90
99
  {
@@ -95,6 +104,7 @@
95
104
  ]
96
105
  },
97
106
  {
107
+ "forge_owned": true,
98
108
  "matcher": "Write",
99
109
  "hooks": [
100
110
  {
@@ -105,6 +115,7 @@
105
115
  ]
106
116
  },
107
117
  {
118
+ "forge_owned": true,
108
119
  "matcher": "MultiEdit",
109
120
  "hooks": [
110
121
  {
@@ -117,6 +128,7 @@
117
128
  ],
118
129
  "SessionStart": [
119
130
  {
131
+ "forge_owned": true,
120
132
  "matcher": "*",
121
133
  "hooks": [
122
134
  {
@@ -129,6 +141,7 @@
129
141
  ],
130
142
  "PreCompact": [
131
143
  {
144
+ "forge_owned": true,
132
145
  "matcher": "*",
133
146
  "hooks": [
134
147
  {
@@ -83,6 +83,14 @@ if [ ! -f "$CONFIG" ]; then
83
83
  exit 0
84
84
  fi
85
85
 
86
+ # Extract work_id from the manifest path: .forge/work/{type}/{name}/manifest.yaml
87
+ # Used to filter telemetry by work item so a stale invocation from another work
88
+ # item cannot satisfy this gate (closes P0-5 from v6.1-beta audit).
89
+ WORK_ID=""
90
+ if [[ "$FILE_PATH" =~ \.forge/work/([^/]+)/([^/]+)/manifest\.yaml$ ]]; then
91
+ WORK_ID="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
92
+ fi
93
+
86
94
  # Check each gate being passed against requirements
87
95
  BLOCKED_REASONS=""
88
96
 
@@ -126,18 +134,55 @@ for GATE in $(jq -r 'keys[] | select(. != "_comment")' "$CONFIG"); do
126
134
  # Resolve required agents: agents array first, then single agent field
127
135
  REQUIRED_AGENTS=$(jq -r "if .\"$GATE\".agents then .\"$GATE\".agents[] else .\"$GATE\".agent // empty end" "$CONFIG" 2>/dev/null)
128
136
 
129
- # Check skill invocation in telemetry
137
+ # Telemetry check is work_id-scoped (closes P0-5 cross-work-item bypass).
138
+ # Records without work_id (legacy or no-active-work) are NOT counted —
139
+ # an untagged invocation can't satisfy a gate on a specific work item.
140
+ # Records lacking jq-parsable shape are also ignored (jq returns null per line;
141
+ # `select(...)` filters them out).
142
+ check_invocation() {
143
+ local NAME="$1"
144
+ if [ ! -f "$TELEMETRY" ]; then
145
+ return 1
146
+ fi
147
+ if [ -z "$WORK_ID" ]; then
148
+ # No work_id resolved from path — should be rare; fall back to bare-name
149
+ # check so we don't accidentally hard-block manifest edits made outside
150
+ # the .forge/work/{type}/{name}/ tree (unlikely given the path filter
151
+ # at line ~43 already excluded non-manifest paths, but defensive).
152
+ grep -q "\"name\":\"$NAME\"" "$TELEMETRY" 2>/dev/null
153
+ else
154
+ # jq -s collects all JSONL lines into an array. select() narrows to the
155
+ # exact work_id + name pair. length > 0 means at least one invocation
156
+ # in this work item. The 2>/dev/null swallows parse errors on
157
+ # malformed records (treated as no match — correct behavior).
158
+ local COUNT
159
+ COUNT=$(jq -s --arg work_id "$WORK_ID" --arg name "$NAME" \
160
+ 'map(select(.work_id == $work_id and .name == $name)) | length' \
161
+ "$TELEMETRY" 2>/dev/null || echo 0)
162
+ [ "$COUNT" != "0" ] && [ -n "$COUNT" ]
163
+ fi
164
+ }
165
+
166
+ # Check skill invocation in telemetry (work_id-scoped)
130
167
  if [ -n "$REQUIRED_SKILL" ]; then
131
- if [ ! -f "$TELEMETRY" ] || ! grep -q "\"name\":\"$REQUIRED_SKILL\"" "$TELEMETRY"; then
132
- BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires skill '$REQUIRED_SKILL' (not found in telemetry). "
168
+ if ! check_invocation "$REQUIRED_SKILL"; then
169
+ if [ -n "$WORK_ID" ]; then
170
+ BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires skill '$REQUIRED_SKILL' (not invoked for work_id '$WORK_ID'). "
171
+ else
172
+ BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires skill '$REQUIRED_SKILL' (not found in telemetry). "
173
+ fi
133
174
  fi
134
175
  fi
135
176
 
136
- # Check agent dispatch in telemetry
177
+ # Check agent dispatch in telemetry (work_id-scoped)
137
178
  for AGENT in $REQUIRED_AGENTS; do
138
179
  if [ -n "$AGENT" ] && [ "$AGENT" != "null" ]; then
139
- if [ ! -f "$TELEMETRY" ] || ! grep -q "\"name\":\"$AGENT\"" "$TELEMETRY"; then
140
- BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires agent '$AGENT' (not found in telemetry). "
180
+ if ! check_invocation "$AGENT"; then
181
+ if [ -n "$WORK_ID" ]; then
182
+ BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires agent '$AGENT' (not dispatched for work_id '$WORK_ID'). "
183
+ else
184
+ BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires agent '$AGENT' (not found in telemetry). "
185
+ fi
141
186
  fi
142
187
  fi
143
188
  done
@@ -1,33 +1,47 @@
1
1
  #!/bin/bash
2
2
  # Pre-compact hook: Save critical state before context window compression.
3
- # This ensures the agent can recover its working context after compaction.
4
3
  #
5
- # Writes to .forge/state/notepad.md which survives context compression
6
- # because it's re-read by the agent when it detects context loss.
4
+ # Writes a "## Checkpoints" entry to aiwiki/sessions/{date}-{session_id_short}.md
5
+ # which is the canonical per-session handoff file. The session file is created
6
+ # lazily on the first event (this hook, /wrap, /dream, or harden's session write).
7
7
  #
8
- # Scans `.forge/work/*/` (typed subdirs) for the single in-progress work item.
9
- # Skips `escalated`/`completed` manifests (terminal states). Uses `{type}/{name}`
10
- # as the canonical work identifier because names can collide across types.
8
+ # Reads session_id from Claude Code's PreCompact hook payload (stdin JSON).
9
+ # Falls back to "0000000" short id if stdin payload is missing or malformed —
10
+ # the file is still created, just without traceable session linkage.
11
+ #
12
+ # Recovery path: post-compact agent (and the next SessionStart) reads the
13
+ # latest aiwiki/sessions/*.md, looks at ## Checkpoints, and acts on any
14
+ # `Status: unconsumed` directive.
15
+ #
16
+ # Notepad (.forge/state/notepad.md) — REMOVED in v6.2. The single source of
17
+ # truth for session recovery state is now aiwiki/sessions/{date}-{id}.md.
18
+ # See templates/aiwiki/schemas/session.md (schema v2) for the file shape.
19
+ #
20
+ # Also logs the compaction event to .forge/state/telemetry.jsonl (unchanged).
11
21
 
12
22
  set -euo pipefail
13
23
 
14
- # Find project root (look for .forge/ or .git/)
24
+ # Read stdin JSON payload (Claude Code's hook contract). The payload includes
25
+ # session_id, transcript_path, cwd, hook_event_name, trigger. We need session_id.
26
+ PAYLOAD="$(cat 2>/dev/null || true)"
27
+
28
+ # Extract session_id via sed (no jq dependency). UUID format expected.
29
+ SESSION_ID=""
30
+ if [ -n "$PAYLOAD" ]; then
31
+ SESSION_ID="$(echo "$PAYLOAD" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
32
+ fi
33
+ SESSION_ID_SHORT="${SESSION_ID:0:7}"
34
+ if [ -z "$SESSION_ID_SHORT" ]; then
35
+ SESSION_ID_SHORT="0000000"
36
+ fi
37
+
38
+ # Find project root
15
39
  PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
16
40
  STATE_DIR="$PROJECT_ROOT/.forge/state"
17
-
18
- # Create state directory if needed
19
41
  mkdir -p "$STATE_DIR"
20
42
 
21
- # Ensure .forge/state/ is gitignored
22
- if [ -f "$PROJECT_ROOT/.gitignore" ]; then
23
- if ! grep -q '.forge/state' "$PROJECT_ROOT/.gitignore" 2>/dev/null; then
24
- echo '.forge/state/' >> "$PROJECT_ROOT/.gitignore"
25
- fi
26
- fi
27
-
28
- # Find active work item (status: in-progress), scanning typed subdirs
29
- NOTEPAD="$STATE_DIR/notepad.md"
30
- ACTIVE_WORK="" # Canonical identifier: {type}/{name}
43
+ # Find active work item (status: in-progress)
44
+ ACTIVE_WORK=""
31
45
  MANIFEST_PATH=""
32
46
 
33
47
  if [ -d "$PROJECT_ROOT/.forge/work" ]; then
@@ -42,49 +56,100 @@ if [ -d "$PROJECT_ROOT/.forge/work" ]; then
42
56
  done
43
57
  fi
44
58
 
45
- # Build notepad content
46
- {
47
- echo "# Forge Context Recovery"
48
- echo ""
49
- echo "**This file was auto-generated before context compaction.**"
50
- echo "**Re-read the manifest and continue from where you left off.**"
51
- echo ""
52
- echo "## Active Work Item"
53
-
54
- if [ -n "$ACTIVE_WORK" ]; then
55
- echo "- Work: $ACTIVE_WORK"
56
- echo "- Manifest: $MANIFEST_PATH"
59
+ # Detect recent aiwiki/ activity (24h window) for dream-directive decision
60
+ RECENT_AIWIKI_CHANGES=0
61
+ if [ -d "$PROJECT_ROOT/aiwiki" ]; then
62
+ if [ -d "$PROJECT_ROOT/aiwiki/raw" ] && [ -n "$(find "$PROJECT_ROOT/aiwiki/raw" -type f -name '*.md' -mtime -1 2>/dev/null | head -1)" ]; then
63
+ RECENT_AIWIKI_CHANGES=1
64
+ fi
65
+ if [ "$RECENT_AIWIKI_CHANGES" -eq 0 ]; then
66
+ for typed in gotchas decisions conventions architecture oracles; do
67
+ if [ -d "$PROJECT_ROOT/aiwiki/$typed" ] && [ -n "$(find "$PROJECT_ROOT/aiwiki/$typed" -type f -name '*.md' -mtime -1 2>/dev/null | head -1)" ]; then
68
+ RECENT_AIWIKI_CHANGES=1
69
+ break
70
+ fi
71
+ done
72
+ fi
73
+ fi
74
+
75
+ # Session file path
76
+ TODAY="$(date -u '+%Y-%m-%d')"
77
+ TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
78
+ SESSIONS_DIR="$PROJECT_ROOT/aiwiki/sessions"
79
+ SESSION_FILE="$SESSIONS_DIR/${TODAY}-${SESSION_ID_SHORT}.md"
80
+
81
+ # If aiwiki/ doesn't exist (forge installed but never bootstrapped), skip
82
+ # session-file write entirely — there's nowhere to put it. Telemetry still logs.
83
+ if [ -d "$PROJECT_ROOT/aiwiki" ]; then
84
+ mkdir -p "$SESSIONS_DIR"
85
+
86
+ # Lazy-create session file with bare frontmatter if missing
87
+ if [ ! -f "$SESSION_FILE" ]; then
88
+ FOCUS_INIT="${ACTIVE_WORK:-unset}"
89
+ {
90
+ echo "---"
91
+ echo "schema_id: session"
92
+ echo "schema_version: 2"
93
+ echo "status: active"
94
+ echo "date_start: $TODAY"
95
+ echo "session_id: ${SESSION_ID:-unknown}"
96
+ echo "focus: $FOCUS_INIT"
97
+ echo "last_commit: ~"
98
+ echo "---"
99
+ echo ""
100
+ echo "## Checkpoints"
101
+ echo ""
102
+ } > "$SESSION_FILE"
103
+ fi
104
+
105
+ # Append PreCompact checkpoint entry
106
+ {
107
+ echo "### PreCompact at $TIMESTAMP"
57
108
  echo ""
109
+ if [ -n "$ACTIVE_WORK" ]; then
110
+ echo "Active work: $ACTIVE_WORK"
111
+ echo "Manifest: $MANIFEST_PATH"
112
+ echo ""
113
+ if [ -f "$MANIFEST_PATH" ]; then
114
+ echo "Phase status (from manifest):"
115
+ echo '```yaml'
116
+ grep -A 1 'status:\|gate-passed:\|locked_at:' "$MANIFEST_PATH" 2>/dev/null | head -30
117
+ echo '```'
118
+ echo ""
119
+ fi
120
+ else
121
+ echo "Active work: none (no in-progress manifest found)"
122
+ echo ""
123
+ fi
58
124
 
59
- # Extract current phase info from manifest
60
- if command -v grep &>/dev/null && [ -f "$MANIFEST_PATH" ]; then
61
- echo "### Phase Status (from manifest)"
62
- echo '```yaml'
63
- grep -A 1 'status:\|gate-passed:' "$MANIFEST_PATH" 2>/dev/null | head -30
64
- echo '```'
125
+ if [ "$RECENT_AIWIKI_CHANGES" -eq 1 ]; then
126
+ echo "**Dream directive (unconsumed):**"
127
+ echo ""
128
+ echo "- Scope: \`aiwiki/raw/\` + typed pages touched in the last 24h (gotchas/, conventions/, etc.)"
129
+ echo "- Trigger: \`pre-compact\`, context ~85%"
130
+ echo "- Action: invoke \`support-dream\` skill before resuming other work"
131
+ echo "- Session-file refinement: if this file's other sections (Files touched, Decisions, etc.) exist, dream may also refine them"
132
+ echo ""
133
+ echo "Mark consumed: after invoking dream, change this entry's header from \`**Dream directive (unconsumed):**\` to \`**Dream directive (consumed at {ISO-timestamp}):**\`. Header-flip is what session-start.sh counts; appending a 'Status: consumed' line leaves the original header in place and the next session-start still surfaces it as unconsumed."
134
+ echo ""
65
135
  fi
66
- else
67
- echo "- No active work item found"
68
- fi
69
136
 
70
- echo ""
71
- echo "## Recovery Instructions"
72
- echo "1. Read the manifest file listed above"
73
- echo "2. Find the first phase with \`status: pending\` or \`gate-passed: false\`"
74
- echo "3. Continue from that phase"
75
- echo "4. If build phase: check which tasks are complete vs pending"
76
- echo ""
77
- echo "---"
78
- echo "*Generated at: $(date -u '+%Y-%m-%dT%H:%M:%SZ')*"
79
- } > "$NOTEPAD"
80
-
81
- # Log compaction event to telemetry
137
+ echo "---"
138
+ echo ""
139
+ } >> "$SESSION_FILE"
140
+
141
+ echo "Pre-compact: appended checkpoint to $SESSION_FILE" >&2
142
+ else
143
+ echo "Pre-compact: aiwiki/ not present — skipped session checkpoint. Telemetry logged." >&2
144
+ fi
145
+
146
+ # Log compaction event to telemetry (unchanged)
82
147
  TELEMETRY_FILE="$STATE_DIR/telemetry.jsonl"
83
- TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
84
148
  WORK_JSON="${ACTIVE_WORK:-null}"
85
149
  if [ -n "$ACTIVE_WORK" ]; then WORK_JSON="\"$ACTIVE_WORK\""; fi
150
+ SESSION_JSON="${SESSION_ID:-null}"
151
+ if [ -n "$SESSION_ID" ]; then SESSION_JSON="\"$SESSION_ID\""; fi
86
152
 
87
- echo "{\"event\":\"compact\",\"timestamp\":\"$TIMESTAMP\",\"work\":$WORK_JSON}" >> "$TELEMETRY_FILE"
153
+ echo "{\"event\":\"compact\",\"timestamp\":\"$TIMESTAMP\",\"work\":$WORK_JSON,\"session_id\":$SESSION_JSON,\"aiwiki_active\":$RECENT_AIWIKI_CHANGES}" >> "$TELEMETRY_FILE"
88
154
 
89
- echo "Pre-compact: Saved context recovery state to $NOTEPAD" >&2
90
155
  exit 0