@christang/keel 5.2.4 → 5.3.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.2.4 -->
1
+ <!-- keel:start version=5.3.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.
@@ -40,7 +40,11 @@
40
40
  - Blocker: none
41
41
 
42
42
  <!-- Exceptional boundaries are declared only when they differ from defaults:
43
- - Mode: diagnose-only (with `Touch: none`) or plan-first
43
+ - Mode: diagnose-only or repo-action (both with `Touch: none`), or plan-first.
44
+ repo-action is for a task whose whole effect is an authorized
45
+ repository-level action — a commit, a tag — and which writes no worktree
46
+ file; it is the one mode that may commit, and it still may not push,
47
+ sync, archive, or mark tasks complete
44
48
  - Read: additional required starting context beyond the base set
45
49
  - Acceptance: a task-specific observable delta the Covers authority does not express
46
50
  - Execution recommendation / Rationale: advisory notes for the current agent
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.2.4",
5
+ "version": "5.3.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.2.4",
3
+ "version": "5.3.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.2.4",
3
+ "version": "5.3.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",
@@ -84,6 +84,29 @@ function pathAllowed(candidate, touch) {
84
84
  });
85
85
  }
86
86
 
87
+ // A checked task keeps its records writable so it can finish the Evidence its
88
+ // completion gate requires, but earns no further product authorization. Byte
89
+ // hashing used to enforce this by accident, since ticking the box changed
90
+ // tasks.md; now it is stated. An unreadable or unmatched tasks.md is not read as
91
+ // checked — every gate that compiles the capsule catches that, and denying
92
+ // product writes on a parse miss would trade a real capability for a guess.
93
+ function taskIsChecked(repo, manifest) {
94
+ const file = path.join(
95
+ repo, "openspec", "changes", manifest.change, "tasks.md"
96
+ );
97
+ let content = "";
98
+ try {
99
+ content = fs.readFileSync(file, "utf8");
100
+ } catch {
101
+ return false;
102
+ }
103
+ const id = manifest.task.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
104
+ const match = content.match(
105
+ new RegExp(`^\\s*-\\s*\\[([ xX])\\]\\s*${id}(?![0-9.])`, "m")
106
+ );
107
+ return Boolean(match && match[1].toLowerCase() === "x");
108
+ }
109
+
87
110
  function main() {
88
111
  let event = {};
89
112
  try {
@@ -114,8 +137,30 @@ function main() {
114
137
  return 0;
115
138
  }
116
139
  const pointer = `${manifest.change}#${manifest.task}`;
140
+ // The record layer: the guarded change's own directory holds the records the
141
+ // task produces — its checkbox, Evidence, and Review — not the product it
142
+ // changes. `keel gate task-complete` already refuses to attribute this
143
+ // directory as an outside-Touch failure, so denying it here made the guard
144
+ // stop the one thing the completion gate is waiting for. Derived from the
145
+ // manifest's existing `change` field, so no manifest shape changes.
146
+ const recordPrefix = `openspec/changes/${manifest.change}/`;
147
+
148
+ const target = event.tool_input ? event.tool_input[pathField] : null;
149
+ if (typeof target !== "string" || !target) return 0;
150
+ const relative = path.relative(repo, path.resolve(repo, target));
151
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
152
+ return 0;
153
+ }
154
+ const candidate = relative.replace(/\\/g, "/");
155
+ if (candidate.startsWith(recordPrefix)) return 0;
117
156
 
118
157
  for (const entry of manifest.authority) {
158
+ // Same boundary: bytes under the guarded change's own directory are
159
+ // records, and the capsule fingerprint — which carries no checkbox state
160
+ // and no Evidence values — is what separates a record write from a
161
+ // contract change. `keel guard status` and `keel gate task-complete`
162
+ // compile the capsule and compare it; this hook cannot, and must not guess.
163
+ if (entry.path.replace(/\\/g, "/").startsWith(recordPrefix)) continue;
119
164
  const file = path.join(repo, entry.path);
120
165
  let fresh = null;
121
166
  try {
@@ -134,13 +179,17 @@ function main() {
134
179
  }
135
180
  }
136
181
 
137
- const target = event.tool_input ? event.tool_input[pathField] : null;
138
- if (typeof target !== "string" || !target) return 0;
139
- const relative = path.relative(repo, path.resolve(repo, target));
140
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
182
+ if (taskIsChecked(repo, manifest)) {
183
+ deny(
184
+ `Keel write guard: ${pointer} is checked complete, so it authorizes no `
185
+ + `further product writes ${candidate} is denied. Its own `
186
+ + `${recordPrefix} records stay writable so the task can finish its `
187
+ + "Evidence. Run `keel guard clear`, then authorize the next task "
188
+ + "explicitly with `keel gate task-start`."
189
+ );
141
190
  return 0;
142
191
  }
143
- const candidate = relative.replace(/\\/g, "/");
192
+
144
193
  if (pathAllowed(candidate, manifest.touch)) return 0;
145
194
 
146
195
  deny(
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.2.4"
41
- PROTOCOL_VERSION = "5.2.4"
40
+ PACKAGE_VERSION = "5.3.0"
41
+ PROTOCOL_VERSION = "5.3.0"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
44
  OPENSPEC_CONFIG_PATH = Path("openspec/config.yaml")
@@ -10367,6 +10367,294 @@ def expect_guard_allow(
10367
10367
  return 0
10368
10368
 
10369
10369
 
10370
+ RECORD_LAYER_SPEC = (
10371
+ "# demo-cap Specification\n\n"
10372
+ "## Purpose\n"
10373
+ "Fixture capability for the record-layer scenario.\n\n"
10374
+ "## Requirements\n"
10375
+ "### Requirement: Guarded behavior holds\n"
10376
+ "The guarded feature MUST keep its public behavior.\n\n"
10377
+ "#### Scenario: Guarded public behavior passes\n"
10378
+ "- **WHEN** the guarded feature runs\n"
10379
+ "- **THEN** it passes\n"
10380
+ )
10381
+
10382
+
10383
+ def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") -> str:
10384
+ box = "x" if checked else " "
10385
+ return (
10386
+ "# Tasks\n\n"
10387
+ f"- [{box}] 1.1 Exercise guarded feature\n"
10388
+ " - Covers:\n"
10389
+ " - demo-cap / Guarded behavior holds / Guarded public behavior passes\n"
10390
+ " - Touch:\n"
10391
+ f" - {touch}\n"
10392
+ " - Verify:\n"
10393
+ " - Strategy: evidence-first\n"
10394
+ " - M1: node test.js\n"
10395
+ " - Evidence:\n"
10396
+ " - M1: pending\n"
10397
+ )
10398
+
10399
+
10400
+ def mode_fixture_tasks(mode: str, touch: str) -> str:
10401
+ return (
10402
+ "# Tasks\n\n"
10403
+ "- [ ] 1.1 Establish the version baseline\n"
10404
+ f" - Mode: {mode}\n"
10405
+ " - Covers:\n"
10406
+ " - E1: the repository carries its first commit\n"
10407
+ " - Touch:\n"
10408
+ f" - {touch}\n"
10409
+ " - Verify:\n"
10410
+ " - Strategy: evidence-first\n"
10411
+ " - M1: git rev-parse HEAD resolves and git log reports one commit\n"
10412
+ " - Evidence:\n"
10413
+ " - Contract: pending\n"
10414
+ " - M1: pending\n"
10415
+ )
10416
+
10417
+
10418
+ def validate_repo_action_mode_scenario() -> int:
10419
+ """Issue #8 example 2: a repository action had no legal contract.
10420
+
10421
+ A task whose whole effect is the repository's first commit writes no
10422
+ worktree file, so it has no concrete Touch; it is not diagnose-only,
10423
+ because it has real side effects needing evidence; and `Touch: none` was
10424
+ accepted for no other mode. The author was forced to name a path the task
10425
+ did not write, which then tripped the drift defect in example 1.
10426
+ """
10427
+ label = "repo-action-mode"
10428
+
10429
+ def compile_task(repo: Path, mode: str, touch: str):
10430
+ write_text(repo / "openspec/changes/demo/tasks.md", mode_fixture_tasks(mode, touch))
10431
+ return run_keel(
10432
+ repo, "gate", "task-start",
10433
+ "--change", "demo", "--task", "1.1", "--no-guard", "--json",
10434
+ )
10435
+
10436
+ with tempfile.TemporaryDirectory(prefix="keel-repo-action-") as raw:
10437
+ repo = Path(raw)
10438
+
10439
+ started = compile_task(repo, "repo-action", "none")
10440
+ if started.returncode != 0:
10441
+ report(f"{label}: `Mode: repo-action` with `Touch: none` was rejected.")
10442
+ report((started.stderr or started.stdout).strip())
10443
+ return 1
10444
+ capsule = json.loads(started.stdout).get("contract", {}).get("capsule", {})
10445
+ prohibitions = capsule.get("prohibitions", [])
10446
+ if capsule.get("mode") != "repo-action":
10447
+ report(f"{label}: the capsule did not record the repo-action mode.")
10448
+ return 1
10449
+ if "must not write product files" not in prohibitions:
10450
+ report(
10451
+ f"{label}: repo-action must prohibit product writes; got "
10452
+ f"{prohibitions}."
10453
+ )
10454
+ return 1
10455
+ if "must not commit" in prohibitions:
10456
+ report(
10457
+ f"{label}: repo-action must be the mode that may commit; got "
10458
+ f"{prohibitions}."
10459
+ )
10460
+ return 1
10461
+
10462
+ # Every other mode keeps the commit prohibition.
10463
+ for mode, touch in (
10464
+ ("implementation", "src/feature.js"),
10465
+ ("plan-first", "src/feature.js"),
10466
+ ("diagnose-only", "none"),
10467
+ ):
10468
+ other = compile_task(repo, mode, touch)
10469
+ if other.returncode != 0:
10470
+ report(f"{label}: `Mode: {mode}` regressed and no longer compiles.")
10471
+ report((other.stderr or other.stdout).strip())
10472
+ return 1
10473
+ other_capsule = (
10474
+ json.loads(other.stdout).get("contract", {}).get("capsule", {})
10475
+ )
10476
+ if "must not commit" not in other_capsule.get("prohibitions", []):
10477
+ report(f"{label}: `Mode: {mode}` lost the commit prohibition.")
10478
+ return 1
10479
+ product_write_prohibited = (
10480
+ "must not write product files"
10481
+ in other_capsule.get("prohibitions", [])
10482
+ )
10483
+ if product_write_prohibited != (mode == "diagnose-only"):
10484
+ report(
10485
+ f"{label}: `Mode: {mode}` changed its product-write "
10486
+ "prohibition."
10487
+ )
10488
+ return 1
10489
+
10490
+ # repo-action means no worktree writes, so a concrete Touch contradicts it.
10491
+ with_touch = compile_task(repo, "repo-action", "src/feature.js")
10492
+ with_touch_problems = (
10493
+ json.loads(with_touch.stdout).get("problems", [])
10494
+ if with_touch.stdout
10495
+ else []
10496
+ )
10497
+ touch_message = " ".join(
10498
+ item.get("message", "")
10499
+ for item in with_touch_problems
10500
+ if item.get("code") == "invalid-touch"
10501
+ )
10502
+ if with_touch.returncode == 0 or "Touch: none" not in touch_message:
10503
+ report(
10504
+ f"{label}: repo-action with a concrete Touch must fail with a "
10505
+ "diagnostic naming the `Touch: none` it requires."
10506
+ )
10507
+ report((with_touch.stderr or with_touch.stdout).strip())
10508
+ return 1
10509
+
10510
+ unsupported = compile_task(repo, "repo-actions", "none")
10511
+ unsupported_message = " ".join(
10512
+ item.get("message", "")
10513
+ for item in (
10514
+ json.loads(unsupported.stdout).get("problems", [])
10515
+ if unsupported.stdout
10516
+ else []
10517
+ )
10518
+ if item.get("code") == "unsupported-mode"
10519
+ )
10520
+ missing = [
10521
+ mode
10522
+ for mode in ("implementation", "diagnose-only", "plan-first", "repo-action")
10523
+ if mode not in unsupported_message
10524
+ ]
10525
+ if unsupported.returncode == 0 or missing:
10526
+ report(
10527
+ f"{label}: the unsupported-mode diagnostic must list every "
10528
+ f"supported mode; missing {missing}."
10529
+ )
10530
+ report((unsupported.stderr or unsupported.stdout).strip())
10531
+ return 1
10532
+
10533
+ if label not in {name for name, _ in SCENARIOS}:
10534
+ report(f"{label}: the scenario registry does not include it.")
10535
+ return 1
10536
+ report(f"{label} scenario passed.")
10537
+ return 0
10538
+
10539
+
10540
+ def validate_touch_guard_record_layer_scenario() -> int:
10541
+ """Issue #8: the guard denied what the completion gate already forgives.
10542
+
10543
+ `scopeProblems` exempts the selected change's own `openspec/changes/<change>/`
10544
+ directory from outside-Touch attribution, but the guard denied writes there
10545
+ and treated the byte hash of that change's tasks.md as authority, so ticking
10546
+ a checkbox or appending Evidence locked the task out of its own bookkeeping.
10547
+ """
10548
+ label = "touch-guard-record-layer"
10549
+ with tempfile.TemporaryDirectory(prefix="keel-record-layer-") as raw:
10550
+ repo = Path(raw)
10551
+ tasks = repo / "openspec/changes/demo/tasks.md"
10552
+ spec = repo / "openspec/specs/demo-cap/spec.md"
10553
+ write_text(tasks, record_layer_tasks())
10554
+ write_text(spec, RECORD_LAYER_SPEC)
10555
+ write_text(repo / "openspec/changes/other/tasks.md", "# Tasks\n")
10556
+ write_text(repo / "keel/archive/follow-ups/note.md", "# Note\n")
10557
+
10558
+ started = run_keel(
10559
+ repo, "guard", "start", "--change", "demo", "--task", "1.1", "--json"
10560
+ )
10561
+ if started.returncode != 0:
10562
+ report(f"{label}: guard start failed on the fixture.")
10563
+ report((started.stderr or started.stdout).strip())
10564
+ return 1
10565
+ manifest = json.loads(started.stdout).get("manifest", {})
10566
+ authority = [entry.get("path") for entry in manifest.get("authority", [])]
10567
+ if not any(item == "openspec/specs/demo-cap/spec.md" for item in authority):
10568
+ report(
10569
+ f"{label}: the fixture does not record an authority file outside "
10570
+ f"the change directory, so the negative case is untested: {authority}"
10571
+ )
10572
+ return 1
10573
+
10574
+ # The record layer: writable although Touch never named it.
10575
+ if expect_guard_allow(repo, tasks, f"{label} record write"):
10576
+ return 1
10577
+ # A record write already made must not lock the task out of its product
10578
+ # writes — this is the drift half of the reported defect.
10579
+ write_text(tasks, record_layer_tasks().replace("M1: pending", "M1: done"))
10580
+ if expect_guard_allow(repo, repo / "src/feature.js", f"{label} after record write"):
10581
+ return 1
10582
+
10583
+ # The layer is exactly this change's directory, nothing wider.
10584
+ if expect_guard_deny(
10585
+ repo,
10586
+ repo / "openspec/changes/other/tasks.md",
10587
+ ["outside Touch"],
10588
+ f"{label} other change",
10589
+ ):
10590
+ return 1
10591
+ if expect_guard_deny(
10592
+ repo,
10593
+ repo / "keel/archive/follow-ups/note.md",
10594
+ ["outside Touch"],
10595
+ f"{label} archive tree",
10596
+ ):
10597
+ return 1
10598
+
10599
+ # Authority outside the change directory still hashes and still denies.
10600
+ write_text(spec, RECORD_LAYER_SPEC.replace("it passes", "it passes twice"))
10601
+ if expect_guard_deny(
10602
+ repo,
10603
+ repo / "src/feature.js",
10604
+ ["authority drift"],
10605
+ f"{label} real authority drift",
10606
+ ):
10607
+ return 1
10608
+ write_text(spec, RECORD_LAYER_SPEC)
10609
+
10610
+ status = run_keel(repo, "guard", "status", "--json")
10611
+ status_payload = json.loads(status.stdout) if status.stdout else {}
10612
+ codes = [item.get("code") for item in status_payload.get("problems", [])]
10613
+ if status_payload.get("status") != "active" or codes:
10614
+ report(
10615
+ f"{label}: guard status reported {status_payload.get('status')!r} "
10616
+ f"with problems {codes} after a record write; a checkbox or "
10617
+ "Evidence write is not authority drift."
10618
+ )
10619
+ return 1
10620
+
10621
+ # A real contract edit in the same file must still hard-stop, through the
10622
+ # fingerprint rather than through byte hashing.
10623
+ write_text(tasks, record_layer_tasks(touch="src/other.js"))
10624
+ drifted = run_keel(repo, "guard", "status", "--json")
10625
+ drifted_codes = [
10626
+ item.get("code")
10627
+ for item in (json.loads(drifted.stdout) if drifted.stdout else {}).get(
10628
+ "problems", []
10629
+ )
10630
+ ]
10631
+ if "fingerprint-drift" not in drifted_codes:
10632
+ report(
10633
+ f"{label}: editing the task's Touch line did not report "
10634
+ f"fingerprint drift; got {drifted_codes}."
10635
+ )
10636
+ return 1
10637
+
10638
+ # Once checked, product writes stop but the task can still finish its
10639
+ # own records — the completion gate requires that Evidence.
10640
+ write_text(tasks, record_layer_tasks(checked=True))
10641
+ if expect_guard_deny(
10642
+ repo,
10643
+ repo / "src/feature.js",
10644
+ ["checked complete"],
10645
+ f"{label} completed product write",
10646
+ ):
10647
+ return 1
10648
+ if expect_guard_allow(repo, tasks, f"{label} completed record write"):
10649
+ return 1
10650
+
10651
+ if label not in {name for name, _ in SCENARIOS}:
10652
+ report(f"{label}: the scenario registry does not include it.")
10653
+ return 1
10654
+ report(f"{label} scenario passed.")
10655
+ return 0
10656
+
10657
+
10370
10658
  def validate_touch_write_guard_scenario() -> int:
10371
10659
  with tempfile.TemporaryDirectory(
10372
10660
  prefix="keel-touch-guard-", ignore_cleanup_errors=True
@@ -11156,18 +11444,32 @@ def validate_touch_guard_drift_scenario() -> int:
11156
11444
  "Exercise guarded feature", "Exercise guarded feature differently"
11157
11445
  ),
11158
11446
  )
11159
- if expect_guard_deny(
11160
- repo,
11161
- repo / "src/feature.js",
11162
- ["drift", "demo", "1.1", "keel guard start"],
11163
- "touch-guard-drift authority edit",
11447
+ # A contract edit inside the guarded change's own directory is caught
11448
+ # where the capsule is compiled, not at the next write: the hook cannot
11449
+ # compile, so it cannot separate this from a checkbox or Evidence write
11450
+ # in the same file. It therefore allows the write and `guard status`
11451
+ # reports the drift. Authority *outside* that directory still denies at
11452
+ # write time — see the touch-guard-record-layer scenario.
11453
+ if expect_guard_allow(
11454
+ repo, repo / "src/feature.js", "touch-guard-drift contract edit"
11164
11455
  ):
11165
11456
  return 1
11166
11457
  status = run_keel(repo, "guard", "status", "--json")
11167
- if status.returncode != 3 or json.loads(status.stdout).get("status") != "drifted":
11458
+ status_payload = json.loads(status.stdout) if status.stdout else {}
11459
+ if status.returncode != 3 or status_payload.get("status") != "drifted":
11168
11460
  report("touch-guard-drift: status did not report drifted.")
11169
11461
  report((status.stderr or status.stdout).strip())
11170
11462
  return 1
11463
+ if not any(
11464
+ item.get("code") == "fingerprint-drift"
11465
+ for item in status_payload.get("problems", [])
11466
+ ):
11467
+ report(
11468
+ "touch-guard-drift: the contract edit was not reported as "
11469
+ "fingerprint drift by the check that compiles the capsule."
11470
+ )
11471
+ report(status.stdout or "")
11472
+ return 1
11171
11473
 
11172
11474
  restarted = run_keel(
11173
11475
  repo, "guard", "start", "--change", "demo", "--task", "1.1", "--json"
@@ -11184,30 +11486,41 @@ def validate_touch_guard_drift_scenario() -> int:
11184
11486
  ):
11185
11487
  return 1
11186
11488
 
11489
+ # A fingerprint-neutral edit to the task's own file is a record write,
11490
+ # not drift. This used to fail closed, which is the defect issue #8
11491
+ # reports: appending Evidence blocked every following write.
11187
11492
  tasks_path.write_text(
11188
11493
  tasks_path.read_text(encoding="utf-8") + "\n", encoding="utf-8"
11189
11494
  )
11190
- if expect_guard_deny(
11191
- repo,
11192
- repo / "src/feature.js",
11193
- ["drift", "keel guard start"],
11194
- "touch-guard-drift cosmetic edit fails closed",
11495
+ if expect_guard_allow(
11496
+ repo, repo / "src/feature.js", "touch-guard-drift cosmetic edit"
11195
11497
  ):
11196
11498
  return 1
11197
- cosmetic = run_keel(
11198
- repo, "guard", "start", "--change", "demo", "--task", "1.1", "--json"
11499
+ cosmetic = run_keel(repo, "guard", "status", "--json")
11500
+ cosmetic_payload = json.loads(cosmetic.stdout) if cosmetic.stdout else {}
11501
+ if cosmetic_payload.get("status") != "active" or cosmetic_payload.get(
11502
+ "problems"
11503
+ ):
11504
+ report(
11505
+ "touch-guard-drift: a fingerprint-neutral edit to the task's "
11506
+ "own file was reported as a guard problem."
11507
+ )
11508
+ report(cosmetic.stdout or "")
11509
+ return 1
11510
+ restarted_cosmetic = run_keel(
11511
+ repo, "guard", "start", "--change", "demo", "--task", "1.1",
11512
+ "--force", "--json",
11199
11513
  )
11200
- if cosmetic.returncode != 0:
11514
+ if restarted_cosmetic.returncode != 0:
11201
11515
  report("touch-guard-drift: cosmetic restart failed.")
11516
+ report((restarted_cosmetic.stderr or restarted_cosmetic.stdout).strip())
11202
11517
  return 1
11203
- third = json.loads(cosmetic.stdout)["manifest"]["fingerprint"]["value"]
11518
+ third = json.loads(restarted_cosmetic.stdout)["manifest"]["fingerprint"][
11519
+ "value"
11520
+ ]
11204
11521
  if third != second:
11205
11522
  report("touch-guard-drift: cosmetic edit drifted the capsule fingerprint.")
11206
11523
  return 1
11207
- if expect_guard_allow(
11208
- repo, repo / "src/feature.js", "touch-guard-drift cosmetic restart"
11209
- ):
11210
- return 1
11211
11524
 
11212
11525
  write_text(
11213
11526
  tasks_path,
@@ -11437,6 +11750,8 @@ SCENARIOS: tuple = (
11437
11750
  ("native-helper-targets", validate_native_helper_targets_scenario),
11438
11751
  ("native-single-task-matrix", validate_native_single_task_matrix_scenario),
11439
11752
  ("touch-write-guard", validate_touch_write_guard_scenario),
11753
+ ("touch-guard-record-layer", validate_touch_guard_record_layer_scenario),
11754
+ ("repo-action-mode", validate_repo_action_mode_scenario),
11440
11755
  ("touch-guard-drift", validate_touch_guard_drift_scenario),
11441
11756
  ("touch-guard-surface", validate_touch_guard_surface_scenario),
11442
11757
  ("plugin-compaction-continuity", validate_plugin_compaction_continuity_scenario),
package/src/core/guard.js CHANGED
@@ -247,7 +247,13 @@ function guardStatus(repo) {
247
247
  + "reauthorize through `keel gate task-start` and `keel guard start`.",
248
248
  });
249
249
  }
250
+ // Bytes under the guarded change's own directory are records the task
251
+ // produces — checkbox, Evidence, Review — not authority it must not touch.
252
+ // The fingerprint comparison above already covers every part of that file
253
+ // the capsule reads, so hashing it here only reported progress as drift.
254
+ const recordPrefix = `openspec/changes/${manifest.change}/`;
250
255
  for (const entry of manifest.authority) {
256
+ if (entry.path.replace(/\\/g, "/").startsWith(recordPrefix)) continue;
251
257
  const file = path.join(repo, entry.path);
252
258
  if (!fs.existsSync(file) || sha256(fs.readFileSync(file)) !== entry.sha256) {
253
259
  problems.push({
@@ -8,8 +8,17 @@ const SUPPORTED_MODES = new Set([
8
8
  "implementation",
9
9
  "diagnose-only",
10
10
  "plan-first",
11
+ // A task whose whole effect is an authorized repository-level action — the
12
+ // repository's first commit, a tag — writes no worktree file, so it has no
13
+ // concrete Touch to declare. It is not diagnose-only either: it has real side
14
+ // effects that need evidence. It is the one mode that may commit.
15
+ "repo-action",
11
16
  ]);
12
17
 
18
+ // Modes whose contract is "no worktree writes", so `Touch: none` is required
19
+ // rather than merely tolerated.
20
+ const NO_WRITE_MODES = new Set(["diagnose-only", "repo-action"]);
21
+
13
22
  const UNFILLED_TOKEN = /(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i;
14
23
 
15
24
  function normalizeFieldText(value) {
@@ -241,16 +250,17 @@ function taskStartContractProblems(task) {
241
250
  code: "unsupported-mode",
242
251
  message:
243
252
  `Unsupported Mode \`${mode}\`; expected implementation, `
244
- + "diagnose-only, or plan-first.",
253
+ + "diagnose-only, plan-first, or repo-action.",
245
254
  },
246
255
  ];
247
256
  }
248
- if (mode === "diagnose-only") {
257
+ if (NO_WRITE_MODES.has(mode)) {
249
258
  if (touch.length !== 1 || touch[0].toLowerCase() !== "none") {
250
259
  return [
251
260
  {
252
261
  code: "invalid-touch",
253
- message: "diagnose-only requires `Touch: none`.",
262
+ message: `${mode} writes no worktree file and requires `
263
+ + "`Touch: none`.",
254
264
  },
255
265
  ];
256
266
  }
@@ -819,13 +829,17 @@ function compileTaskContract(repo, change, task) {
819
829
  helperAuthority: "read-only-evidence-only",
820
830
  prohibitions: [
821
831
  "must not change Acceptance",
822
- "must not commit",
832
+ // repo-action is the one mode whose authorized effect is the repository
833
+ // action itself, so it alone does not carry the commit prohibition.
834
+ // Whether the action it performed was the authorized one is a Review
835
+ // judgment; what the capsule fixes is the write posture.
836
+ ...(mode === "repo-action" ? [] : ["must not commit"]),
823
837
  "must not continue to another task",
824
838
  "must not mark tasks complete",
825
839
  "must not push",
826
840
  "must not sync or archive",
827
841
  "must not transfer Keel ownership",
828
- ...(mode === "diagnose-only" ? ["must not write product files"] : []),
842
+ ...(NO_WRITE_MODES.has(mode) ? ["must not write product files"] : []),
829
843
  ],
830
844
  };
831
845
  if (resolved.diagnostics.length > 0) {