@christang/keel 5.1.1 → 5.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 (33) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +114 -158
  3. package/README.zh-CN.md +118 -197
  4. package/assets/bootstrap/AGENTS.md +1 -1
  5. package/{plugins/keel/skills/keel-align-expectations/references → assets/lenses}/hardware-dsl.md +4 -2
  6. package/{plugins/keel/skills/keel-align-expectations/references → assets/lenses}/hardware.md +4 -2
  7. package/{plugins/keel/skills/keel-align-expectations/references → assets/lenses}/web.md +4 -2
  8. package/assets/openspec/schemas/keel-spec-driven/schema.yaml +172 -166
  9. package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +13 -4
  10. package/bin/keel.js +218 -15
  11. package/package.json +1 -1
  12. package/plugins/keel/.claude-plugin/plugin.json +1 -1
  13. package/plugins/keel/.codex-plugin/plugin.json +1 -1
  14. package/plugins/keel/hooks/hooks.json +30 -30
  15. package/plugins/keel/scripts/pretooluse-guard.js +156 -156
  16. package/plugins/keel/scripts/session-start.js +182 -182
  17. package/plugins/keel/skills/keel-align-expectations/SKILL.md +2 -6
  18. package/plugins/keel/skills/keel-debug-failure/SKILL.md +2 -2
  19. package/plugins/keel/skills/keel-review-checklist/SKILL.md +2 -2
  20. package/plugins/keel/skills/keel-tdd-or-test-first/SKILL.md +2 -2
  21. package/scripts/bump_version.js +140 -0
  22. package/scripts/install_to_repo.py +0 -70
  23. package/scripts/run_python.js +63 -63
  24. package/scripts/validate_plugin.py +408 -96
  25. package/src/core/capabilities.js +291 -291
  26. package/src/core/context.js +521 -514
  27. package/src/core/gates.js +664 -643
  28. package/src/core/goal.js +230 -230
  29. package/src/core/guard.js +295 -295
  30. package/src/core/helper.js +319 -319
  31. package/src/core/projection.js +195 -195
  32. package/src/core/task-contract.js +757 -736
  33. package/src/core/tasksview.js +123 -123
