@christang/keel 5.2.2 → 5.2.4

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
  }
@@ -215,12 +246,21 @@ function reviewValue(task, label) {
215
246
  return match ? match[1].trim() : "";
216
247
  }
217
248
 
249
+ // The durable-owner forms that are pure shape checks, shared by the Review
250
+ // Findings check and the Expectation Coverage check so a form added to one is
251
+ // never missing from the other. Gates run without network and have never
252
+ // confirmed that an archive path resolves either, so an external tracker
253
+ // reference is no less checkable than what was already accepted; whether the
254
+ // owner is real stays a Review judgment.
255
+ const SHARED_DURABLE_OWNER_FORMS =
256
+ /(?:\bkeel\/archive\/[A-Za-z0-9._/-]+|\bhttps?:\/\/\S)/i;
257
+
218
258
  function findingOwnerIsDurable(repo, findings) {
219
259
  if (/keel\/HANDOFF\.md/i.test(findings)) return false;
220
260
  if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
221
261
  return true;
222
262
  }
223
- if (/\bkeel\/archive\/[A-Za-z0-9._/-]+/i.test(findings)) return true;
263
+ if (SHARED_DURABLE_OWNER_FORMS.test(findings)) return true;
224
264
  const owner = findings.match(
225
265
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
226
266
  );
@@ -421,8 +461,8 @@ function completionChecks(repo, task, contract = null) {
421
461
  "finding-owner",
422
462
  "Review Findings must be `none` or carry a durable owner — a "
423
463
  + "`Discard reason:`/`Discard rationale:` prefix, a `keel/archive/…` "
424
- + "path, or an existing `openspec/changes/…` artifact; "
425
- + "`keel/HANDOFF.md` is not an owner."
464
+ + "path, an existing `openspec/changes/…` artifact, or an absolute "
465
+ + "`https://…` tracker reference; `keel/HANDOFF.md` is not an owner."
426
466
  )
427
467
  );
428
468
  }
@@ -489,7 +529,8 @@ function expectationProblems(content, tasks) {
489
529
  "expectation-coverage",
490
530
  "Expectation Coverage must declare each `E<n>` closure — "
491
531
  + "`- E<n>: <expectation> Covered by: <task ids>`, a `Durable owner:` "
492
- + "path, or a `Discard reason:` — or `- None.`."
532
+ + "path or `https://…` tracker reference, or a `Discard reason:` — "
533
+ + "or `- None.`."
493
534
  ),
494
535
  ];
495
536
  }
@@ -497,8 +538,12 @@ function expectationProblems(content, tasks) {
497
538
  for (const entry of entries) {
498
539
  const [, id, body] = entry;
499
540
  const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
500
- const hasDurableOwner =
501
- /Durable owner:\s*(?:openspec\/changes\/|keel\/archive\/)/i.test(body);
541
+ const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
542
+ const hasDurableOwner = Boolean(
543
+ declaredOwner
544
+ && (/^openspec\/changes\//i.test(declaredOwner[1].trim())
545
+ || SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
546
+ );
502
547
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
503
548
  if (!covered && !hasDurableOwner && !discarded) {
504
549
  problems.push(
@@ -658,7 +703,8 @@ function renderGate(result) {
658
703
  }
659
704
  if (result.record) {
660
705
  lines.push(
661
- `Recorded: ${result.record.path}:${result.record.line} (Contract anchor)`
706
+ `Contract anchor ${result.record.status}: ${result.record.path}:`
707
+ + result.record.line
662
708
  );
663
709
  }
664
710
  return `${lines.join("\n")}\n`;
package/src/core/guard.js CHANGED
@@ -35,6 +35,14 @@ function guardResult(subcommand, status, extra = {}) {
35
35
  "The guard manifest is a disposable enforcement pointer; OpenSpec and "
36
36
  + "Git remain the only durable authority and selection never derives "
37
37
  + "from it.",
38
+ // The status describes a file Keel wrote. Whether anything reads that
39
+ // file is a target-side fact: enforcement runs as a runtime hook the
40
+ // host loads, and a host that loaded different plugins keeps them for
41
+ // the life of its session. Reporting `started` as though it were a probe
42
+ // result is the same inference `--doctor` already refuses to make.
43
+ "This status describes the manifest only. Enforcement runs as a runtime "
44
+ + "hook in the host, which Keel cannot observe from the repository, so "
45
+ + "a written manifest is not evidence that any write was checked.",
38
46
  ],
39
47
  ...extra,
40
48
  };
@@ -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) {
@@ -245,7 +269,27 @@ function taskStartContractProblems(task) {
245
269
  }
246
270
 
247
271
  function requiredFieldProblems(task) {
248
- 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
+ }
249
293
  const required = compact
250
294
  ? ["Covers", "Verify", "Evidence"]
251
295
  : [
@@ -313,12 +357,8 @@ function scenarioOutcomes(content) {
313
357
  ].map((match) => normalizeText(match[1]));
314
358
  }
315
359
 
316
- function specAuthority(repo, change, reference) {
317
- const parts = reference.split("/").map((part) => part.trim());
318
- if (parts.length < 2 || parts.length > 3) return null;
319
- const [capability, requirementName, scenarioName] = parts;
320
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)) return null;
321
- const candidates = [
360
+ function specCandidatePaths(repo, change, capability) {
361
+ return [
322
362
  path.join(
323
363
  repo,
324
364
  "openspec",
@@ -330,6 +370,68 @@ function specAuthority(repo, change, reference) {
330
370
  ),
331
371
  path.join(repo, "openspec", "specs", capability, "spec.md"),
332
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);
333
435
  for (const specPath of candidates) {
334
436
  if (!fs.existsSync(specPath)) continue;
335
437
  const content = fs.readFileSync(specPath, "utf8");
@@ -361,7 +463,10 @@ function specAuthority(repo, change, reference) {
361
463
  code: scenarios.length > 1 ? "ambiguous-covers" : "unresolved-covers",
362
464
  message:
363
465
  `${scenarios.length > 1 ? "Duplicated" : "Missing"} Covers `
364
- + `scenario: ${reference}.`,
466
+ + `scenario: ${reference}.`
467
+ + (scenarios.length > 1
468
+ ? ""
469
+ : collisionHint(repo, change, capability)),
365
470
  },
366
471
  };
367
472
  }
@@ -383,7 +488,9 @@ function specAuthority(repo, change, reference) {
383
488
  return {
384
489
  diagnostic: {
385
490
  code: "unresolved-covers",
386
- message: `Covers reference could not be resolved: ${reference}.`,
491
+ message:
492
+ `Covers reference could not be resolved: ${reference}.`
493
+ + collisionHint(repo, change, capability),
387
494
  },
388
495
  };
389
496
  }
@@ -657,11 +764,17 @@ function compileTaskContract(repo, change, task) {
657
764
  questionIds.length > 0
658
765
  && !isConcrete(fallback.replace(/^Pre-authorized fallback:\s*/i, ""))
659
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.
660
770
  resolved.diagnostics.push(...questionIds.map((questionId) => ({
661
771
  code: "unresolved-authority",
662
772
  message:
663
- `${questionId} requires documented design authority and an authorized `
664
- + "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.",
665
778
  })));
666
779
  }
667
780
  const capsule = {