@christang/keel 5.2.1 → 5.2.3

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.
@@ -35,10 +35,33 @@ function codexHome() {
35
35
  return path.resolve(configured || path.join(os.homedir(), ".codex"));
36
36
  }
37
37
 
38
+ // Keel's own repository ships the plugin source under plugins/keel/. A project
39
+ // that consumes Keel never has it, and `keel --init` never creates it, so any
40
+ // check reading that path is a development-only check. Require both signals so
41
+ // a project that merely vendors a plugins/keel/ directory is not misread as
42
+ // Keel's own source.
43
+ function isKeelSourceRepo(repo) {
44
+ try {
45
+ const manifest = JSON.parse(
46
+ fs.readFileSync(path.join(repo, "package.json"), "utf8")
47
+ );
48
+ if (manifest.name !== "@christang/keel") return false;
49
+ } catch {
50
+ return false;
51
+ }
52
+ return fs.existsSync(path.join(repo, "plugins", "keel"));
53
+ }
54
+
55
+ const PLUGIN_RUNTIME_OBSERVATION =
56
+ "installed/enabled/trusted/active/behavior-verified plugin states need "
57
+ + "native runtime evidence and remain advisory or manual until probed";
58
+
38
59
  function pluginObservation(repo, target) {
39
60
  if (target === "opencode") {
40
61
  return "OpenCode has no v4 native plugin surface; manual CLI compatibility only";
41
62
  }
63
+ // The plugin source path is meaningful only in Keel's own repository.
64
+ if (!isKeelSourceRepo(repo)) return PLUGIN_RUNTIME_OBSERVATION;
42
65
  const manifestRelative = path.join(
43
66
  "plugins",
44
67
  "keel",
@@ -58,11 +81,7 @@ function pluginObservation(repo, target) {
58
81
  sourceState = `plugin source unreadable at ${manifestRelative}`;
59
82
  }
60
83
  }
61
- return (
62
- `${sourceState}; installed/enabled/trusted/active/behavior-verified plugin `
63
- + "states need native runtime evidence and remain advisory or manual "
64
- + "until probed"
65
- );
84
+ return `${sourceState}; ${PLUGIN_RUNTIME_OBSERVATION}`;
66
85
  }
67
86
 
