@christang/keel 5.1.1 → 5.1.2

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/src/core/guard.js CHANGED
@@ -1,295 +1,295 @@
1
- "use strict";
2
-
3
- // Keel 4.x write-guard contract: an explicit, fingerprinted, disposable
4
- // enforcement manifest for exactly one task. The manifest is never selection,
5
- // continuity, or completion authority; only its presence authorizes the
6
- // plugin PreToolUse hook to deny out-of-Touch file edits, and every broken
7
- // state fails closed through guard status while absence changes nothing.
8
-
9
- const crypto = require("crypto");
10
- const fs = require("fs");
11
- const path = require("path");
12
- const { loadTaskContract } = require("./task-contract");
13
-
14
- const MANIFEST_SCHEMA = "keel-write-guard/v1";
15
-
16
- class GuardInputError extends Error {}
17
-
18
- function manifestFile(repo) {
19
- return path.join(repo, "keel", "guard.json");
20
- }
21
-
22
- function sha256(buffer) {
23
- return crypto.createHash("sha256").update(buffer).digest("hex");
24
- }
25
-
26
- function guardResult(subcommand, status, extra = {}) {
27
- return {
28
- schemaVersion: 1,
29
- command: "guard",
30
- subcommand,
31
- status,
32
- manifestPath: "keel/guard.json",
33
- problems: [],
34
- warnings: [
35
- "The guard manifest is a disposable enforcement pointer; OpenSpec and "
36
- + "Git remain the only durable authority and selection never derives "
37
- + "from it.",
38
- ],
39
- ...extra,
40
- };
41
- }
42
-
43
- function authorityPaths(repo, change, contract) {
44
- const paths = new Set([`openspec/changes/${change}/tasks.md`]);
45
- for (const item of contract.capsule.authority) {
46
- const source = String(item.source || "").split("#")[0].trim();
47
- if (source && fs.existsSync(path.join(repo, source))) {
48
- paths.add(source.replace(/\\/g, "/"));
49
- }
50
- }
51
- return [...paths].sort();
52
- }
53
-
54
- function hashAuthority(repo, paths) {
55
- return paths.map((relative) => ({
56
- path: relative,
57
- sha256: sha256(fs.readFileSync(path.join(repo, relative))),
58
- }));
59
- }
60
-
61
- function readManifest(repo) {
62
- const file = manifestFile(repo);
63
- if (!fs.existsSync(file)) return { state: "absent" };
64
- let manifest;
65
- try {
66
- manifest = JSON.parse(fs.readFileSync(file, "utf8"));
67
- } catch {
68
- return {
69
- state: "invalid",
70
- problems: [
71
- {
72
- code: "invalid-manifest",
73
- message:
74
- "keel/guard.json is unreadable or not JSON; run `keel guard "
75
- + "clear` and reauthorize with `keel guard start`.",
76
- },
77
- ],
78
- };
79
- }
80
- const shapeErrors = [];
81
- if (manifest.schema !== MANIFEST_SCHEMA) {
82
- shapeErrors.push(`schema must be ${MANIFEST_SCHEMA}`);
83
- }
84
- if (typeof manifest.change !== "string" || !manifest.change) {
85
- shapeErrors.push("change must be a non-empty string");
86
- }
87
- if (typeof manifest.task !== "string" || !manifest.task) {
88
- shapeErrors.push("task must be a non-empty string");
89
- }
90
- if (
91
- !manifest.fingerprint
92
- || manifest.fingerprint.algorithm !== "sha256"
93
- || !/^[0-9a-f]{64}$/.test(String(manifest.fingerprint.value || ""))
94
- ) {
95
- shapeErrors.push("fingerprint must record a sha256 value");
96
- }
97
- if (
98
- !Array.isArray(manifest.touch)
99
- || manifest.touch.length === 0
100
- || manifest.touch.some((item) => typeof item !== "string" || !item)
101
- ) {
102
- shapeErrors.push("touch must be a non-empty string list");
103
- }
104
- if (
105
- !Array.isArray(manifest.authority)
106
- || manifest.authority.length === 0
107
- || manifest.authority.some(
108
- (item) =>
109
- !item
110
- || typeof item.path !== "string"
111
- || !/^[0-9a-f]{64}$/.test(String(item.sha256 || ""))
112
- )
113
- ) {
114
- shapeErrors.push("authority must list hashed source files");
115
- }
116
- if (shapeErrors.length > 0) {
117
- return {
118
- state: "invalid",
119
- problems: shapeErrors.map((message) => ({
120
- code: "invalid-manifest",
121
- message:
122
- `keel/guard.json is invalid (${message}); run \`keel guard clear\` `
123
- + "and reauthorize with `keel guard start`.",
124
- })),
125
- };
126
- }
127
- return { state: "ok", manifest };
128
- }
129
-
130
- function startGuard(repo, options) {
131
- if (!options.change || !options.task) {
132
- throw new GuardInputError("guard start requires --change and --task");
133
- }
134
- const loaded = loadTaskContract(repo, options.change, options.task);
135
- if (!loaded) {
136
- throw new GuardInputError(
137
- `task ${options.change}#${options.task} does not exist`
138
- );
139
- }
140
- const problems = [];
141
- if (loaded.task.checked) {
142
- problems.push({
143
- code: "task-completed",
144
- message:
145
- `Task ${options.change}#${options.task} is already checked complete; `
146
- + "a completed task cannot be guarded. Run `keel guard clear` and "
147
- + "authorize a new task explicitly.",
148
- });
149
- }
150
- problems.push(...loaded.contract.diagnostics);
151
-
152
- const existing = readManifest(repo);
153
- if (
154
- problems.length === 0
155
- && existing.state === "ok"
156
- && (
157
- existing.manifest.change !== options.change
158
- || existing.manifest.task !== options.task
159
- )
160
- && !options.force
161
- ) {
162
- problems.push({
163
- code: "guard-active",
164
- message:
165
- `An active guard already covers ${existing.manifest.change}#`
166
- + `${existing.manifest.task}; run \`keel guard clear\` first or pass `
167
- + "--force to replace it.",
168
- });
169
- }
170
- if (problems.length > 0) {
171
- const refused = guardResult("start", "refused");
172
- refused.problems = problems;
173
- return refused;
174
- }
175
-
176
- const paths = authorityPaths(repo, options.change, loaded.contract);
177
- const manifest = {
178
- schema: MANIFEST_SCHEMA,
179
- change: options.change,
180
- task: options.task,
181
- fingerprint: loaded.contract.fingerprint,
182
- touch: loaded.contract.capsule.touch,
183
- authority: hashAuthority(repo, paths),
184
- };
185
- fs.mkdirSync(path.join(repo, "keel"), { recursive: true });
186
- fs.writeFileSync(
187
- manifestFile(repo),
188
- `${JSON.stringify(manifest, null, 2)}\n`,
189
- "utf8"
190
- );
191
- return guardResult("start", "started", { manifest });
192
- }
193
-
194
- function guardStatus(repo) {
195
- const existing = readManifest(repo);
196
- if (existing.state === "absent") {
197
- return guardResult("status", "absent");
198
- }
199
- if (existing.state === "invalid") {
200
- const invalid = guardResult("status", "invalid");
201
- invalid.problems = existing.problems;
202
- return invalid;
203
- }
204
- const manifest = existing.manifest;
205
- const problems = [];
206
- const loaded = loadTaskContract(repo, manifest.change, manifest.task);
207
- if (!loaded) {
208
- problems.push({
209
- code: "authority-drift",
210
- message:
211
- `Guarded task ${manifest.change}#${manifest.task} no longer resolves; `
212
- + "reauthorize through `keel gate task-start` and `keel guard start`.",
213
- });
214
- const drifted = guardResult("status", "drifted", { manifest });
215
- drifted.problems = problems;
216
- return drifted;
217
- }
218
- if (loaded.task.checked) {
219
- const completed = guardResult("status", "completed", { manifest });
220
- completed.problems = [
221
- {
222
- code: "task-completed",
223
- message:
224
- `Guarded task ${manifest.change}#${manifest.task} is checked `
225
- + "complete; run `keel guard clear` before authorizing new work.",
226
- },
227
- ];
228
- return completed;
229
- }
230
- if (loaded.contract.diagnostics.length > 0) {
231
- problems.push(...loaded.contract.diagnostics);
232
- } else if (
233
- loaded.contract.fingerprint.value !== manifest.fingerprint.value
234
- ) {
235
- problems.push({
236
- code: "fingerprint-drift",
237
- message:
238
- "The recompiled capsule fingerprint no longer matches the guard; "
239
- + "reauthorize through `keel gate task-start` and `keel guard start`.",
240
- });
241
- }
242
- for (const entry of manifest.authority) {
243
- const file = path.join(repo, entry.path);
244
- if (!fs.existsSync(file) || sha256(fs.readFileSync(file)) !== entry.sha256) {
245
- problems.push({
246
- code: "authority-drift",
247
- message:
248
- `Recorded authority hash for ${entry.path} no longer matches; `
249
- + "reauthorize through `keel gate task-start` and `keel guard start`.",
250
- });
251
- }
252
- }
253
- if (problems.length > 0) {
254
- const drifted = guardResult("status", "drifted", { manifest });
255
- drifted.problems = problems;
256
- return drifted;
257
- }
258
- return guardResult("status", "active", { manifest });
259
- }
260
-
261
- function clearGuard(repo) {
262
- const file = manifestFile(repo);
263
- if (!fs.existsSync(file)) {
264
- return guardResult("clear", "absent");
265
- }
266
- fs.rmSync(file, { force: true });
267
- return guardResult("clear", "cleared");
268
- }
269
-
270
- function renderGuard(result) {
271
- const lines = [
272
- `Keel guard: ${result.subcommand}`,
273
- `Status: ${result.status}`,
274
- ];
275
- if (result.manifest) {
276
- lines.push(
277
- `Selection: ${result.manifest.change}#${result.manifest.task}`,
278
- `Fingerprint: ${result.manifest.fingerprint.algorithm}:`
279
- + result.manifest.fingerprint.value
280
- );
281
- }
282
- for (const item of result.problems) lines.push(`Problem: ${item.message}`);
283
- for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
284
- return `${lines.join("\n")}\n`;
285
- }
286
-
287
- module.exports = {
288
- GuardInputError,
289
- MANIFEST_SCHEMA,
290
- clearGuard,
291
- guardStatus,
292
- readManifest,
293
- renderGuard,
294
- startGuard,
295
- };
1
+ "use strict";
2
+
3
+ // Keel 4.x write-guard contract: an explicit, fingerprinted, disposable
4
+ // enforcement manifest for exactly one task. The manifest is never selection,
5
+ // continuity, or completion authority; only its presence authorizes the
6
+ // plugin PreToolUse hook to deny out-of-Touch file edits, and every broken
7
+ // state fails closed through guard status while absence changes nothing.
8
+
9
+ const crypto = require("crypto");
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { loadTaskContract } = require("./task-contract");
13
+
14
+ const MANIFEST_SCHEMA = "keel-write-guard/v1";
15
+
16
+ class GuardInputError extends Error {}
17
+
18
+ function manifestFile(repo) {
19
+ return path.join(repo, "keel", "guard.json");
20
+ }
21
+
22
+ function sha256(buffer) {
23
+ return crypto.createHash("sha256").update(buffer).digest("hex");
24
+ }
25
+
26
+ function guardResult(subcommand, status, extra = {}) {
27
+ return {
28
+ schemaVersion: 1,
29
+ command: "guard",
30
+ subcommand,
31
+ status,
32
+ manifestPath: "keel/guard.json",
33
+ problems: [],
34
+ warnings: [
35
+ "The guard manifest is a disposable enforcement pointer; OpenSpec and "
36
+ + "Git remain the only durable authority and selection never derives "
37
+ + "from it.",
38
+ ],
39
+ ...extra,
40
+ };
41
+ }
42
+
43
+ function authorityPaths(repo, change, contract) {
44
+ const paths = new Set([`openspec/changes/${change}/tasks.md`]);
45
+ for (const item of contract.capsule.authority) {
46
+ const source = String(item.source || "").split("#")[0].trim();
47
+ if (source && fs.existsSync(path.join(repo, source))) {
48
+ paths.add(source.replace(/\\/g, "/"));
49
+ }
50
+ }
51
+ return [...paths].sort();
52
+ }
53
+
54
+ function hashAuthority(repo, paths) {
55
+ return paths.map((relative) => ({
56
+ path: relative,
57
+ sha256: sha256(fs.readFileSync(path.join(repo, relative))),
58
+ }));
59
+ }
60
+
61
+ function readManifest(repo) {
62
+ const file = manifestFile(repo);
63
+ if (!fs.existsSync(file)) return { state: "absent" };
64
+ let manifest;
65
+ try {
66
+ manifest = JSON.parse(fs.readFileSync(file, "utf8"));
67
+ } catch {
68
+ return {
69
+ state: "invalid",
70
+ problems: [
71
+ {
72
+ code: "invalid-manifest",
73
+ message:
74
+ "keel/guard.json is unreadable or not JSON; run `keel guard "
75
+ + "clear` and reauthorize with `keel guard start`.",
76
+ },
77
+ ],
78
+ };
79
+ }
80
+ const shapeErrors = [];
81
+ if (manifest.schema !== MANIFEST_SCHEMA) {
82
+ shapeErrors.push(`schema must be ${MANIFEST_SCHEMA}`);
83
+ }
84
+ if (typeof manifest.change !== "string" || !manifest.change) {
85
+ shapeErrors.push("change must be a non-empty string");
86
+ }
87
+ if (typeof manifest.task !== "string" || !manifest.task) {
88
+ shapeErrors.push("task must be a non-empty string");
89
+ }
90
+ if (
91
+ !manifest.fingerprint
92
+ || manifest.fingerprint.algorithm !== "sha256"
93
+ || !/^[0-9a-f]{64}$/.test(String(manifest.fingerprint.value || ""))
94
+ ) {
95
+ shapeErrors.push("fingerprint must record a sha256 value");
96
+ }
97
+ if (
98
+ !Array.isArray(manifest.touch)
99
+ || manifest.touch.length === 0
100
+ || manifest.touch.some((item) => typeof item !== "string" || !item)
101
+ ) {
102
+ shapeErrors.push("touch must be a non-empty string list");
103
+ }
104
+ if (
105
+ !Array.isArray(manifest.authority)
106
+ || manifest.authority.length === 0
107
+ || manifest.authority.some(
108
+ (item) =>
109
+ !item
110
+ || typeof item.path !== "string"
111
+ || !/^[0-9a-f]{64}$/.test(String(item.sha256 || ""))
112
+ )
113
+ ) {
114
+ shapeErrors.push("authority must list hashed source files");
115
+ }
116
+ if (shapeErrors.length > 0) {
117
+ return {
118
+ state: "invalid",
119
+ problems: shapeErrors.map((message) => ({
120
+ code: "invalid-manifest",
121
+ message:
122
+ `keel/guard.json is invalid (${message}); run \`keel guard clear\` `
123
+ + "and reauthorize with `keel guard start`.",
124
+ })),
125
+ };
126
+ }
127
+ return { state: "ok", manifest };
128
+ }
129
+
130
+ function startGuard(repo, options) {
131
+ if (!options.change || !options.task) {
132
+ throw new GuardInputError("guard start requires --change and --task");
133
+ }
134
+ const loaded = loadTaskContract(repo, options.change, options.task);
135
+ if (!loaded) {
136
+ throw new GuardInputError(
137
+ `task ${options.change}#${options.task} does not exist`
138
+ );
139
+ }
140
+ const problems = [];
141
+ if (loaded.task.checked) {
142
+ problems.push({
143
+ code: "task-completed",
144
+ message:
145
+ `Task ${options.change}#${options.task} is already checked complete; `
146
+ + "a completed task cannot be guarded. Run `keel guard clear` and "
147
+ + "authorize a new task explicitly.",
148
+ });
149
+ }
150
+ problems.push(...loaded.contract.diagnostics);
151
+
152
+ const existing = readManifest(repo);
153
+ if (
154
+ problems.length === 0
155
+ && existing.state === "ok"
156
+ && (
157
+ existing.manifest.change !== options.change
158
+ || existing.manifest.task !== options.task
159
+ )
160
+ && !options.force
161
+ ) {
162
+ problems.push({
163
+ code: "guard-active",
164
+ message:
165
+ `An active guard already covers ${existing.manifest.change}#`
166
+ + `${existing.manifest.task}; run \`keel guard clear\` first or pass `
167
+ + "--force to replace it.",
168
+ });
169
+ }
170
+ if (problems.length > 0) {
171
+ const refused = guardResult("start", "refused");
172
+ refused.problems = problems;
173
+ return refused;
174
+ }
175
+
176
+ const paths = authorityPaths(repo, options.change, loaded.contract);
177
+ const manifest = {
178
+ schema: MANIFEST_SCHEMA,
179
+ change: options.change,
180
+ task: options.task,
181
+ fingerprint: loaded.contract.fingerprint,
182
+ touch: loaded.contract.capsule.touch,
183
+ authority: hashAuthority(repo, paths),
184
+ };
185
+ fs.mkdirSync(path.join(repo, "keel"), { recursive: true });
186
+ fs.writeFileSync(
187
+ manifestFile(repo),
188
+ `${JSON.stringify(manifest, null, 2)}\n`,
189
+ "utf8"
190
+ );
191
+ return guardResult("start", "started", { manifest });
192
+ }
193
+
194
+ function guardStatus(repo) {
195
+ const existing = readManifest(repo);
196
+ if (existing.state === "absent") {
197
+ return guardResult("status", "absent");
198
+ }
199
+ if (existing.state === "invalid") {
200
+ const invalid = guardResult("status", "invalid");
201
+ invalid.problems = existing.problems;
202
+ return invalid;
203
+ }
204
+ const manifest = existing.manifest;
205
+ const problems = [];
206
+ const loaded = loadTaskContract(repo, manifest.change, manifest.task);
207
+ if (!loaded) {
208
+ problems.push({
209
+ code: "authority-drift",
210
+ message:
211
+ `Guarded task ${manifest.change}#${manifest.task} no longer resolves; `
212
+ + "reauthorize through `keel gate task-start` and `keel guard start`.",
213
+ });
214
+ const drifted = guardResult("status", "drifted", { manifest });
215
+ drifted.problems = problems;
216
+ return drifted;
217
+ }
218
+ if (loaded.task.checked) {
219
+ const completed = guardResult("status", "completed", { manifest });
220
+ completed.problems = [
221
+ {
222
+ code: "task-completed",
223
+ message:
224
+ `Guarded task ${manifest.change}#${manifest.task} is checked `
225
+ + "complete; run `keel guard clear` before authorizing new work.",
226
+ },
227
+ ];
228
+ return completed;
229
+ }
230
+ if (loaded.contract.diagnostics.length > 0) {
231
+ problems.push(...loaded.contract.diagnostics);
232
+ } else if (
233
+ loaded.contract.fingerprint.value !== manifest.fingerprint.value
234
+ ) {
235
+ problems.push({
236
+ code: "fingerprint-drift",
237
+ message:
238
+ "The recompiled capsule fingerprint no longer matches the guard; "
239
+ + "reauthorize through `keel gate task-start` and `keel guard start`.",
240
+ });
241
+ }
242
+ for (const entry of manifest.authority) {
243
+ const file = path.join(repo, entry.path);
244
+ if (!fs.existsSync(file) || sha256(fs.readFileSync(file)) !== entry.sha256) {
245
+ problems.push({
246
+ code: "authority-drift",
247
+ message:
248
+ `Recorded authority hash for ${entry.path} no longer matches; `
249
+ + "reauthorize through `keel gate task-start` and `keel guard start`.",
250
+ });
251
+ }
252
+ }
253
+ if (problems.length > 0) {
254
+ const drifted = guardResult("status", "drifted", { manifest });
255
+ drifted.problems = problems;
256
+ return drifted;
257
+ }
258
+ return guardResult("status", "active", { manifest });
259
+ }
260
+
261
+ function clearGuard(repo) {
262
+ const file = manifestFile(repo);
263
+ if (!fs.existsSync(file)) {
264
+ return guardResult("clear", "absent");
265
+ }
266
+ fs.rmSync(file, { force: true });
267
+ return guardResult("clear", "cleared");
268
+ }
269
+
270
+ function renderGuard(result) {
271
+ const lines = [
272
+ `Keel guard: ${result.subcommand}`,
273
+ `Status: ${result.status}`,
274
+ ];
275
+ if (result.manifest) {
276
+ lines.push(
277
+ `Selection: ${result.manifest.change}#${result.manifest.task}`,
278
+ `Fingerprint: ${result.manifest.fingerprint.algorithm}:`
279
+ + result.manifest.fingerprint.value
280
+ );
281
+ }
282
+ for (const item of result.problems) lines.push(`Problem: ${item.message}`);
283
+ for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
284
+ return `${lines.join("\n")}\n`;
285
+ }
286
+
287
+ module.exports = {
288
+ GuardInputError,
289
+ MANIFEST_SCHEMA,
290
+ clearGuard,
291
+ guardStatus,
292
+ readManifest,
293
+ renderGuard,
294
+ startGuard,
295
+ };