@christang/keel 5.14.0 → 5.20.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.14.0 -->
1
+ <!-- keel:start version=5.20.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.
package/bin/keel.js CHANGED
@@ -699,21 +699,35 @@ function openspecReportedVersion(command) {
699
699
  return match ? match[0] : null;
700
700
  }
701
701
 
702
- function lockedOpenSpecVersion() {
702
+ // The root is the repository under diagnosis, never PACKAGE_ROOT. Rooting this
703
+ // at Keel's own install location made the line a statement about a repository
704
+ // the reader was never shown: a consumer pinning 9.9.9 was told the version
705
+ // Keel's checkout pins, and a global install — which ships no lockfile — was
706
+ // told `unreadable` forever, which is exactly the drift this check was added
707
+ // to expose. There is no fallback to PACKAGE_ROOT on purpose; falling back
708
+ // would reinstate the misattribution and leave the reader unable to tell which
709
+ // case they were in.
710
+ //
711
+ // Three outcomes, not two. A repository with no lockfile and one whose lockfile
712
+ // names no OpenSpec both *declare* nothing, which is the ordinary case for any
713
+ // project that does not depend on OpenSpec directly. A lockfile that exists and
714
+ // cannot be parsed is a read failure. Collapsing them would either warn at
715
+ // everyone or hide a real failure.
716
+ function declaredOpenSpecVersion(repo) {
717
+ const lockPath = path.join(repo, "package-lock.json");
718
+ if (!fs.existsSync(lockPath)) return { state: "none", version: null };
719
+ let lock;
703
720
  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
- }
721
+ lock = JSON.parse(fs.readFileSync(lockPath, "utf8"));
712
722
  } 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.
723
+ return { state: "unreadable", version: null };
715
724
  }
716
- return null;
725
+ for (const [name, entry] of Object.entries(lock.packages || {})) {
726
+ if (name.endsWith("@fission-ai/openspec") && entry && entry.version) {
727
+ return { state: "declared", version: entry.version };
728
+ }
729
+ }
730
+ return { state: "none", version: null };
717
731
  }
718
732
 
719
733
  function findOpenSpecCommand() {
@@ -1426,16 +1440,24 @@ function runDoctor(options) {
1426
1440
  silentNotFound: true,
1427
1441
  }) === 0;
1428
1442
  const resolvedVersion = openspecReportedVersion(openspec);
1429
- const locked = lockedOpenSpecVersion();
1443
+ const declared = declaredOpenSpecVersion(repo);
1430
1444
  const mismatched = Boolean(
1431
- resolvedVersion && locked && resolvedVersion !== locked
1445
+ resolvedVersion
1446
+ && declared.state === "declared"
1447
+ && resolvedVersion !== declared.version
1432
1448
  );
1433
1449
  const where = bareOpenSpecOnPath
1434
1450
  ? openspec
1435
1451
  : `${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
- }`;
1452
+ // Two versions on one line need two owners. `repo` is the repository named
1453
+ // on doctor's first line; the answering build is attributed by the path
1454
+ // already printed beside it.
1455
+ const declaredText = {
1456
+ declared: `repo pins ${declared.version}`,
1457
+ none: "repo declares no OpenSpec version",
1458
+ unreadable: "repo package-lock.json unreadable",
1459
+ }[declared.state];
1460
+ const versions = `${resolvedVersion || "version unreadable"}, ${declaredText}`;
1439
1461
  printDoctorLine(
1440
1462
  "openspec",
1441
1463
  mismatched || !bareOpenSpecOnPath ? "warning" : "ok",
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.14.0",
5
+ "version": "5.20.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.14.0",
3
+ "version": "5.20.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.14.0",
3
+ "version": "5.20.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",
@@ -23,7 +23,7 @@ Read the selected OpenSpec proposal, design, specs, tasks, diff, and command evi
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
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
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.
26
- - When no trustworthy explicit Git base exists, do not attribute dirty paths automatically. Review scope semantically.
26
+ - Scope attribution is now the gate's job by default: `keel gate task-complete` compares the worktree against the dirty set `task-start` recorded and refuses a path outside Touch, so a `pass` is evidence about scope rather than silence about it. Two cases still need you. When the gate reports paths as *unattributed* — no recorded set, because the manifest predates the field or the guard was cleared — nothing was checked and the review is the only scope evidence. And a path already dirty when the task started is never attributed even if the task changed it again, so read the gate's silence about such a path as absence of a check rather than absence of a write.
27
27
  - For `Coupling: required`, confirm one complete candidate reached its completion gate and generated artifacts are aligned.
28
28
 
29
29
  ## Semantic Review
@@ -120,9 +120,19 @@ TASKS_COMMIT_STATUS_PATTERNS = (
120
120
  "remove branch merge state from tasks.md; git log is the source of truth",
121
121
  ),
122
122
  )
123
+ # A commit identifier is hexadecimal, and `a`-`f` are the only reason those
124
+ # letters appear in one. A run of decimal digits alone is a phone number, a
125
+ # timestamp, an order number, or a port — evidence prose, not state git owns.
126
+ # Matching one as a recorded identifier (#58) asks the author to reword
127
+ # something that was true, and a check that refuses correct work is one people
128
+ # learn to route around. The length and word-boundary conditions are kept
129
+ # verbatim inside the lookahead, so the token that matches is the token that
130
+ # always matched, minus the all-decimal ones.
131
+ _HASH_SHAPED_TOKEN = r"\b(?=[0-9a-f]{7,40}\b)[0-9a-f]*[a-f][0-9a-f]*\b"
132
+ _HASH_CONTEXT_WORD = r"(?:commit|提交|合入|master|main|HEAD|hash|哈希)"
123
133
  TASKS_CONTEXTUAL_HASH_RE = re.compile(
124
- r"(?i)(?:commit|提交|合入|master|main|HEAD|hash|哈希).*\b[0-9a-f]{7,40}\b|"
125
- r"\b[0-9a-f]{7,40}\b.*(?:commit|提交|合入|master|main|HEAD|hash|哈希)"
134
+ rf"(?i){_HASH_CONTEXT_WORD}.*{_HASH_SHAPED_TOKEN}|"
135
+ rf"{_HASH_SHAPED_TOKEN}.*{_HASH_CONTEXT_WORD}"
126
136
  )
127
137
 
128
138