@christang/keel 5.7.0 → 5.14.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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.7.0 -->
1
+ <!-- keel:start version=5.14.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
@@ -2,8 +2,10 @@
2
2
  Record only task-specific authority. Omitted fields inherit versioned
3
3
  defaults: Owner is the current Keel agent, Mode is implementation, Read
4
4
  is the change proposal/design/specs/tasks plus discovered repository
5
- context, Acceptance derives from Covers, Coupling defaults to none, and
6
- helpers stay read-only/evidence-only. Autonomy defaults to hard-stop, and
5
+ context, Acceptance derives from Covers, Coupling defaults to none,
6
+ helpers stay read-only/evidence-only, and delegation defaults to none
7
+ declare `Delegation: <tier>` only to override what `keel/config.yaml`
8
+ supplies. Autonomy defaults to hard-stop, and
7
9
  commit, push, sync, archive, and cross-task continuation stay unauthorized,
8
10
  EXCEPT where `keel/config.yaml` standing-authorizes an action: a task that
9
11
  authors no `Autonomy boundary:` inherits that declaration, and the capsule
package/bin/keel.js CHANGED
@@ -72,7 +72,14 @@ const OPENSPEC_SKILLS = [
72
72
  "openspec-sync-specs",
73
73
  "openspec-archive-change",
74
74
  ];
75
- const OPENSPEC_OVERLAY_ACTIONS = ["propose", "apply", "archive"];
75
+ // `sync` is here because `AGENTS.md` gates it exactly as it gates archive
76
+ // `keel gate change-close --action sync|archive` plus `keel-review-checklist`.
77
+ // Archive's surface said so and sync's said nothing, so an agent invoking
78
+ // `/opsx:sync` read generic upstream instructions with no mention of the gate
79
+ // that decides whether the sync may complete. `explore` is deliberately absent:
80
+ // it reads and reports, reaches no gate, and changes no state, so an overlay
81
+ // there would read as governance where there is none.
82
+ const OPENSPEC_OVERLAY_ACTIONS = ["propose", "apply", "archive", "sync"];
76
83
  const OPENSPEC_SURFACE_OVERLAY_START =
77
84
  `<!-- keel:openspec-surface-overlay version=${PACKAGE_JSON.version} -->`;
78
85
  const OPENSPEC_SURFACE_OVERLAY_END =
@@ -614,31 +621,16 @@ function parseArgs(argv) {
614
621
  return parsed;
615
622
  }
616
623
 
617
- function pythonCandidates() {
618
- if (process.env.KEEL_PYTHON) {
619
- return [{ command: process.env.KEEL_PYTHON, prefixArgs: [] }];
620
- }
621
-
622
- const candidates = [];
623
- if (process.platform === "win32") {
624
- candidates.push({ command: "py", prefixArgs: ["-3"] });
625
- candidates.push({ command: "python", prefixArgs: [] });
626
- candidates.push({ command: "python3", prefixArgs: [] });
627
- } else {
628
- candidates.push({ command: "python3", prefixArgs: [] });
629
- candidates.push({ command: "python", prefixArgs: [] });
630
- }
631
- return candidates;
632
- }
633
-
634
- function commandExists(candidate) {
635
- const result = spawnSync(
636
- candidate.command,
637
- [...candidate.prefixArgs, "--version"],
638
- { encoding: "utf8" }
639
- );
640
- return !result.error && result.status === 0;
641
- }
624
+ // The interpreter rule lives in `scripts/run_python.js` and is imported rather
625
+ // than restated. This file used to carry its own candidate list that asked only
626
+ // whether a command runs, so `keel --doctor` reported `python3: ok` for the
627
+ // same 3.9.6 the runner refuses — measured 2026-08-02. Two statements of one
628
+ // threshold drift the moment either moves; one statement cannot.
629
+ const {
630
+ MINIMUM_PYTHON,
631
+ resolveInterpreter,
632
+ describeTried,
633
+ } = require(path.join(__dirname, "..", "scripts", "run_python.js"));
642
634
 