68
87
  function targetObservation(repo, target) {
@@ -286,6 +305,7 @@ function renderCapabilities(result) {
286
305
 
287
306
  module.exports = {
288
307
  CAPABILITY_COMMANDS,
308
+ isKeelSourceRepo,
289
309
  probeCapabilities,
290
310
  renderCapabilities,
291
311
  };
package/src/core/gates.js CHANGED
@@ -111,24 +111,36 @@ function contractAnchorPlan(selection, task) {
111
111
  ? selection.tasks[index + 1].line
112
112
  : lines.length;
113
113
  for (let cursor = task.line; cursor < end; cursor += 1) {
114
- const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*pending(\r?)$/);
114
+ const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
115
115
  if (match) {
116
- return { lines, cursor, indent: match[1], cr: match[2] };
116
+ return {
117
+ lines,
118
+ cursor,
119
+ indent: match[1],
120
+ previous: match[2].trim(),
121
+ cr: match[3],
122
+ };
117
123
  }
118
124
  }
119
125
  return null;
120
126
  }
121
127
 
128
+ function anchoredFingerprint(previous) {
129
+ const match = previous.match(/sha-?256[\s:`]*([a-f0-9]{64})/i);
130
+ return match ? match[1].toLowerCase() : null;
131
+ }
132
+
122
133
  function taskStart(repo, options) {
123
134
  const selection = loadSelection(repo, options);
124
135
  const task = selection.selected[0];
125
136
  const compiled = compileTaskContract(repo, selection.change, task);
126
137
  const problems = [...compiled.diagnostics];
127
- // The explicit --record anchor write is refused loudly when the selected
128
- // task's Evidence has no literal pending Contract line: a silent skip would
129
- // hide a stale anchor and an overwrite would destroy the recorded start
130
- // evidence drift detection depends on. Refusal writes nothing, guard
131
- // manifest included.
138
+ // Recording the current fingerprint is idempotent: --record replaces the
139
+ // selected task's Contract anchor whatever it holds, so reauthorizing a task
140
+ // whose authority changed the path the guard's own drift messages direct
141
+ // authors to needs no manual edit. Refusal is kept only for a task with no
142
+ // anchor at all, which is a malformed capsule rather than a reauthorization,
143
+ // and it writes nothing, guard manifest included.
132
144
  let anchorPlan = null;
133
145
  if (options.record && problems.length === 0) {
134
146
  anchorPlan = contractAnchorPlan(selection, task);
@@ -136,9 +148,9 @@ function taskStart(repo, options) {
136
148
  problems.push(
137
149
  problem(
138
150
  "record-refused",
139
- "--record requires the selected task's Evidence to contain the "
140
- + 'literal line "- Contract: pending"; the anchor is already '
141
- + "recorded or missing, so nothing was written."
151
+ "--record needs a \"- Contract:\" Evidence line on the selected "
152
+ + "task to anchor, and this task has none, so nothing was "
153
+ + 'written. Add "- Contract: pending" to its Evidence.'
142
154
  )
143
155
  );
144
156
  }
@@ -177,19 +189,38 @@ function taskStart(repo, options) {
177
189
  }
178
190
  }
179
191
  if (result.status === "pass" && anchorPlan) {
180
- anchorPlan.lines[anchorPlan.cursor] =
192
+ const anchorLine =
181
193
  `${anchorPlan.indent}- Contract: keel-task-capsule/v1 `
182
194
  + `sha256:${compiled.fingerprint.value}${anchorPlan.cr}`;
183
- fs.writeFileSync(
184
- selection.tasksPath,
185
- anchorPlan.lines.join("\n"),
186
- "utf8"
187
- );
195
+ const unchanged = anchorPlan.lines[anchorPlan.cursor] === anchorLine;
196
+ if (!unchanged) {
197
+ anchorPlan.lines[anchorPlan.cursor] = anchorLine;
198
+ fs.writeFileSync(
199
+ selection.tasksPath,
200
+ anchorPlan.lines.join("\n"),
201
+ "utf8"
202
+ );
203
+ }
204
+ const wasPending = /^pending$/i.test(anchorPlan.previous);
188
205
  result.record = {
189
- status: "recorded",
206
+ status: unchanged ? "unchanged" : wasPending ? "recorded" : "rerecorded",
190
207
  path: `openspec/changes/${selection.change}/tasks.md`,
191
208
  line: anchorPlan.cursor + 1,
209
+ previous: anchorPlan.previous,
192
210
  };
211
+ // A re-record that lands a different fingerprint is a contract change, so
212
+ // any Evidence already produced under the previous one is stale. The gate
213
+ // cannot judge which Evidence survives; it names the change and leaves the
214
+ // call to the current agent's Review.
215
+ const replaced = anchoredFingerprint(anchorPlan.previous);
216
+ if (replaced && replaced !== compiled.fingerprint.value) {
217
+ result.warnings.push(
218
+ `Re-recorded over a different contract: was sha256:${replaced}, now `
219
+ + `sha256:${compiled.fingerprint.value}. Execution evidence produced `
220
+ + "under the previous contract is stale; clear or re-verify it "
221
+ + "before completing this task."
222
+ );
223
+ }
193
224
  }
194
225
  return result;
195
226
  }
@@ -658,7 +689,8 @@ function renderGate(result) {
658
689
  }
659
690
  if (result.record) {
660
691
  lines.push(
661
- `Recorded: ${result.record.path}:${result.record.line} (Contract anchor)`
692
+ `Contract anchor ${result.record.status}: ${result.record.path}:`
693
+ + result.record.line
662
694
  );
663
695
  }
664
696
  return `${lines.join("\n")}\n`;
@@ -10,13 +10,37 @@ const SUPPORTED_MODES = new Set([
10
10
  "plan-first",
11
11
  ]);
12
12
 
13
- function isConcrete(value) {
14
- const normalized = String(value || "")
13
+ const UNFILLED_TOKEN = /(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i;
14
+
15
+ function normalizeFieldText(value) {
16
+ return String(value || "")
15
17
  .replace(/<!--[\s\S]*?-->/g, "")
16
18
  .replace(/^\s*-\s*/gm, "")
17
19
  .trim();
20
+ }
21
+
22
+ // Inline code spans hold documented patterns — a filename shape, or prose that
23
+ // has to name the token forms themselves. Strip them before looking for an
24
+ // unfilled slot, but only after the emptiness test, so a field whose whole
25
+ // value is one code span is not mistaken for an empty field.
26
+ function withoutInlineCode(text) {
27
+ return text.replace(/`[^`]*`/g, " ");
28
+ }
29
+
30
+ function isConcrete(value) {
31
+ const normalized = normalizeFieldText(value);
18
32
  if (!normalized || /^(?:none|pending)\.?$/i.test(normalized)) return false;
19
- return !/(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i.test(normalized);
33
+ return !UNFILLED_TOKEN.test(withoutInlineCode(normalized));
34
+ }
35
+
36
+ // The unfilled token that made a field non-concrete, or null when the field is
37
+ // empty, `none`, or `pending`. Used to explain a non-concrete field instead of
38
+ // letting the caller infer a different schema from it.
39
+ function unfilledToken(value) {
40
+ const normalized = normalizeFieldText(value);
41
+ if (!normalized || /^(?:none|pending)\.?$/i.test(normalized)) return null;
42
+ const match = withoutInlineCode(normalized).match(UNFILLED_TOKEN);
43
+ return match ? match[0] : null;
20
44
  }
21
45
 
22
46
  function parseTasks(content) {
@@ -123,10 +147,12 @@ function verification(task) {
123
147
  ? compact.filter((entry) => !/^Strategy:\s*/i.test(entry))
124
148
  : fieldValues(task, "Commands");
125
149
  const commands = commandSource.map((entry) => {
126
- const match = entry.match(/^(M[1-9]\d*):\s*(.*)$/);
150
+ // An optional (fast)/(full) layer tag after the M<n> label marks which
151
+ // checks the fast inner loop runs; an untagged check is full.
152
+ const match = entry.match(/^(M[1-9]\d*)(?:\s*\((fast|full)\))?:\s*(.*)$/);
127
153
  return match
128
- ? { label: match[1], check: normalizeText(match[2]) }
129
- : { label: null, check: entry };
154
+ ? { label: match[1], layer: match[2] || "full", check: normalizeText(match[3]) }
155
+ : { label: null, layer: "full", check: entry };
130
156
  });
131
157
  return {
132
158
  compact: compact.length > 0,
@@ -243,7 +269,27 @@ function taskStartContractProblems(task) {
243
269
  }
244
270
 
245
271
  function requiredFieldProblems(task) {
246
- const compact = isConcrete(field(task, "Verify"));
272
+ const verify = field(task, "Verify");
273
+ const compact = isConcrete(verify);
274
+ // A task that declared Verify but left an unfilled token in it is a compact
275
+ // v4 task with one bad token, not an expanded v3 task. Say which token, and
276
+ // do not report the v3 fields it never declared.
277
+ if (!compact) {
278
+ const token = unfilledToken(verify);
279
+ if (token) {
280
+ return [
281
+ {
282
+ code: "non-concrete-verify",
283
+ message:
284
+ `Verify contains the unfilled token \`${token}\`; compact v4 `
285
+ + "detection requires a concrete Verify. Replace or remove that "
286
+ + "token — the expanded v3 fields are not required. Angle "
287
+ + "brackets, TODO, TBD, and the word placeholder all read as "
288
+ + "unfilled, including inside prose.",
289
+ },
290
+ ];
291
+ }
292
+ }
247
293
  const required = compact
248
294
  ? ["Covers", "Verify", "Evidence"]
249
295
  : [
@@ -311,12 +357,8 @@ function scenarioOutcomes(content) {
311
357
  ].map((match) => normalizeText(match[1]));
312
358
  }
313
359
 
314
- function specAuthority(repo, change, reference) {
315
- const parts = reference.split("/").map((part) => part.trim());
316
- if (parts.length < 2 || parts.length > 3) return null;
317
- const [capability, requirementName, scenarioName] = parts;
318
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)) return null;
319
- const candidates = [
360
+ function specCandidatePaths(repo, change, capability) {
361
+ return [
320
362
  path.join(
321
363
  repo,
322
364
  "openspec",
@@ -328,6 +370,68 @@ function specAuthority(repo, change, reference) {
328
370
  ),
329
371
  path.join(repo, "openspec", "specs", capability, "spec.md"),
330
372
  ];
373
+ }
374
+
375
+ // Requirement and scenario names that contain the hierarchy separator can never
376
+ // be referenced, whatever the author writes, so name them instead of leaving a
377
+ // correct-looking reference unexplained.
378
+ function separatorCollisions(repo, change, capability) {
379
+ const collisions = [];
380
+ for (const specPath of specCandidatePaths(repo, change, capability)) {
381
+ if (!fs.existsSync(specPath)) continue;
382
+ const content = fs.readFileSync(specPath, "utf8");
383
+ for (const pattern of [
384
+ /^### Requirement:\s*(.+?)\s*$/gm,
385
+ /^#### Scenario:\s*(.+?)\s*$/gm,
386
+ ]) {
387
+ for (const match of content.matchAll(pattern)) {
388
+ if (match[1].includes("/") && !collisions.includes(match[1])) {
389
+ collisions.push(match[1]);
390
+ }
391
+ }
392
+ }
393
+ }
394
+ return collisions;
395
+ }
396
+
397
+ function collisionHint(repo, change, capability) {
398
+ const collisions = separatorCollisions(repo, change, capability);
399
+ if (collisions.length === 0) return "";
400
+ const named = collisions.map((name) => `"${name}"`).join(", ");
401
+ return (
402
+ ` Capability ${capability} declares a name containing the / separator, `
403
+ + `which cannot be referenced: ${named}. Rename it in the spec, or `
404
+ + "reference its parent requirement instead."
405
+ );
406
+ }
407
+
408
+ function specAuthority(repo, change, reference) {
409
+ const parts = reference.split("/").map((part) => part.trim());
410
+ const [capability, requirementName, scenarioName] = parts;
411
+ const namesCapability =
412
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability || "")
413
+ && specCandidatePaths(repo, change, capability).some((specPath) =>
414
+ fs.existsSync(specPath)
415
+ );
416
+ if (parts.length < 2 || parts.length > 3) {
417
+ // Only a reference that names a real capability is a failed spec
418
+ // reference; anything else is free text and stays a legacy reference.
419
+ if (parts.length > 3 && namesCapability) {
420
+ return {
421
+ diagnostic: {
422
+ code: "unresolved-covers",
423
+ message:
424
+ `Covers reference has ${parts.length} segments; the hierarchy is `
425
+ + "capability / requirement, or capability / requirement / "
426
+ + `scenario: ${reference}.`
427
+ + collisionHint(repo, change, capability),
428
+ },
429
+ };
430
+ }
431
+ return null;
432
+ }
433
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)) return null;
434
+ const candidates = specCandidatePaths(repo, change, capability);
331
435
  for (const specPath of candidates) {
332
436
  if (!fs.existsSync(specPath)) continue;
333
437
  const content = fs.readFileSync(specPath, "utf8");
@@ -359,7 +463,10 @@ function specAuthority(repo, change, reference) {
359
463
  code: scenarios.length > 1 ? "ambiguous-covers" : "unresolved-covers",
360
464
  message:
361
465
  `${scenarios.length > 1 ? "Duplicated" : "Missing"} Covers `
362
- + `scenario: ${reference}.`,
466
+ + `scenario: ${reference}.`
467
+ + (scenarios.length > 1
468
+ ? ""
469
+ : collisionHint(repo, change, capability)),
363
470
  },
364
471
  };
365
472
  }
@@ -381,7 +488,9 @@ function specAuthority(repo, change, reference) {
381
488
  return {
382
489
  diagnostic: {
383
490
  code: "unresolved-covers",
384
- message: `Covers reference could not be resolved: ${reference}.`,
491
+ message:
492
+ `Covers reference could not be resolved: ${reference}.`
493
+ + collisionHint(repo, change, capability),
385
494
  },
386
495
  };
387
496
  }
@@ -655,11 +764,17 @@ function compileTaskContract(repo, change, task) {
655
764
  questionIds.length > 0
656
765
  && !isConcrete(fallback.replace(/^Pre-authorized fallback:\s*/i, ""))
657
766
  ) {
767
+ // Name the field and prefix this check actually reads. The previous
768
+ // wording said "documented design authority", which sent authors to
769
+ // design.md — where the answer usually already is.
658
770
  resolved.diagnostics.push(...questionIds.map((questionId) => ({
659
771
  code: "unresolved-authority",
660
772
  message:
661
- `${questionId} requires documented design authority and an authorized `
662
- + "fallback before implementation.",
773
+ `${questionId} is referenced in Covers but task ${task.id} declares no `
774
+ + "authorized fallback. Add an \"Autonomy boundary:\" field whose entry "
775
+ + "line begins \"Pre-authorized fallback:\" and states the reversible "
776
+ + "bound plus the evidence it requires. This check reads only that line "
777
+ + "on the task; prose in design.md does not satisfy it.",
663
778
  })));
664
779
  }
665
780
  const capsule = {
@@ -678,7 +793,15 @@ function compileTaskContract(repo, change, task) {
678
793
  acceptance: [...new Set([...derivedAcceptance, ...explicitAcceptance])],
679
794
  verification: {
680
795
  strategy: taskVerification.strategy,
681
- commands: taskVerification.commands.filter((entry) => entry.label),
796
+ // Emit the layer only when a check opts into the fast inner loop, so
797
+ // untagged (full) checks keep their existing capsule shape and fingerprint.
798
+ commands: taskVerification.commands
799
+ .filter((entry) => entry.label)
800
+ .map((entry) =>
801
+ entry.layer && entry.layer !== "full"
802
+ ? { label: entry.label, check: entry.check, layer: entry.layer }
803
+ : { label: entry.label, check: entry.check }
804
+ ),
682
805
  },
683
806
  boundaries: {
684
807
  autonomy,