@christang/keel 5.14.0 → 5.16.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.16.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.16.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.16.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.16.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
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.14.0"
41
- PROTOCOL_VERSION = "5.14.0"
40
+ PACKAGE_VERSION = "5.16.0"
41
+ PROTOCOL_VERSION = "5.16.0"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
44
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -5594,6 +5594,146 @@ def validate_runtime_versions_are_checked_scenario() -> int:
5594
5594
  return 0
5595
5595
 
5596
5596
 
5597
+ def validate_doctor_reads_the_diagnosed_repository_scenario() -> int:
5598
+ """Issue #57: doctor named one repository and answered about another.
5599
+
5600
+ The OpenSpec declaration was read from `PACKAGE_ROOT/package-lock.json` —
5601
+ the directory Keel is installed in — while the line above it says
5602
+ `keel doctor for <repo>`. The two coincide only when `node bin/keel.js`
5603
+ runs from Keel's own checkout, which is the one arrangement every existing
5604
+ scenario uses: `run_keel` always spawns `node <ROOT>/bin/keel.js`, so
5605
+ `PACKAGE_ROOT` is Keel's checkout no matter what `cwd` is. Driving the
5606
+ diagnosis at a repository that declares its own version is what separates
5607
+ the two roots without needing a second installation.
5608
+ """
5609
+ label = "doctor-reads-the-diagnosed-repository"
5610
+
5611
+ def openspec_line(stdout: str) -> str:
5612
+ return next(
5613
+ (line for line in stdout.splitlines() if line.startswith("openspec:")),
5614
+ "",
5615
+ )
5616
+
5617
+ # M1 first half — a repository that declares a version Keel's own tree does
5618
+ # not. The sentinel is deliberately one no published OpenSpec reports, so a
5619
+ # line carrying it cannot have come from the resolved binary either.
5620
+ declared = "0.0.1"
5621
+ with tempfile.TemporaryDirectory(prefix="keel-doctor-declares-") as raw:
5622
+ declares = Path(raw).resolve()
5623
+ write_text(
5624
+ declares / "package-lock.json",
5625
+ json.dumps(
5626
+ {
5627
+ "name": "consumer",
5628
+ "lockfileVersion": 3,
5629
+ "packages": {
5630
+ "node_modules/@fission-ai/openspec": {"version": declared}
5631
+ },
5632
+ },
5633
+ indent=2,
5634
+ )
5635
+ + "\n",
5636
+ )
5637
+ doctor = run_keel(declares, "--doctor")
5638
+ line = openspec_line(doctor.stdout or "")
5639
+ if not line:
5640
+ report(f"{label} M1 `keel --doctor` emitted no openspec line.")
5641
+ report((doctor.stdout or doctor.stderr or "").strip())
5642
+ return 1
5643
+ own_lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8"))
5644
+ own_declared = None
5645
+ for name, entry in own_lock.get("packages", {}).items():
5646
+ if name.endswith("@fission-ai/openspec"):
5647
+ own_declared = entry.get("version")
5648
+ if not own_declared:
5649
+ report(f"{label} could not read Keel's own declared OpenSpec version.")
5650
+ return 1
5651
+ # The reader sees two versions on this line: the one the resolved binary
5652
+ # reports and the one the repository declares. Assert on the declared
5653
+ # POSITION, not on bare presence — the resolved binary's version may
5654
+ # legitimately equal Keel's own declared version, and a test that reads
5655
+ # any occurrence cannot tell the two apart.
5656
+ # Two distinct failures, two conditions. A line carrying the right
5657
+ # version with no attribution and a line attributing the wrong version
5658
+ # need different fixes, and one message covering both would send half
5659
+ # its readers to a place with no problem in it.
5660
+ if "repo pins" not in line:
5661
+ report(
5662
+ f"{label} M1 the openspec line does not attribute any declared "
5663
+ "version to the repository, so a reader seeing two versions "
5664
+ "cannot tell which one is theirs."
5665
+ )
5666
+ report(f" {line}")
5667
+ return 1
5668
+ if f"repo pins {declared}" not in line:
5669
+ report(
5670
+ f"{label} M1 the openspec line attributes a declared version "
5671
+ f"to the repository, but not {declared}, which is what this "
5672
+ "repository declares. It is answering about some other "
5673
+ "repository."
5674
+ )
5675
+ report(f" {line}")
5676
+ return 1
5677
+ if own_declared != declared and f"repo pins {own_declared}" in line:
5678
+ report(
5679
+ f"{label} M1 the openspec line reports {own_declared} as the "
5680
+ "declared version. That is what is declared where Keel is "
5681
+ "installed, not where it was pointed."
5682
+ )
5683
+ report(f" {line}")
5684
+ return 1
5685
+ if not line.startswith("openspec: warning"):
5686
+ report(
5687
+ f"{label} M1 the resolved binary and the declared version "
5688
+ f"disagree ({declared} is declared and no release reports it), "
5689
+ "and the line does not state the disagreement."
5690
+ )
5691
+ report(f" {line}")
5692
+ return 1
5693
+
5694
+ # M1 second half — absence is a statement about the repository, not a read
5695
+ # failure, and not a disagreement. Most repositories do not depend on
5696
+ # OpenSpec at all, so warning here would train readers to ignore the line.
5697
+ with tempfile.TemporaryDirectory(prefix="keel-doctor-silent-") as raw:
5698
+ silent = Path(raw).resolve()
5699
+ write_text(silent / "README.md", "# consumer\n")
5700
+ doctor = run_keel(silent, "--doctor")
5701
+ line = openspec_line(doctor.stdout or "")
5702
+ if not line:
5703
+ report(f"{label} M1 `keel --doctor` emitted no openspec line.")
5704
+ report((doctor.stdout or doctor.stderr or "").strip())
5705
+ return 1
5706
+ if "declares no OpenSpec" not in line:
5707
+ report(
5708
+ f"{label} M1 a repository declaring no OpenSpec version is not "
5709
+ "reported as declaring none."
5710
+ )
5711
+ report(f" {line}")
5712
+ return 1
5713
+ if "unreadable" in line:
5714
+ report(
5715
+ f"{label} M1 the absence of a declaration is reported as a "
5716
+ "failure to read one. The two are different facts and only one "
5717
+ "of them is true here."
5718
+ )
5719
+ report(f" {line}")
5720
+ return 1
5721
+ if "answering from a different" in line:
5722
+ report(
5723
+ f"{label} M1 a repository declaring nothing is reported as "
5724
+ "disagreeing with the resolved binary. There is nothing for it "
5725
+ "to disagree with."
5726
+ )
5727
+ report(f" {line}")
5728
+ return 1
5729
+
5730
+ if label not in {name for name, _ in SCENARIOS}:
5731
+ report(f"{label}: the scenario registry does not include it.")
5732
+ return 1
5733
+ report(f"{label} scenario passed.")
5734
+ return 0
5735
+
5736
+
5597
5737
  TASK_SHAPE_TEMPLATE = """## 1. Work
5598
5738
 
5599
5739
  - [ ] 1.1 First half
@@ -6054,6 +6194,174 @@ def validate_interpreter_surfaces_agree_scenario() -> int:
6054
6194
  return 0
6055
6195
 
6056
6196
 
6197
+ def validate_default_completion_attributes_writes_scenario() -> int:
6198
+ """A write outside Touch has to fail the gate the author actually runs.
6199
+
6200
+ `attributionResult` refuses an out-of-Touch path in full — diff, union with
6201
+ the dirty set, `pathAllowed` filter, `outside-touch` problem — behind
6202
+ `if (!base) return { problems: [] }`. Without `--base` the whole comparison
6203
+ is skipped and the paths are printed as a warning instead. Measured at
6204
+ 5.15.0 on one tree and one task: `pass` with the offending path in a
6205
+ warning, `fail` once `--base HEAD` was added.
6206
+
6207
+ It was found by it happening. During the 5.15.0 release task a file was
6208
+ written that the task had not declared, through a `python3` heredoc in
6209
+ Bash — the write guard binds the host's file-writing tools and cannot bind
6210
+ a shell — and `task-complete` returned `pass`. A human reading `git status`
6211
+ caught it. Nobody reads `git status` in an unattended run, and this
6212
+ repository admits work into unattended runs by declaration.
6213
+
6214
+ The base does not have to come from the caller. `task-start` already writes
6215
+ the manifest at the instant the task is authorized; recording what was
6216
+ dirty then answers "did this task write it" without asking Git to answer
6217
+ "which task wrote it", which is the question it cannot answer in a
6218
+ half-finished change.
6219
+ """
6220
+ label = "default-completion-attributes-writes"
6221
+
6222
+ def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
6223
+ return subprocess.run(
6224
+ ["git", "-C", str(repo), *args], capture_output=True, text=True
6225
+ )
6226
+
6227
+ def tasks_doc() -> str:
6228
+ return (
6229
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
6230
+ "- [ ] 1.1 Exercise task contract\n"
6231
+ " - Covers:\n - E1: Public behavior passes.\n"
6232
+ " - Touch:\n - src/declared.js\n"
6233
+ " - Verify:\n - Strategy: evidence-first\n"
6234
+ " - M1: node test.js asserts the recorded feed status\n"
6235
+ " - Evidence:\n - Contract: pending\n - M1: the suite passed\n"
6236
+ " - Review:\n - Status: pass\n"
6237
+ " - Acceptance check: behavior asserted at the interface\n"
6238
+ " - Scope check: only Touch files changed\n"
6239
+ " - Findings: none\n"
6240
+ " - Blocker: none\n"
6241
+ )
6242
+
6243
+ with tempfile.TemporaryDirectory(prefix="keel-default-attribution-") as raw:
6244
+ repo = (Path(raw) / "repo").resolve()
6245
+ repo.mkdir()
6246
+ git(repo, "init", "-q")
6247
+ git(repo, "config", "user.email", "t@example.com")
6248
+ git(repo, "config", "user.name", "keel-test")
6249
+ write_text(repo / "src/declared.js", "// product\n")
6250
+ write_text(repo / "src/undeclared.js", "// product\n")
6251
+ write_text(repo / "src/already-dirty.js", "// product\n")
6252
+ write_text(repo / "openspec/changes/demo/tasks.md", tasks_doc())
6253
+ git(repo, "add", "-A")
6254
+ git(repo, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "base")
6255
+
6256
+ def start(*extra: str) -> subprocess.CompletedProcess[str]:
6257
+ return run_keel(
6258
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
6259
+ "--record", *extra, "--json",
6260
+ )
6261
+
6262
+ def gate(*extra: str) -> dict:
6263
+ return json.loads(
6264
+ run_keel(
6265
+ repo, "gate", "task-complete", "--change", "demo",
6266
+ "--task", "1.1", *extra, "--json",
6267
+ ).stdout
6268
+ )
6269
+
6270
+ def outside(payload: dict) -> list[str]:
6271
+ return [
6272
+ problem.get("message", "")
6273
+ for problem in payload.get("problems", [])
6274
+ if problem.get("code") == "outside-touch"
6275
+ ]
6276
+
6277
+ # A path already dirty before the task is authorized. It must stay
6278
+ # unattributed afterwards: subtracting the start set is what removes
6279
+ # the false-positive class that made automatic attribution unsafe.
6280
+ write_text(repo / "src/already-dirty.js", "// touched before the task\n")
6281
+
6282
+ if start().returncode != 0:
6283
+ report(f"{label} could not authorize the fixture task.")
6284
+ return 1
6285
+
6286
+ # M1 — the reported defect. A write the guard never saw, and the gate
6287
+ # invoked the way an author actually invokes it.
6288
+ write_text(repo / "src/undeclared.js", "// written outside Touch\n")
6289
+ payload = gate()
6290
+ problems = outside(payload)
6291
+ if not any("src/undeclared.js" in message for message in problems):
6292
+ report(
6293
+ f"{label} M1 a path written outside Touch after task start was "
6294
+ "not refused by the default completion gate. The boundary "
6295
+ "holds only when the caller asks for it."
6296
+ )
6297
+ report(f" status={payload.get('status')!r}")
6298
+ for warning in payload.get("warnings", []):
6299
+ report(f" warning: {warning}")
6300
+ return 1
6301
+ if payload.get("status") != "fail":
6302
+ report(
6303
+ f"{label} M1 the gate named the out-of-Touch path but did not "
6304
+ f"fail; got status {payload.get('status')!r}. A boundary that "
6305
+ "reports without refusing is not a boundary."
6306
+ )
6307
+ return 1
6308
+ if any("src/already-dirty.js" in message for message in problems):
6309
+ report(
6310
+ f"{label} M1 a path that was already dirty when the task "
6311
+ "started was attributed to the task. The recorded set is not "
6312
+ "being subtracted, so an unrelated dirty worktree fails the "
6313
+ "gate."
6314
+ )
6315
+ return 1
6316
+
6317
+ # M1 — an explicit base answers the question the caller asked, which is
6318
+ # the broader one: everything since that commit, including what was
6319
+ # already dirty when the task started.
6320
+ base_problems = outside(gate("--base", "HEAD"))
6321
+ if not any("src/already-dirty.js" in message for message in base_problems):
6322
+ report(
6323
+ f"{label} M1 an explicit --base did not attribute a path that "
6324
+ "changed since that base, so the recorded set is overriding "
6325
+ "the base the caller supplied instead of yielding to it."
6326
+ )
6327
+ for message in base_problems:
6328
+ report(f" {message}")
6329
+ return 1
6330
+
6331
+ # M1 — no record means no attribution. A manifest written before this
6332
+ # existed, or a cleared one, must not be read as a clean start: absence
6333
+ # of a record is not a record of absence.
6334
+ run_keel(repo, "guard", "clear")
6335
+ if start("--no-guard").returncode != 0:
6336
+ report(f"{label} could not re-authorize without a manifest.")
6337
+ return 1
6338
+ payload = gate()
6339
+ if outside(payload):
6340
+ report(
6341
+ f"{label} M1 the gate attributed paths with no recorded "
6342
+ "task-start set. A missing record is being read as a clean "
6343
+ "start, which fails every completion in a dirty repository."
6344
+ )
6345
+ for message in outside(payload):
6346
+ report(f" {message}")
6347
+ return 1
6348
+ if not any(
6349
+ "not attributed" in warning for warning in payload.get("warnings", [])
6350
+ ):
6351
+ report(
6352
+ f"{label} M1 with no record the gate neither attributed nor "
6353
+ "reported the dirty paths, so the fallback lost the semantic "
6354
+ "review evidence it is supposed to preserve."
6355
+ )
6356
+ return 1
6357
+
6358
+ if label not in {name for name, _ in SCENARIOS}:
6359
+ report(f"{label}: the scenario registry does not include it.")
6360
+ return 1
6361
+ report(f"{label} scenario passed.")
6362
+ return 0
6363
+
6364
+
6057
6365
  CJK_PATH = "src/摄影光影规划工具.js"
6058
6366
  SPACE_PATH = "src/has space.js"
6059
6367
  QUOTE_PATH = 'src/has"quote.js'
@@ -19286,10 +19594,18 @@ SCENARIOS: tuple = (
19286
19594
  "git-paths-carry-no-escaping",
19287
19595
  validate_git_paths_carry_no_escaping_scenario,
19288
19596
  ),
19597
+ (
19598
+ "default-completion-attributes-writes",
19599
+ validate_default_completion_attributes_writes_scenario,
19600
+ ),
19289
19601
  (
19290
19602
  "runtime-versions-are-checked",
19291
19603
  validate_runtime_versions_are_checked_scenario,
19292
19604
  ),
19605
+ (
19606
+ "doctor-reads-the-diagnosed-repository",
19607
+ validate_doctor_reads_the_diagnosed_repository_scenario,
19608
+ ),
19293
19609
  ("task-shape-warning", validate_task_shape_warning_scenario),
19294
19610
  ("context-names-its-keel", validate_context_names_its_keel_scenario),
19295
19611
  ("interpreter-surfaces-agree", validate_interpreter_surfaces_agree_scenario),
package/src/core/gates.js CHANGED
@@ -14,7 +14,7 @@ const {
14
14
  isPassingReviewStatus,
15
15
  parseTasks,
16
16
  } = require("./task-contract");
17
- const { startGuard } = require("./guard");
17
+ const { gitPaths, readManifest, startGuard } = require("./guard");
18
18
 
19
19
  const GATE_STAGES = new Set(["task-start", "task-complete", "change-close"]);
20
20
 
@@ -483,33 +483,21 @@ function findingOwnerIsDurable(repo, findings) {
483
483
  // decoder — and it is a flag rather than a repository setting, so the answer
484
484
  // does not depend on how the repository happens to be configured.
485
485
  //
486
- // Nothing rewrites backslashes here any more. Git emits forward slashes on
487
- // every platform, so the rewrite normalized a separator that never arrives
488
- // while turning `\346` into `/346`, which is how a path declared on the first
489
- // line of Touch was reported as outside Touch (issue #40).
490
- function gitPaths(repo) {
491
- const status = spawnSync(
492
- "git",
493
- ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
494
- { cwd: repo, encoding: "utf8" }
495
- );
496
- if (status.error || status.status !== 0) return [];
497
- // Each record is `XY <path>`, NUL-terminated. A rename or copy is followed
498
- // by a second bare field holding its other endpoint — the new path first in
499
- // `-z`, the reverse of the ` -> ` line format. The order is immaterial:
500
- // both endpoints are attributed, so a rename whose paths are both in Touch
501
- // is not a false outside-Touch failure.
502
- const fields = status.stdout.split("\0").filter(Boolean);
503
- const paths = [];
504
- for (let index = 0; index < fields.length; index += 1) {
505
- const record = fields[index];
506
- paths.push(record.slice(3));
507
- if (record[0] === "R" || record[0] === "C") {
508
- index += 1;
509
- if (index < fields.length) paths.push(fields[index]);
510
- }
511
- }
512
- return paths;
486
+ // `gitPaths` moved to guard.js, which owns the worktree reading now that the
487
+ // task-start record and this comparison must use the same one.
488
+
489
+ // The dirty set recorded when this task was authorized, or null when nobody
490
+ // recorded one. Null and empty are different answers: an empty list says
491
+ // nothing was dirty at task start, and null says no record exists, which is
492
+ // what a manifest written before this field, a cleared guard, or a
493
+ // `--no-guard` start all produce. Reading null as empty would attribute the
494
+ // whole worktree to the task and fail every completion in a dirty repository.
495
+ function recordedBaseline(repo, change, task) {
496
+ const loaded = readManifest(repo);
497
+ if (loaded.state !== "ok") return null;
498
+ const manifest = loaded.manifest;
499
+ if (manifest.change !== change || manifest.task !== task) return null;
500
+ return Array.isArray(manifest.startedDirty) ? manifest.startedDirty : null;
513
501
  }
514
502
 
515
503
  function touchEntries(task, contract = null) {
@@ -570,7 +558,15 @@ function scopeEvidence(
570
558
  tasks = null
571
559
  ) {
572
560
  const dirtyPaths = gitPaths(repo);
573
- if (!base) {
561
+ // An explicit base wins. It asks a broader question than the record does —
562
+ // everything since that commit, not only since this task started — and
563
+ // substituting the narrower answer would make `--base` mean something other
564
+ // than what it says.
565
+ const baseline = base ? null : recordedBaseline(repo, change, task.id);
566
+ if (!base && !baseline) {
567
+ // No base and no record: the original conservatism, and the reason for it
568
+ // is unchanged. Git alone cannot say which task of a half-finished change
569
+ // wrote a given path, so the dirty state stays semantic review evidence.
574
570
  return {
575
571
  problems: [],
576
572
  warnings:
@@ -583,6 +579,27 @@ function scopeEvidence(
583
579
  };
584
580
  }
585
581
 
582
+ if (!base) {
583
+ // Dirty now and not dirty when the task started. This answers "did this
584
+ // task write it", which is the question the boundary actually asks; it
585
+ // does not answer "which task wrote it", which is why the completed-
586
+ // sibling exclusion below still applies and still reports itself.
587
+ //
588
+ // A path already dirty at task start is subtracted even if the task also
589
+ // modified it. That is the price of a baseline that is not a commit, and
590
+ // it buys the far larger class this exists to avoid: failing every
591
+ // completion in a worktree that was dirty before the task began.
592
+ const startedDirty = new Set(baseline);
593
+ return attributeChanged(
594
+ repo,
595
+ task,
596
+ dirtyPaths.filter((item) => !startedDirty.has(item)),
597
+ contract,
598
+ change,
599
+ tasks
600
+ );
601
+ }
602
+
586
603
  const verified = spawnSync(
587
604
  "git",
588
605
  ["rev-parse", "--verify", `${base}^{commit}`],
@@ -601,10 +618,23 @@ function scopeEvidence(
601
618
  if (diff.error || diff.status !== 0) {
602
619
  throw new GateInputError(`could not compare Git base: ${base}`);
603
620
  }
604
- const changed = new Set([
605
- ...diff.stdout.split("\0").filter(Boolean),
606
- ...dirtyPaths,
607
- ]);
621
+ return attributeChanged(
622
+ repo,
623
+ task,
624
+ [...diff.stdout.split("\0").filter(Boolean), ...dirtyPaths],
625
+ contract,
626
+ change,
627
+ tasks
628
+ );
629
+ }
630
+
631
+ // The one place a candidate path becomes a problem, whichever comparison
632
+ // produced it. Both callers reach it: the recorded-baseline path and the
633
+ // explicit-base path differ only in how they decide which paths are
634
+ // candidates, and a second copy of this would be a second definition of what
635
+ // Touch means.
636
+ function attributeChanged(repo, task, changedList, contract, change, tasks) {
637
+ const changed = new Set(changedList);
608
638
  const touch = touchEntries(task, contract);
609
639
  // The disposable guard manifest is the one artifact the gate contract itself
610
640
  // permits a gate to write, and the selected change's own authoring artifacts
package/src/core/guard.js CHANGED
@@ -9,8 +9,44 @@
9
9
  const crypto = require("crypto");
10
10
  const fs = require("fs");
11
11
  const path = require("path");
12
+ const { spawnSync } = require("child_process");
12
13
  const { loadTaskContract } = require("./task-contract");
13
14
 
15
+ // Nothing rewrites backslashes here. Git emits forward slashes on every
16
+ // platform, so the rewrite normalized a separator that never arrives while
17
+ // turning `\346` into `/346`, which is how a path declared on the first line
18
+ // of Touch was reported as outside Touch (issue #40).
19
+ //
20
+ // This lives here rather than in gates.js because both the task-start record
21
+ // and the completion comparison read it, and gates.js already requires this
22
+ // module. One implementation is the point: a baseline and a comparison that
23
+ // disagreed about what "dirty" means, or about how a rename is represented,
24
+ // would attribute a path nobody wrote.
25
+ function gitPaths(repo) {
26
+ const status = spawnSync(
27
+ "git",
28
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
29
+ { cwd: repo, encoding: "utf8" }
30
+ );
31
+ if (status.error || status.status !== 0) return [];
32
+ // Each record is `XY <path>`, NUL-terminated. A rename or copy is followed
33
+ // by a second bare field holding its other endpoint — the new path first in
34
+ // `-z`, the reverse of the ` -> ` line format. The order is immaterial:
35
+ // both endpoints are attributed, so a rename whose paths are both in Touch
36
+ // is not a false outside-Touch failure.
37
+ const fields = status.stdout.split("\0").filter(Boolean);
38
+ const paths = [];
39
+ for (let index = 0; index < fields.length; index += 1) {
40
+ const record = fields[index];
41
+ paths.push(record.slice(3));
42
+ if (record[0] === "R" || record[0] === "C") {
43
+ index += 1;
44
+ if (index < fields.length) paths.push(fields[index]);
45
+ }
46
+ }
47
+ return paths;
48
+ }
49
+
14
50
  const MANIFEST_SCHEMA = "keel-write-guard/v1";
15
51
 
16
52
  class GuardInputError extends Error {}
@@ -121,6 +157,18 @@ function readManifest(repo) {
121
157
  ) {
122
158
  shapeErrors.push("authority must list hashed source files");
123
159
  }
160
+ // Optional on purpose. A manifest written before this field existed, and one
161
+ // written by a Keel that omits it, are both valid; what they are not is
162
+ // evidence that nothing was dirty. The consumer distinguishes absent from
163
+ // empty, so an empty list means "nothing was dirty" and an absent one means
164
+ // "nobody looked".
165
+ if (
166
+ manifest.startedDirty !== undefined
167
+ && (!Array.isArray(manifest.startedDirty)
168
+ || manifest.startedDirty.some((item) => typeof item !== "string"))
169
+ ) {
170
+ shapeErrors.push("startedDirty must be a string list when present");
171
+ }
124
172
  if (shapeErrors.length > 0) {
125
173
  return {
126
174
  state: "invalid",
@@ -182,6 +230,9 @@ function startGuard(repo, options) {
182
230
  }
183
231
 
184
232
  const paths = authorityPaths(repo, options.change, loaded.contract);
233
+ // Read before the manifest is written, so the manifest is never in its own
234
+ // record and cannot be attributed to the task it authorizes.
235
+ const startedDirty = gitPaths(repo);
185
236
  const manifest = {
186
237
  schema: MANIFEST_SCHEMA,
187
238
  change: options.change,
@@ -189,6 +240,7 @@ function startGuard(repo, options) {
189
240
  fingerprint: loaded.contract.fingerprint,
190
241
  touch: loaded.contract.capsule.touch,
191
242
  authority: hashAuthority(repo, paths),
243
+ startedDirty,
192
244
  };
193
245
  fs.mkdirSync(path.join(repo, "keel"), { recursive: true });
194
246
  fs.writeFileSync(
@@ -302,6 +354,7 @@ module.exports = {
302
354
  GuardInputError,
303
355
  MANIFEST_SCHEMA,
304
356
  clearGuard,
357
+ gitPaths,
305
358
  guardStatus,
306
359
  readManifest,
307
360
  renderGuard,