643
635
  function npmCommand() {
644
636
  return process.platform === "win32" ? "npm.cmd" : "npm";
@@ -688,6 +680,42 @@ function runCommand(command, args, options = {}) {
688
680
  return typeof result.status === "number" ? result.status : 1;
689
681
  }
690
682
 
683
+ // Which OpenSpec answered is not cosmetic. `openspecCandidates` prefers the
684
+ // installed dependency and otherwise falls back to PATH in silence, so a
685
+ // worktree with no `node_modules` validates against whatever version happens
686
+ // to be installed globally — measured here as 1.4.1 against a lockfile that
687
+ // resolves 1.6.0, which rejects a requirement the shipped template writes.
688
+ // A green pipeline and a red worktree were the same command run against two
689
+ // different programs. Keel states which one; it does not install or select.
690
+ function openspecReportedVersion(command) {
691
+ const result = spawnSync(command, ["--version"], {
692
+ encoding: "utf8",
693
+ shell: process.platform === "win32",
694
+ });
695
+ if (result.error || result.status !== 0) return null;
696
+ const match = `${result.stdout || ""}${result.stderr || ""}`.match(
697
+ /\d+\.\d+\.\d+/
698
+ );
699
+ return match ? match[0] : null;
700
+ }
701
+
702
+ function lockedOpenSpecVersion() {
703
+ try {
704
+ const lock = JSON.parse(
705
+ fs.readFileSync(path.join(PACKAGE_ROOT, "package-lock.json"), "utf8")
706
+ );
707
+ for (const [name, entry] of Object.entries(lock.packages || {})) {
708
+ if (name.endsWith("@fission-ai/openspec") && entry && entry.version) {
709
+ return entry.version;
710
+ }
711
+ }
712
+ } catch {
713
+ // No lockfile in a published install; there is then nothing to disagree
714
+ // with, which is not the same as agreement and is reported as such.
715
+ }
716
+ return null;
717
+ }
718
+
691
719
  function findOpenSpecCommand() {
692
720
  for (const command of openspecCandidates()) {
693
721
  const status = runCommand(command, ["--version"], {
@@ -826,10 +854,8 @@ function runPython(script, args) {
826
854
  return 1;
827
855
  }
828
856
 
829
- for (const candidate of pythonCandidates()) {
830
- if (!commandExists(candidate)) {
831
- continue;
832
- }
857
+ const { candidate, tried } = resolveInterpreter();
858
+ if (candidate) {
833
859
  const result = spawnSync(
834
860
  candidate.command,
835
861
  [...candidate.prefixArgs, script, ...args],
@@ -838,8 +864,11 @@ function runPython(script, args) {
838
864
  return typeof result.status === "number" ? result.status : 1;
839
865
  }
840
866
 
867
+ const minimum = MINIMUM_PYTHON.join(".");
841
868
  process.stderr.write(
842
- "keel: Python 3 is required. Install python3/python, or set KEEL_PYTHON.\n"
869
+ `keel: Python ${minimum} or newer is required. Tried `
870
+ + `${describeTried(tried) || "nothing"}. Install one, or set `
871
+ + "KEEL_PYTHON.\n"
843
872
  );
844
873
  return 1;
845
874
  }
@@ -999,12 +1028,12 @@ function openspecOverlaySurfacesForTarget(target, repo) {
999
1028
  if (action === "propose" && target === "opencode") {
1000
1029
  return [];
1001
1030
  }
1002
- const skillName =
1003
- action === "propose"
1004
- ? "openspec-propose"
1005
- : action === "apply"
1006
- ? "openspec-apply-change"
1007
- : "openspec-archive-change";
1031
+ const skillName = {
1032
+ propose: "openspec-propose",
1033
+ apply: "openspec-apply-change",
1034
+ archive: "openspec-archive-change",
1035
+ sync: "openspec-sync-specs",
1036
+ }[action];
1008
1037
  return [
1009
1038
  {
1010
1039
  action,
@@ -1018,11 +1047,23 @@ function openspecOverlaySurfacesForTarget(target, repo) {
1018
1047
  });
1019
1048
  }
1020
1049
 
1050
+ // Derived from the managed set rather than written beside it. The label was
1051
+ // the literal string "apply/archive", which was correct while those were the
1052
+ // managed actions and became wrong — silently — the moment a third joined them.
1053
+ // `propose` is excluded because its overlay governs authoring rather than a
1054
+ // state-changing command, and the doctor line counts the command surfaces.
1055
+ function overlayActionLabel() {
1056
+ return OPENSPEC_OVERLAY_ACTIONS.filter((action) => action !== "propose")
1057
+ .join("/");
1058
+ }
1059
+
1021
1060
  function overlayTitleForAction(action) {
1022
- if (action === "propose") return "Keel Authoring Overlay";
1023
- return action === "apply"
1024
- ? "Keel Apply Overlay"
1025
- : "Keel Archive Overlay";
1061
+ return {
1062
+ propose: "Keel Authoring Overlay",
1063
+ apply: "Keel Apply Overlay",
1064
+ archive: "Keel Archive Overlay",
1065
+ sync: "Keel Sync Overlay",
1066
+ }[action];
1026
1067
  }
1027
1068
 
1028
1069
  function keelOpenSpecOverlay(action) {
@@ -1046,6 +1087,33 @@ function keelOpenSpecOverlay(action) {
1046
1087
  ];
1047
1088
  return lines.join("\n");
1048
1089
  }
1090
+ // Sync mirrors archive's structure and not its content: the two are gated and
1091
+ // owned identically, so the ownership, subagent, and delegation-language
1092
+ // rules are the same statements with `sync` in them. What differs is the
1093
+ // artifact consequence — archive warns about re-applying a promoted delta,
1094
+ // and sync is the thing that promotes it, so it says so from its own side. A
1095
+ // reader who only ever sees one of the two surfaces still learns the pairing.
1096
+ const syncBody = [
1097
+ "- The current agent owns the sync decision and must verify task evidence, follow-up ownership, and completion gates before proceeding.",
1098
+ "- Sync completion is gated by `keel gate change-close --action sync` plus `keel-review-checklist`; there is no runtime hook for it, so running the gate is the agent's own step.",
1099
+ "- Before syncing, each related critical expectation must have behavior evidence, a durable follow-up owner, or an explicit discard reason.",
1100
+ "- Target-native subagents may help with bounded assessment or evidence production only; they cannot sync, change acceptance, or bypass completion gates.",
1101
+ "- Do not treat generic OpenSpec sync delegation language as authority to transfer Keel ownership.",
1102
+ "- Invoke OpenSpec through `keel openspec` (for example `keel openspec validate`); a bare `openspec` command may not be on PATH.",
1103
+ "- Syncing promotes the change's spec delta into `openspec/specs/`. A later archive of the same change must use `--skip-specs`, because archive is not idempotent over an already-promoted delta.",
1104
+ ];
1105
+ if (action === "sync") {
1106
+ return [
1107
+ OPENSPEC_SURFACE_OVERLAY_START,
1108
+ `## ${overlayTitleForAction(action)}`,
1109
+ "",
1110
+ "Keel rules below take precedence over conflicting generic OpenSpec instructions in this file.",
1111
+ "",
1112
+ ...syncBody,
1113
+ OPENSPEC_SURFACE_OVERLAY_END,
1114
+ "",
1115
+ ].join("\n");
1116
+ }
1049
1117
  const actionBody =
1050
1118
  action === "apply"
1051
1119
  ? [
@@ -1054,7 +1122,7 @@ function keelOpenSpecOverlay(action) {
1054
1122
  "- Run the Slice Start Gate: selected current slices must name source expectations and include Read, Touch, Acceptance, Commands, and Stop/Autonomy boundaries before implementation.",
1055
1123
  "- Rough future slices may remain drafts, but cannot be selected for implementation or marked complete.",
1056
1124
  "- Obey the selected task contract: Read is required starting context, Touch is the write boundary, Commands prove Acceptance, and the Autonomy boundary controls fallback decisions.",
1057
- "- Target-native subagents return report/evidence only; they cannot mark tasks complete, update OpenSpec state, commit, sync, archive, or change Acceptance.",
1125
+ "- A target-native helper returns report/evidence only, and a declared delegate may write inside `Touch`; both cannot mark tasks complete, update OpenSpec state, commit, sync, archive, or change Acceptance.",
1058
1126
  "- The current agent reviews all subagent output, command evidence, and diffs before marking any task complete.",
1059
1127
  "- When implementation exposes a material expectation, acceptance boundary, or user-owned decision absent from durable authority, stop before implementing that choice, rerun `keel-align-expectations`, and reauthor the affected proposal/design/spec/task authority first.",
1060
1128
  "- A discovered repository fact that does not change accepted behavior or scope may be recorded and execution continues inside the existing task boundary without a product interview.",
@@ -1084,9 +1152,11 @@ function keelOpenSpecOverlay(action) {
1084
1152
  "### Target-native subagent gate",
1085
1153
  "",
1086
1154
  "- The current agent remains responsible for Keel ownership, task/archive decisions, scope control, and final reporting.",
1087
- "- Use a target-native subagent only when the current agent decides it is useful for a bounded helper step.",
1088
- "- Target-native subagents return report/evidence only; the current agent reviews the output before acting.",
1089
- "- The subagent brief must name the selected change/task, required read context, allowed write boundary or read-only diagnostic scope, expected commands/evidence, and prohibited actions.",
1155
+ "- Use a target-native subagent when the current agent decides it is useful for a bounded helper step, or as a delegate implementing the selected task where `delegation:` is declared in `keel/config.yaml` and a guard manifest is active.",
1156
+ "- Target-native subagents acting as helpers return report/evidence only. A delegate may write, and only inside `Touch`; its reported command results are a claim, and the current agent re-runs each `M<n>` check itself before recording Evidence.",
1157
+ "- Delegation is refused with no active guard manifest, because an absent manifest passes every write through silently and looks identical to a checked one.",
1158
+ "- Neither may mark tasks complete, update OpenSpec state, commit, sync, archive, or change Acceptance; the current agent reviews all output before acting.",
1159
+ "- The subagent brief must name the selected change/task, required read context, allowed write boundary or read-only diagnostic scope, expected commands/evidence, and prohibited actions. Compile it with `keel project --event subagent-start --authorize subagent`; Keel adds no separate carrier because the host already has one.",
1090
1160
  "- Prohibited actions include scope expansion, Acceptance changes, completion marking, sync/archive decisions, commits, handoff changes, and cross-runtime delegation unless the selected task or user explicitly authorizes them.",
1091
1161
  ...actionBody,
1092
1162
  OPENSPEC_SURFACE_OVERLAY_END,
@@ -1292,13 +1362,13 @@ function printTargetSurface(repo, target) {
1292
1362
  const overlayCounts = countOpenSpecOverlays(overlayPaths);
1293
1363
  const overlayDetail =
1294
1364
  surfaceStatus(overlayCounts) === "ok"
1295
- ? formatCount(overlayCounts, "apply/archive skills and commands")
1365
+ ? formatCount(overlayCounts, `${overlayActionLabel()} skills and commands`)
1296
1366
  : `${formatCount(
1297
1367
  overlayCounts,
1298
- "apply/archive skills and commands"
1368
+ `${overlayActionLabel()} skills and commands`
1299
1369
  )}; ${overlayRemediation(target)}`;
1300
1370
  printDoctorLine(
1301
- "Keel apply/archive overlay",
1371
+ `Keel ${overlayActionLabel()} overlay`,
1302
1372
  surfaceStatus(overlayCounts),
1303
1373
  overlayDetail
1304
1374
  );
@@ -1318,12 +1388,28 @@ function runDoctor(options) {
1318
1388
  const repo = path.resolve(options.repo || process.cwd());
1319
1389
  process.stdout.write(`keel doctor for ${repo}\n`);
1320
1390
 
1321
- const python = pythonCandidates().find(commandExists);
1322
- printDoctorLine(
1323
- "python3",
1324
- python ? "ok" : "missing",
1325
- python ? formatCommand(python.command, python.prefixArgs) : "set KEEL_PYTHON or install Python 3"
1326
- );
1391
+ // The version is printed, not just a verdict: `ok` with no number behind it
1392
+ // is a claim the reader cannot check, and it was the wrong claim here for as
1393
+ // long as this line existed.
1394
+ const minimumPython = MINIMUM_PYTHON.join(".");
1395
+ const interpreter = resolveInterpreter();
1396
+ if (interpreter.candidate) {
1397
+ printDoctorLine(
1398
+ "python3",
1399
+ "ok",
1400
+ `${formatCommand(interpreter.candidate.command, interpreter.candidate.prefixArgs)}`
1401
+ + ` (${interpreter.version})`
1402
+ );
1403
+ } else {
1404
+ const runnable = interpreter.tried.filter((entry) => entry.version);
1405
+ printDoctorLine(
1406
+ "python3",
1407
+ runnable.length > 0 ? "problem" : "missing",
1408
+ runnable.length > 0
1409
+ ? `needs ${minimumPython} or newer; found ${describeTried(runnable)}`
1410
+ : `needs ${minimumPython} or newer; set KEEL_PYTHON or install Python 3`
1411
+ );
1412
+ }
1327
1413
 
1328
1414
  const openspec = findOpenSpecCommand();
1329
1415
  if (!openspec) {
@@ -1339,12 +1425,26 @@ function runDoctor(options) {
1339
1425
  stdio: "ignore",
1340
1426
  silentNotFound: true,
1341
1427
  }) === 0;
1428
+ const resolvedVersion = openspecReportedVersion(openspec);
1429
+ const locked = lockedOpenSpecVersion();
1430
+ const mismatched = Boolean(
1431
+ resolvedVersion && locked && resolvedVersion !== locked
1432
+ );
1433
+ const where = bareOpenSpecOnPath
1434
+ ? openspec
1435
+ : `${openspec} is keel-resolvable but bare \`openspec\` is not on PATH — use \`keel openspec\``;
1436
+ const versions = `${resolvedVersion || "version unreadable"}, lockfile ${
1437
+ locked || "unreadable"
1438
+ }`;
1342
1439
  printDoctorLine(
1343
1440
  "openspec",
1344
- bareOpenSpecOnPath ? "ok" : "warning",
1345
- bareOpenSpecOnPath
1346
- ? openspec
1347
- : `${openspec} is keel-resolvable but bare \`openspec\` is not on PATH use \`keel openspec\``
1441
+ mismatched || !bareOpenSpecOnPath ? "warning" : "ok",
1442
+ mismatched
1443
+ ? `${where} (${versions}) — validation is answering from a different `
1444
+ + "build than this repository pins, which is what a green pipeline "
1445
+ + "and a red worktree look like. Keel reports which one answered "
1446
+ + "and selects none."
1447
+ : `${where} (${versions})`
1348
1448
  );
1349
1449
  }
1350
1450
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.7.0",
5
+ "version": "5.14.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.7.0",
3
+ "version": "5.14.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.7.0",
3
+ "version": "5.14.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: keel-single-task-goal-claude
3
- description: Thin Claude activation adapter for one authorized OpenSpec task; single /goal within the 4,000-character budget with read-only subagent helpers only.
3
+ description: Thin Claude activation adapter for one authorized OpenSpec task; single /goal within the 4,000-character budget; the current agent keeps sole write authority.
4
4
  target: claude
5
5
  role: single-task-goal-activation
6
6
  ---
@@ -11,6 +11,7 @@ Thin Claude adapter for the `keel-run-single-task-goal` skill. It activates one
11
11
 
12
12
  - Activation: compile the view with `keel project goal --target claude --change C --task T --json`; the goal condition stays within the 4,000-character budget, and Keel refuses activation rather than omit Acceptance, fingerprint, or stop authority.
13
13
  - Evidence: Claude's evaluator is transcript-only, so surface every command result and gate outcome before any success claim; only `keel gate task-complete` plus the current agent's durable checkbox complete the task.
14
- - Helpers: Claude subagents are used only as bounded read-only evidence producers via `keel project helper`; they carry no write, delegation, acceptance, or completion authority, and their returns are accepted only after repository byte identity.
14
+ - Helpers: Claude subagents used as helpers are bounded read-only evidence producers via `keel project helper`; they carry no write, delegation, acceptance, or completion authority, and their returns are accepted only after repository byte identity.
15
+ - Delegates: where `delegation:` is declared and a guard manifest is active, a subagent may implement inside `Touch`. The current agent keeps sole write authority, re-runs each `M<n>` check itself before recording Evidence, and owns Review, gates, the checkbox, and completion.
15
16
  - Stop: terminate after one task on completion, drift, blocker, or premature native success; continuing requires a new explicit authorization and start fingerprint.
16
17
  - Fallback: with disabled hooks, managed policy, or missing trust, report and run the identical manual Keel loop.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: keel-single-task-goal-codex
3
- description: Thin Codex activation adapter for one authorized OpenSpec task; bounded goal execution with read-only subagent helpers only.
3
+ description: Thin Codex activation adapter for one authorized OpenSpec task; bounded goal execution; the current agent keeps sole write authority.
4
4
  target: codex
5
5
  role: single-task-goal-activation
6
6
  ---
@@ -10,7 +10,7 @@ role: single-task-goal-activation
10
10
  Thin Codex adapter for the `keel-run-single-task-goal` skill. It activates one bounded goal for exactly one authorized OpenSpec task and never introduces a scheduler, global stop hook, agent team, or cross-task authority.
11
11
 
12
12
  - Activation: compile the view with `keel project goal --target codex --change C --task T --json`, then follow the single-task goal lifecycle. Where no callable goal surface exists, surface the exact command and treat the capability as advisory.
13
- - Ownership: the current agent stays the sole writer and owns Review, gates, the checkbox, and completion; a native evaluator success never completes the task.
13
+ - Ownership: the current agent stays the sole holder of write authority and owns Review, gates, the checkbox, and completion; a native evaluator success never completes the task. A declared delegate writes only inside `Touch`, and the current agent re-runs each `M<n>` check before recording Evidence.
14
14
  - Helpers: Codex subagents are used only as bounded read-only evidence producers via `keel project helper`; they carry no write, delegation, acceptance, or completion authority, and their returns are accepted only after repository byte identity.
15
15
  - Stop: terminate after one task on completion, drift, blocker, or premature native success; continuing requires a new explicit authorization and start fingerprint.
16
16
  - Fallback: without a callable Codex surface, run the identical manual Keel loop.
@@ -109,6 +109,41 @@ function taskIsChecked(repo, manifest) {
109
109
  return Boolean(match && match[1].toLowerCase() === "x");
110
110
  }
111
111
 
112
+ // A path is not the file it names. `path.resolve` never follows a symbolic
113
+ // link, while the `cwd` the operating system reports usually already has, so
114
+ // one file could be spelled two ways and get two containment answers: measured
115
+ // as a write denied by its resolved path and allowed through a link to the
116
+ // same directory, which walked straight past the manifest. The guarded target
117
+ // is usually a file about to be created, so what can be resolved is its
118
+ // nearest existing ancestor; when nothing resolves, the unresolved path is
119
+ // what remains, which is exactly the comparison that shipped.
120
+ //
121
+ // `src/core/helper.js` answers the same question and must keep answering it
122
+ // the same way. It is deliberately not shared as an import: this hook is a
123
+ // standalone script the host executes, and depending on `src/core` would make
124
+ // the guard fail wherever the package layout differs from this repository's.
125
+ function realPathOrNearest(target) {
126
+ let current = path.resolve(target);
127
+ const trailing = [];
128
+ for (;;) {
129
+ try {
130
+ return path.join(fs.realpathSync(current), ...trailing);
131
+ } catch {
132
+ const parent = path.dirname(current);
133
+ if (parent === current) return path.resolve(target);
134
+ trailing.unshift(path.basename(current));
135
+ current = parent;
136
+ }
137
+ }
138
+ }
139
+
140
+ function repoRelative(repo, target) {
141
+ return path.relative(
142
+ realPathOrNearest(repo),
143
+ realPathOrNearest(path.resolve(repo, target))
144
+ );
145
+ }
146
+
112
147
  function main() {
113
148
  let event = {};
114
149
  try {
@@ -130,8 +165,13 @@ function main() {
130
165
  // guard never protected; that ordering has already failed twice.
131
166
  const target = event.tool_input ? event.tool_input[pathField] : null;
132
167
  if (typeof target !== "string" || !target) return 0;
133
- const relative = path.relative(repo, path.resolve(repo, target));
134
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
168
+ const relative = repoRelative(repo, target);
169
+ if (
170
+ !relative
171
+ || relative === ".."
172
+ || relative.startsWith(`..${path.sep}`)
173
+ || path.isAbsolute(relative)
174
+ ) {
135
175
  return 0;
136
176
  }
137
177
  const candidate = relative.replace(/\\/g, "/");
@@ -154,6 +194,29 @@ function main() {
154
194
  return 0;
155
195
  }
156
196
  const pointer = `${manifest.change}#${manifest.task}`;
197
+ // Two states used to arrive at the same place, and they need different
198
+ // answers. A task id absent from a live `tasks.md` is a parse miss the guard
199
+ // must not guess about — see `taskIsChecked`. A change directory that is not
200
+ // there at all is a fact: the manifest points at work that has been archived
201
+ // or removed, and `keel guard status` already classifies it as drifted.
202
+ //
203
+ // Untreated, the archived case was wrong in both directions. A write outside
204
+ // the stale Touch was denied by a message naming a task in an archived change
205
+ // and telling the reader to reauthorize it, which cannot be done; a write
206
+ // *inside* it passed silently, so an archived task went on granting write
207
+ // authority. Denying here fixes both, and denying rather than allowing is
208
+ // what stops `openspec archive` from quietly disabling the guard.
209
+ const changeDir = path.join(repo, "openspec", "changes", manifest.change);
210
+ if (!fs.existsSync(changeDir)) {
211
+ deny(
212
+ `Keel write guard: this manifest is stale — it guards ${pointer}, but `
213
+ + `openspec/changes/${manifest.change} no longer exists, so the task it `
214
+ + "names cannot be reauthorized and its Touch list authorizes nothing. "
215
+ + "File edits fail closed. Run `keel guard clear`, then start the task "
216
+ + "you are actually working on with `keel gate task-start`."
217
+ );
218
+ return 0;
219
+ }
157
220
  // The record layer: the guarded change's own directory holds the records the
158
221
  // task produces — its checkbox, Evidence, and Review — not the product it
159
222
  // changes. `keel gate task-complete` already refuses to attribute this
@@ -108,6 +108,88 @@ function precedentPointer(cwd) {
108
108
  }
109
109
  }
110
110
 
111
+ // The remedy is the host's, so it is named rather than run. Only Claude's
112
+ // command is stated outright, because that is the manifest whose host command
113
+ // Keel has verified; an unprobed target gets a description instead of an
114
+ // invented command line.
115
+ const HOST_UPDATE = "your host's own plugin update command";
116
+
117
+ // The plugin's own manifest sits beside this script, so its version is read
118
+ // from `__dirname` rather than from CLAUDE_PLUGIN_ROOT: the path this file was
119
+ // loaded from is a fact, while an environment variable is a claim the host may
120
+ // not have made. Whichever target's manifest is present is the one that ran.
121
+ function pluginManifest() {
122
+ const targets = [
123
+ [".claude-plugin", "`claude plugin update`"],
124
+ [".codex-plugin", HOST_UPDATE],
125
+ ];
126
+ for (const [dir, remedy] of targets) {
127
+ try {
128
+ const manifest = path.join(__dirname, "..", dir, "plugin.json");
129
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8")).version;
130
+ if (typeof value === "string" && value.trim()) {
131
+ return { version: value.trim(), remedy };
132
+ }
133
+ } catch {
134
+ // A missing or unreadable manifest is undiscoverable, not drift.
135
+ }
136
+ }
137
+ return { version: null, remedy: HOST_UPDATE };
138
+ }
139
+
140
+ // The repository states which protocol it runs in the managed block the
141
+ // installer wrote. AGENTS.md is the canonical carrier; CLAUDE.md is read second
142
+ // because a repository may carry only the target-native file.
143
+ function protocolVersion(cwd) {
144
+ for (const name of ["AGENTS.md", "CLAUDE.md"]) {
145
+ try {
146
+ const text = fs.readFileSync(path.join(cwd, name), "utf8");
147
+ const match = text.match(
148
+ /<!--\s*keel:start\s+version=(\d+\.\d+\.\d+)\s*-->/
149
+ );
150
+ if (match) return match[1];
151
+ } catch {
152
+ // Same as above: absent is not mismatched.
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ // Three versions are comparable in any repository: the plugin executing this
159
+ // hook, the CLI it just invoked, and the protocol version the repository
160
+ // stamped into its managed block. Keel reports the disagreement and stops
161
+ // there — installing and updating are the host's, which already has commands
162
+ // for both.
163
+ function versionReport(cwd, cli) {
164
+ const plugin = pluginManifest();
165
+ const found = [
166
+ ["plugin", plugin.version],
167
+ ["CLI", cli],
168
+ ["protocol", protocolVersion(cwd)],
169
+ ];
170
+ // Missing is not mismatched. A version nobody can discover never produces a
171
+ // line on its own, or a repository with no managed block would be warned at
172
+ // every session until its reader stopped looking — and fewer than two
173
+ // readable versions is not agreement either, it is nothing to compare.
174
+ const known = found.filter(([, value]) => value);
175
+ if (known.length < 2) return null;
176
+ // Silence when they agree. This line exists to be noticed, and one printed
177
+ // every session stops being read long before the session it mattered in.
178
+ if (new Set(known.map(([, value]) => value)).size === 1) return null;
179
+ const named = known.map(([name, value]) => `${name} ${value}`).join(", ");
180
+ // Naming what was never read is what keeps a partial comparison from being
181
+ // read as a complete one.
182
+ const unread = found
183
+ .filter(([, value]) => !value)
184
+ .map(([name]) => name)
185
+ .join(" and ");
186
+ const missing = unread ? ` (${unread} undiscovered, not compared)` : "";
187
+ return `runtime versions disagree: ${named}${missing}. A session's hooks are `
188
+ + "fixed at session start, so an updated plugin applies only after "
189
+ + `restarting. Updating is ${plugin.remedy}, which Keel names and does `
190
+ + "not run.";
191
+ }
192
+
111
193
  // The Keel mark. A keel is the carina, the ridge on a bird's sternum, so the
112
194
  // animal that literally has one is a bird. Every cell is drawn from
113
195
  // U+2580–U+259F — the same block-element family as the host's own startup
@@ -293,6 +375,11 @@ function main() {
293
375
  + "does not guess among candidates."
294
376
  );
295
377
  }
378
+ const drift = versionReport(cwd, versionMatch[0]);
379
+ if (drift) {
380
+ lines.push(`- ${drift}`);
381
+ human.splice(human.length - 1, 0, drift[0].toUpperCase() + drift.slice(1));
382
+ }
296
383
  const pointer = precedentPointer(cwd);
297
384
  if (pointer) lines.push(`- ${pointer}`);
298
385
  lines.push(`- report this state ${DISCLOSURE}; it authorizes nothing.`);
@@ -21,6 +21,8 @@ Read the selected OpenSpec proposal, design, specs, tasks, diff, and command evi
21
21
  - Confirm the task's Evidence `Contract` line records the task-start capsule fingerprint and that completion recompiled the same fingerprint; a drift result returns the task to authoring for explicit reauthorization instead of review.
22
22
  - A behavioral task's checks must prove observable Acceptance through the public interface, not build-only or shape-only evidence.
23
23
  - For a red-green strategy (`vertical-tdd`, `regression-first`), confirm concrete per-label `.red` and `.green` Evidence exists for the same check; evidence-first tasks instead name their observable proof.
24
+ - A failure message must **name the actual cause** of what it reports. Watch for one condition guarding **two distinct failures** — `if result is None or result["status"] != expected` reports the first failure's message when the second one happened, sending the reader to a place with no problem in it. Split the condition. No gate can judge this: deciding whether a sentence misleads needs a model, so it stays here.
25
+ - When two tasks in the change declared the same Touch set under a red-green strategy — `keel gate task-start` warns about this — ask whether they turned out to be **one behavior** split in half. The tell is that the first task's minimal implementation was wrong in the field, or that the second had no honest red left because the first already made its checks pass. The gate can only see the shape; by completion you can see the outcome, which is the only point at which this is answerable.
24
26
  - When no trustworthy explicit Git base exists, do not attribute dirty paths automatically. Review scope semantically.
25
27
  - For `Coupling: required`, confirm one complete candidate reached its completion gate and generated artifacts are aligned.
26
28
 
@@ -31,7 +33,22 @@ Record the current agent's judgment inside the selected task Evidence:
31
33
  - `Status`: `pass` only when the task is ready to complete.
32
34
  - `Acceptance check`: why the behavior evidence proves the authored Acceptance.
33
35
  - `Scope check`: why the actual changes stay within Touch; identify an explicit base if deterministic comparison was used.
34
- - `Findings`: `none`, or each unresolved finding with a durable OpenSpec task/new change, archive-evidence owner, or explicit discard rationale.
36
+ - `Findings`: `none`, or every finding with the disposition it actually has.
37
+
38
+ A finding has three, and the criterion is what the task did about it — not
39
+ whichever marker gets past the gate:
40
+
41
+ - **`Resolved here:`** — found and fixed inside this task. Name what proves it:
42
+ an `M<n>` check this task declares, or a repo-relative path that exists. If no
43
+ check covers it, the fix is not proved and the finding is one of the other two.
44
+ - **`Durable owner:`** — real, still open, and someone must do it. Name an
45
+ absolute `https://…` tracker reference or a repo-relative path that exists.
46
+ The reference must already carry the content it claims to hold.
47
+ - **`Discard reason:`** — considered, and deliberately not being done. Say why.
48
+
49
+ Picking the marker that passes rather than the one that is true is how a repair
50
+ gets filed as a dismissal, and the archive then records the opposite of what
51
+ happened.
35
52
 
36
53
  The Review remains in tasks.md. A user-facing Report summarizes delivery but is not hidden gate state. Do not let Core or this checklist write evidence automatically.
37
54
 
@@ -43,6 +60,13 @@ When the change's artifacts or Touch extensions signal a domain, consult the mat
43
60
 
44
61
  Each related critical expectation needs behavior evidence, a durable follow-up owner, or an explicit discard reason. Relevant `D<n>`, `F<n>`, `A<n>`, and `Q<n>` references in Covers must agree with their OpenSpec basis and resolution owner. Unresolved authority returns to OpenSpec authoring.
45
62
 
63
+ A durable owner declared as a URL must **already carry the content** it claims to hold, at the moment
64
+ it is cited. A valid link to an empty issue owns nothing: create the content, then reference it. Check
65
+ this **when it is cited**, not at archive — a check deferred to the close finds the same fact after
66
+ the reauthorization it should have prevented. This is **not a deterministic gate check** and must not
67
+ become one: a gate that fetched a reference would stop being local and offline, which is the property
68
+ its verdict rests on.
69
+
46
70
  `keel/HANDOFF.md` is an optional pointer override and cannot own findings, critical expectation state, evidence details, or follow-ups.
47
71
 
48
72
  ## Skill change review