@@ -1,156 +1,156 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
-
4
- // Keel plugin PreToolUse write guard: deterministic denial of out-of-Touch
5
- // file edits while an explicit keel/guard.json manifest is active. Absence of
6
- // the manifest allows everything silently; a present-but-untrusted manifest
7
- // fails closed. The hook never writes state, never spawns the keel CLI, and
8
- // always exits 0 — denial is expressed only through hook output. Paths that
9
- // resolve outside the repository root are not product writes and pass through.
10
-
11
- const crypto = require("crypto");
12
- const fs = require("fs");
13
- const path = require("path");
14
-
15
- const MANIFEST_SCHEMA = "keel-write-guard/v1";
16
- const FILE_EDIT_TOOLS = new Map([
17
- ["Edit", "file_path"],
18
- ["Write", "file_path"],
19
- ["NotebookEdit", "notebook_path"],
20
- ]);
21
-
22
- function readStdin() {
23
- try {
24
- return fs.readFileSync(0, "utf8");
25
- } catch {
26
- return "";
27
- }
28
- }
29
-
30
- function deny(reason) {
31
- process.stdout.write(
32
- `${JSON.stringify({
33
- hookSpecificOutput: {
34
- hookEventName: "PreToolUse",
35
- permissionDecision: "deny",
36
- permissionDecisionReason: reason,
37
- },
38
- })}\n`
39
- );
40
- }
41
-
42
- function sha256(buffer) {
43
- return crypto.createHash("sha256").update(buffer).digest("hex");
44
- }
45
-
46
- function manifestShapeValid(manifest) {
47
- return (
48
- manifest
49
- && manifest.schema === MANIFEST_SCHEMA
50
- && typeof manifest.change === "string"
51
- && manifest.change !== ""
52
- && typeof manifest.task === "string"
53
- && manifest.task !== ""
54
- && Array.isArray(manifest.touch)
55
- && manifest.touch.length > 0
56
- && manifest.touch.every((item) => typeof item === "string" && item !== "")
57
- && Array.isArray(manifest.authority)
58
- && manifest.authority.length > 0
59
- && manifest.authority.every(
60
- (item) =>
61
- item
62
- && typeof item.path === "string"
63
- && /^[0-9a-f]{64}$/.test(String(item.sha256 || ""))
64
- )
65
- );
66
- }
67
-
68
- function globPattern(value) {
69
- const escaped = value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
70
- return new RegExp(
71
- `^${escaped
72
- .replace(/\*\*/g, "\u0000")
73
- .replace(/\*/g, "[^/]*")
74
- .replace(/\u0000/g, ".*")}$`
75
- );
76
- }
77
-
78
- function pathAllowed(candidate, touch) {
79
- return touch.some((entry) => {
80
- const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
81
- if (normalized.endsWith("/")) return candidate.startsWith(normalized);
82
- if (normalized.includes("*")) return globPattern(normalized).test(candidate);
83
- return candidate === normalized;
84
- });
85
- }
86
-
87
- function main() {
88
- let event = {};
89
- try {
90
- event = JSON.parse(readStdin() || "{}");
91
- } catch {
92
- event = {};
93
- }
94
- const repo =
95
- typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
96
- const manifestPath = path.join(repo, "keel", "guard.json");
97
- if (!fs.existsSync(manifestPath)) return 0;
98
-
99
- const pathField = FILE_EDIT_TOOLS.get(event.tool_name);
100
- if (!pathField) return 0;
101
-
102
- let manifest = null;
103
- try {
104
- manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
105
- } catch {
106
- manifest = null;
107
- }
108
- if (!manifestShapeValid(manifest)) {
109
- deny(
110
- "Keel write guard: keel/guard.json is present but invalid, so file "
111
- + "edits fail closed. Run `keel guard clear`, then `keel gate "
112
- + "task-start` and `keel guard start` to reauthorize."
113
- );
114
- return 0;
115
- }
116
- const pointer = `${manifest.change}#${manifest.task}`;
117
-
118
- for (const entry of manifest.authority) {
119
- const file = path.join(repo, entry.path);
120
- let fresh = null;
121
- try {
122
- fresh = sha256(fs.readFileSync(file));
123
- } catch {
124
- fresh = null;
125
- }
126
- if (fresh !== entry.sha256) {
127
- deny(
128
- `Keel write guard: task authority drift detected for ${pointer} `
129
- + `(${entry.path} changed since guard start), so file edits fail `
130
- + "closed. Re-run `keel gate task-start` and `keel guard start` to "
131
- + "reauthorize, or `keel guard clear` to stop enforcement."
132
- );
133
- return 0;
134
- }
135
- }
136
-
137
- const target = event.tool_input ? event.tool_input[pathField] : null;
138
- if (typeof target !== "string" || !target) return 0;
139
- const relative = path.relative(repo, path.resolve(repo, target));
140
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
141
- return 0;
142
- }
143
- const candidate = relative.replace(/\\/g, "/");
144
- if (pathAllowed(candidate, manifest.touch)) return 0;
145
-
146
- deny(
147
- `Keel write guard: ${candidate} is outside Touch for ${pointer}. `
148
- + `Touch allows: ${manifest.touch.join(", ")}. Stop and report an `
149
- + "Out-of-scope Need, update the task authority and reauthorize via "
150
- + "`keel gate task-start` and `keel guard start`, or run "
151
- + "`keel guard clear` to stop enforcement."
152
- );
153
- return 0;
154
- }
155
-
156
- process.exit(main());
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Keel plugin PreToolUse write guard: deterministic denial of out-of-Touch
5
+ // file edits while an explicit keel/guard.json manifest is active. Absence of
6
+ // the manifest allows everything silently; a present-but-untrusted manifest
7
+ // fails closed. The hook never writes state, never spawns the keel CLI, and
8
+ // always exits 0 — denial is expressed only through hook output. Paths that
9
+ // resolve outside the repository root are not product writes and pass through.
10
+
11
+ const crypto = require("crypto");
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const MANIFEST_SCHEMA = "keel-write-guard/v1";
16
+ const FILE_EDIT_TOOLS = new Map([
17
+ ["Edit", "file_path"],
18
+ ["Write", "file_path"],
19
+ ["NotebookEdit", "notebook_path"],
20
+ ]);
21
+
22
+ function readStdin() {
23
+ try {
24
+ return fs.readFileSync(0, "utf8");
25
+ } catch {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ function deny(reason) {
31
+ process.stdout.write(
32
+ `${JSON.stringify({
33
+ hookSpecificOutput: {
34
+ hookEventName: "PreToolUse",
35
+ permissionDecision: "deny",
36
+ permissionDecisionReason: reason,
37
+ },
38
+ })}\n`
39
+ );
40
+ }
41
+
42
+ function sha256(buffer) {
43
+ return crypto.createHash("sha256").update(buffer).digest("hex");
44
+ }
45
+
46
+ function manifestShapeValid(manifest) {
47
+ return (
48
+ manifest
49
+ && manifest.schema === MANIFEST_SCHEMA
50
+ && typeof manifest.change === "string"
51
+ && manifest.change !== ""
52
+ && typeof manifest.task === "string"
53
+ && manifest.task !== ""
54
+ && Array.isArray(manifest.touch)
55
+ && manifest.touch.length > 0
56
+ && manifest.touch.every((item) => typeof item === "string" && item !== "")
57
+ && Array.isArray(manifest.authority)
58
+ && manifest.authority.length > 0
59
+ && manifest.authority.every(
60
+ (item) =>
61
+ item
62
+ && typeof item.path === "string"
63
+ && /^[0-9a-f]{64}$/.test(String(item.sha256 || ""))
64
+ )
65
+ );
66
+ }
67
+
68
+ function globPattern(value) {
69
+ const escaped = value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
70
+ return new RegExp(
71
+ `^${escaped
72
+ .replace(/\*\*/g, "\u0000")
73
+ .replace(/\*/g, "[^/]*")
74
+ .replace(/\u0000/g, ".*")}$`
75
+ );
76
+ }
77
+
78
+ function pathAllowed(candidate, touch) {
79
+ return touch.some((entry) => {
80
+ const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
81
+ if (normalized.endsWith("/")) return candidate.startsWith(normalized);
82
+ if (normalized.includes("*")) return globPattern(normalized).test(candidate);
83
+ return candidate === normalized;
84
+ });
85
+ }
86
+
87
+ function main() {
88
+ let event = {};
89
+ try {
90
+ event = JSON.parse(readStdin() || "{}");
91
+ } catch {
92
+ event = {};
93
+ }
94
+ const repo =
95
+ typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
96
+ const manifestPath = path.join(repo, "keel", "guard.json");
97
+ if (!fs.existsSync(manifestPath)) return 0;
98
+
99
+ const pathField = FILE_EDIT_TOOLS.get(event.tool_name);
100
+ if (!pathField) return 0;
101
+
102
+ let manifest = null;
103
+ try {
104
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
105
+ } catch {
106
+ manifest = null;
107
+ }
108
+ if (!manifestShapeValid(manifest)) {
109
+ deny(
110
+ "Keel write guard: keel/guard.json is present but invalid, so file "
111
+ + "edits fail closed. Run `keel guard clear`, then `keel gate "
112
+ + "task-start` and `keel guard start` to reauthorize."
113
+ );
114
+ return 0;
115
+ }
116
+ const pointer = `${manifest.change}#${manifest.task}`;
117
+
118
+ for (const entry of manifest.authority) {
119
+ const file = path.join(repo, entry.path);
120
+ let fresh = null;
121
+ try {
122
+ fresh = sha256(fs.readFileSync(file));
123
+ } catch {
124
+ fresh = null;
125
+ }
126
+ if (fresh !== entry.sha256) {
127
+ deny(
128
+ `Keel write guard: task authority drift detected for ${pointer} `
129
+ + `(${entry.path} changed since guard start), so file edits fail `
130
+ + "closed. Re-run `keel gate task-start` and `keel guard start` to "
131
+ + "reauthorize, or `keel guard clear` to stop enforcement."
132
+ );
133
+ return 0;
134
+ }
135
+ }
136
+
137
+ const target = event.tool_input ? event.tool_input[pathField] : null;
138
+ if (typeof target !== "string" || !target) return 0;
139
+ const relative = path.relative(repo, path.resolve(repo, target));
140
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
141
+ return 0;
142
+ }
143
+ const candidate = relative.replace(/\\/g, "/");
144
+ if (pathAllowed(candidate, manifest.touch)) return 0;
145
+
146
+ deny(
147
+ `Keel write guard: ${candidate} is outside Touch for ${pointer}. `
148
+ + `Touch allows: ${manifest.touch.join(", ")}. Stop and report an `
149
+ + "Out-of-scope Need, update the task authority and reauthorize via "
150
+ + "`keel gate task-start` and `keel guard start`, or run "
151
+ + "`keel guard clear` to stop enforcement."
152
+ );
153
+ return 0;
154
+ }
155
+
156
+ process.exit(main());
@@ -1,182 +1,182 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
-
4
- // Keel plugin SessionStart projection: disposable context only.
5
- // OpenSpec and Git stay the durable authority; this script never writes
6
- // state, selects an ambiguous owner, records a fingerprint, creates a goal,
7
- // or blocks the session. It always exits 0.
8
- //
9
- // The projection is source-aware: a compact start reinjects the recomputed
10
- // task pointer (selection, recorded Contract fingerprint, next command) that
11
- // a summary is most likely to lose, a resume start reinjects the selection,
12
- // and startup, clear, or any unknown source falls back to the generic view.
13
-
14
- const fs = require("fs");
15
- const path = require("path");
16
- const { spawnSync } = require("child_process");
17
-
18
- const TIMEOUT_MS = Number(process.env.KEEL_HOOK_TIMEOUT_MS || 8000) || 8000;
19
- const MAX_REASONS = 3;
20
- const MAX_REASON_LENGTH = 300;
21
-
22
- function readStdin() {
23
- try {
24
- return fs.readFileSync(0, "utf8");
25
- } catch {
26
- return "";
27
- }
28
- }
29
-
30
- function emit(context) {
31
- process.stdout.write(
32
- `${JSON.stringify({
33
- hookSpecificOutput: {
34
- hookEventName: "SessionStart",
35
- additionalContext: context,
36
- },
37
- })}\n`
38
- );
39
- }
40
-
41
- function runKeel(cwd, args) {
42
- const cli = (process.env.KEEL_CLI || "keel").trim();
43
- return spawnSync(`${cli} ${args.join(" ")}`, {
44
- cwd,
45
- shell: true,
46
- encoding: "utf8",
47
- timeout: TIMEOUT_MS,
48
- });
49
- }
50
-
51
- function fallback(reason) {
52
- emit(
53
- `Keel hook fallback: ${reason} Run \`keel context\` manually; `
54
- + "OpenSpec and Git remain the durable authority."
55
- );
56
- }
57
-
58
- function main() {
59
- let event = {};
60
- try {
61
- event = JSON.parse(readStdin() || "{}");
62
- } catch {
63
- event = {};
64
- }
65
- const cwd =
66
- typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
67
-
68
- if (!fs.existsSync(path.join(cwd, "openspec"))) {
69
- return 0;
70
- }
71
-
72
- const version = runKeel(cwd, ["--version"]);
73
- const versionMatch = String(version.stdout || "").match(/(\d+)\.\d+\.\d+/);
74
- if (
75
- version.error
76
- || version.status !== 0
77
- || !versionMatch
78
- || Number(versionMatch[1]) < 3
79
- ) {
80
- fallback(
81
- "the keel CLI is missing or incompatible with this plugin; install "
82
- + "@christang/keel (npm install -g @christang/keel)."
83
- );
84
- return 0;
85
- }
86
-
87
- const result = runKeel(cwd, ["context", "--json"]);
88
- if (result.error || result.status !== 0 || !String(result.stdout || "").trim()) {
89
- fallback("`keel context --json` failed or timed out.");
90
- return 0;
91
- }
92
- let context;
93
- try {
94
- context = JSON.parse(result.stdout);
95
- } catch {
96
- fallback("keel produced malformed context output.");
97
- return 0;
98
- }
99
-
100
- const source = typeof event.source === "string" ? event.source : "";
101
- const reinject = source === "compact" || source === "resume";
102
- const header = source === "compact"
103
- ? "Keel post-compaction reinjection (disposable; recomputed from OpenSpec and Git):"
104
- : source === "resume"
105
- ? "Keel resume reinjection (disposable; recomputed from OpenSpec and Git):"
106
- : "Keel session projection (disposable; OpenSpec and Git are the durable authority):";
107
-
108
- const lines = [header];
109
- if (context.status === "ready" && context.selection) {
110
- const task = context.selection.task ? `#${context.selection.task}` : "";
111
- lines.push(
112
- `- context ready: ${context.selection.change}${task} `
113
- + `(${context.selection.source}); next action: `
114
- + `${context.nextAction ? context.nextAction.kind : "unknown"}.`
115
- );
116
- if (reinject) {
117
- const recorded = recordedContract(cwd, context.selection);
118
- if (recorded) {
119
- lines.push(
120
- `- recorded Contract fingerprint: ${recorded} (recorded, not `
121
- + "verified; gates recompile and compare before any write)."
122
- );
123
- }
124
- lines.push(
125
- "- next: re-run `keel context --json`, then `keel gate task-start` "
126
- + "before continuing implementation; nothing was selected or "
127
- + "recorded by this projection."
128
- );
129
- } else {
130
- if (Array.isArray(context.read) && context.read.length > 0) {
131
- lines.push(`- read first: ${context.read.slice(0, 5).join(", ")}.`);
132
- }
133
- lines.push(
134
- "- run `keel gate task-start` before implementation; this projection "
135
- + "selects nothing and records nothing."
136
- );
137
- }
138
- } else {
139
- lines.push(`- context status: ${context.status || "unknown"}.`);
140
- for (const reason of (context.reasons || []).slice(0, MAX_REASONS)) {
141
- lines.push(`- reason: ${String(reason).slice(0, MAX_REASON_LENGTH)}`);
142
- }
143
- lines.push(
144
- "- next: run `keel context` and select an owner explicitly; this hook "
145
- + "does not guess among candidates."
146
- );
147
- }
148
- emit(lines.join("\n"));
149
- return 0;
150
- }
151
-
152
- function recordedContract(repo, selection) {
153
- if (!selection || !selection.change || !selection.task) return null;
154
- const tasksPath = path.join(
155
- repo,
156
- "openspec",
157
- "changes",
158
- selection.change,
159
- "tasks.md"
160
- );
161
- let content = "";
162
- try {
163
- content = fs.readFileSync(tasksPath, "utf8");
164
- } catch {
165
- return null;
166
- }
167
- const wanted = String(selection.task);
168
- let inTask = false;
169
- for (const line of content.split(/\r?\n/)) {
170
- const heading = line.match(/^\s*-\s+\[[ xX]\]\s+(\d+(?:\.\d+)+)\s+/);
171
- if (heading) {
172
- inTask = heading[1] === wanted;
173
- continue;
174
- }
175
- if (!inTask) continue;
176
- const contract = line.match(/^\s*-\s*Contract:\s*(sha256:[0-9a-f]{64})\b/);
177
- if (contract) return contract[1];
178
- }
179
- return null;
180
- }
181
-
182
- process.exit(main());
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Keel plugin SessionStart projection: disposable context only.
5
+ // OpenSpec and Git stay the durable authority; this script never writes
6
+ // state, selects an ambiguous owner, records a fingerprint, creates a goal,
7
+ // or blocks the session. It always exits 0.
8
+ //
9
+ // The projection is source-aware: a compact start reinjects the recomputed
10
+ // task pointer (selection, recorded Contract fingerprint, next command) that
11
+ // a summary is most likely to lose, a resume start reinjects the selection,
12
+ // and startup, clear, or any unknown source falls back to the generic view.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const { spawnSync } = require("child_process");
17
+
18
+ const TIMEOUT_MS = Number(process.env.KEEL_HOOK_TIMEOUT_MS || 8000) || 8000;
19
+ const MAX_REASONS = 3;
20
+ const MAX_REASON_LENGTH = 300;
21
+
22
+ function readStdin() {
23
+ try {
24
+ return fs.readFileSync(0, "utf8");
25
+ } catch {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ function emit(context) {
31
+ process.stdout.write(
32
+ `${JSON.stringify({
33
+ hookSpecificOutput: {
34
+ hookEventName: "SessionStart",
35
+ additionalContext: context,
36
+ },
37
+ })}\n`
38
+ );
39
+ }
40
+
41
+ function runKeel(cwd, args) {
42
+ const cli = (process.env.KEEL_CLI || "keel").trim();
43
+ return spawnSync(`${cli} ${args.join(" ")}`, {
44
+ cwd,
45
+ shell: true,
46
+ encoding: "utf8",
47
+ timeout: TIMEOUT_MS,
48
+ });
49
+ }
50
+
51
+ function fallback(reason) {
52
+ emit(
53
+ `Keel hook fallback: ${reason} Run \`keel context\` manually; `
54
+ + "OpenSpec and Git remain the durable authority."
55
+ );
56
+ }
57
+
58
+ function main() {
59
+ let event = {};
60
+ try {
61
+ event = JSON.parse(readStdin() || "{}");
62
+ } catch {
63
+ event = {};
64
+ }
65
+ const cwd =
66
+ typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
67
+
68
+ if (!fs.existsSync(path.join(cwd, "openspec"))) {
69
+ return 0;
70
+ }
71
+
72
+ const version = runKeel(cwd, ["--version"]);
73
+ const versionMatch = String(version.stdout || "").match(/(\d+)\.\d+\.\d+/);
74
+ if (
75
+ version.error
76
+ || version.status !== 0
77
+ || !versionMatch
78
+ || Number(versionMatch[1]) < 3
79
+ ) {
80
+ fallback(
81
+ "the keel CLI is missing or incompatible with this plugin; install "
82
+ + "@christang/keel (npm install -g @christang/keel)."
83
+ );
84
+ return 0;
85
+ }
86
+
87
+ const result = runKeel(cwd, ["context", "--json"]);
88
+ if (result.error || result.status !== 0 || !String(result.stdout || "").trim()) {
89
+ fallback("`keel context --json` failed or timed out.");
90
+ return 0;
91
+ }
92
+ let context;
93
+ try {
94
+ context = JSON.parse(result.stdout);
95
+ } catch {
96
+ fallback("keel produced malformed context output.");
97
+ return 0;
98
+ }
99
+
100
+ const source = typeof event.source === "string" ? event.source : "";
101
+ const reinject = source === "compact" || source === "resume";
102
+ const header = source === "compact"
103
+ ? "Keel post-compaction reinjection (disposable; recomputed from OpenSpec and Git):"
104
+ : source === "resume"
105
+ ? "Keel resume reinjection (disposable; recomputed from OpenSpec and Git):"
106
+ : "Keel session projection (disposable; OpenSpec and Git are the durable authority):";
107
+
108
+ const lines = [header];
109
+ if (context.status === "ready" && context.selection) {
110
+ const task = context.selection.task ? `#${context.selection.task}` : "";
111
+ lines.push(
112
+ `- context ready: ${context.selection.change}${task} `
113
+ + `(${context.selection.source}); next action: `
114
+ + `${context.nextAction ? context.nextAction.kind : "unknown"}.`
115
+ );
116
+ if (reinject) {
117
+ const recorded = recordedContract(cwd, context.selection);
118
+ if (recorded) {
119
+ lines.push(
120
+ `- recorded Contract fingerprint: ${recorded} (recorded, not `
121
+ + "verified; gates recompile and compare before any write)."
122
+ );
123
+ }
124
+ lines.push(
125
+ "- next: re-run `keel context --json`, then `keel gate task-start` "
126
+ + "before continuing implementation; nothing was selected or "
127
+ + "recorded by this projection."
128
+ );
129
+ } else {
130
+ if (Array.isArray(context.read) && context.read.length > 0) {
131
+ lines.push(`- read first: ${context.read.slice(0, 5).join(", ")}.`);
132
+ }
133
+ lines.push(
134
+ "- run `keel gate task-start` before implementation; this projection "
135
+ + "selects nothing and records nothing."
136
+ );
137
+ }
138
+ } else {
139
+ lines.push(`- context status: ${context.status || "unknown"}.`);
140
+ for (const reason of (context.reasons || []).slice(0, MAX_REASONS)) {
141
+ lines.push(`- reason: ${String(reason).slice(0, MAX_REASON_LENGTH)}`);
142
+ }
143
+ lines.push(
144
+ "- next: run `keel context` and select an owner explicitly; this hook "
145
+ + "does not guess among candidates."
146
+ );
147
+ }
148
+ emit(lines.join("\n"));
149
+ return 0;
150
+ }
151
+
152
+ function recordedContract(repo, selection) {
153
+ if (!selection || !selection.change || !selection.task) return null;
154
+ const tasksPath = path.join(
155
+ repo,
156
+ "openspec",
157
+ "changes",
158
+ selection.change,
159
+ "tasks.md"
160
+ );
161
+ let content = "";
162
+ try {
163
+ content = fs.readFileSync(tasksPath, "utf8");
164
+ } catch {
165
+ return null;
166
+ }
167
+ const wanted = String(selection.task);
168
+ let inTask = false;
169
+ for (const line of content.split(/\r?\n/)) {
170
+ const heading = line.match(/^\s*-\s+\[[ xX]\]\s+(\d+(?:\.\d+)+)\s+/);
171
+ if (heading) {
172
+ inTask = heading[1] === wanted;
173
+ continue;
174
+ }
175
+ if (!inTask) continue;
176
+ const contract = line.match(/^\s*-\s*Contract:\s*(sha256:[0-9a-f]{64})\b/);
177
+ if (contract) return contract[1];
178
+ }
179
+ return null;
180
+ }
181
+
182
+ process.exit(main());