@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.
- package/README.md +77 -59
- package/agents/dreamer.md +10 -7
- package/agents/gotcha-hunter.md +1 -1
- package/agents/prototype-codifier.md +2 -2
- package/commands/{forge.md → discover.md} +13 -9
- package/commands/dream.md +71 -0
- package/commands/feature.md +57 -10
- package/commands/{evolve.md → forge-evolve.md} +3 -3
- package/commands/greenfield.md +5 -5
- package/commands/note.md +64 -0
- package/commands/{task-force.md → parallel.md} +15 -15
- package/commands/resume.md +2 -2
- package/commands/setup.md +18 -17
- package/commands/status.md +2 -2
- package/commands/wrap.md +130 -0
- package/dist/__tests__/hooks.test.js +334 -0
- package/dist/__tests__/init.test.js +110 -0
- package/dist/__tests__/work-manifest.test.js +48 -14
- package/dist/cli.js +0 -0
- package/dist/hooks.js +88 -6
- package/dist/init.js +39 -1
- package/dist/uninstall.js +11 -5
- package/dist/work-manifest.js +63 -24
- package/hooks/config/gate-requirements.json +1 -1
- package/hooks/hooks.json +14 -1
- package/hooks/scripts/gate-enforcer.sh +51 -6
- package/hooks/scripts/pre-compact.sh +120 -55
- package/hooks/scripts/session-start.sh +43 -4
- package/hooks/scripts/telemetry.sh +32 -2
- package/hooks/templates/CLAUDE.md.template +6 -3
- package/package.json +1 -1
- package/references/common/phases.md +8 -6
- package/references/common/skill-authoring.md +1 -1
- package/rules/common/forge-system.md +64 -6
- package/rules/common/quality-gates.md +2 -0
- package/skills/build-prototype/SKILL.md +4 -4
- package/skills/build-tdd/SKILL.md +14 -0
- package/skills/concept-slides/SKILL.md +11 -11
- package/skills/deliver-deploy/SKILL.md +10 -1
- package/skills/harden/SKILL.md +22 -8
- package/skills/iterate-prototype/SKILL.md +22 -0
- package/skills/quality-test-execution/SKILL.md +26 -1
- package/skills/quality-test-plan/SKILL.md +21 -1
- package/skills/support-debug/SKILL.md +1 -1
- package/skills/support-dream/SKILL.md +8 -7
- package/skills/support-gotcha/SKILL.md +3 -3
- package/skills/{support-task-force → support-parallel}/SKILL.md +22 -22
- package/skills/{support-task-force → support-parallel}/references/dispatch-pattern.md +10 -10
- package/skills/{support-task-force → support-parallel}/references/synthesis-template.md +10 -10
- package/skills/support-skill-validator/SKILL.md +5 -5
- package/skills/support-skill-validator/references/validation-checks.md +1 -1
- package/skills/support-system-guide/SKILL.md +4 -3
- package/skills/support-wiki-lint/scripts/lint.mjs +52 -0
- package/templates/README.md +1 -1
- package/templates/aiwiki/CLAUDE.md.template +48 -22
- package/templates/aiwiki/schemas/session.md +134 -49
- package/templates/manifests/bugfix.yaml +1 -1
- package/templates/manifests/feature.yaml +1 -1
- package/templates/manifests/greenfield.yaml +1 -1
- package/templates/manifests/hotfix.yaml +1 -1
- package/templates/manifests/refactor.yaml +1 -1
- package/templates/manifests/v5/SCHEMA.md +14 -17
- package/templates/manifests/v5/feature.yaml +1 -1
- package/templates/manifests/v6/SCHEMA.md +14 -10
- package/commands/abort.md +0 -25
- package/dist/__tests__/active-manifest.test.js +0 -272
- package/dist/__tests__/gate-check.test.js +0 -384
- package/dist/active-manifest.js +0 -229
- 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
|
-
|
|
164
|
-
if
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) {
|
package/dist/work-manifest.js
CHANGED
|
@@ -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
|
|
76
|
-
// recommended-keys table. Keys NOT in this set produce
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const
|
|
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
|
+
// errors — strict 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,
|
|
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
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
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
|
-
*
|
|
632
|
-
*
|
|
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,
|
|
631
|
+
function validatePhasePlanKeysForType(manifest, errors) {
|
|
636
632
|
if (!manifest.phase_plan)
|
|
637
633
|
return;
|
|
638
|
-
const allowed =
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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"
|
|
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
|
-
#
|
|
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
|
|
132
|
-
|
|
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
|
|
140
|
-
|
|
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
|
|
6
|
-
#
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
22
|
-
|
|
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
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
echo "
|
|
62
|
-
echo
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
echo "
|
|
75
|
-
|
|
76
|
-
echo ""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|