@tech-leads-club/harness-toolkit 0.2.1

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.
Files changed (300) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/LICENSE +84 -0
  3. package/NOTICE +9 -0
  4. package/README.md +480 -0
  5. package/bin/tlc +10 -0
  6. package/bin/tlc-build +80 -0
  7. package/bin/tlc-cli.ts +1509 -0
  8. package/bin/tlc-exec +10 -0
  9. package/bin/tlc-exec.cmd +4 -0
  10. package/bin/tlc-exec.d.mts +60 -0
  11. package/bin/tlc-exec.mjs +248 -0
  12. package/bin/tlc.cmd +4 -0
  13. package/bin/tlc.mjs +7 -0
  14. package/bin/write-user-hooks.d.mts +40 -0
  15. package/bin/write-user-hooks.mjs +123 -0
  16. package/capabilities/catalog.json +418 -0
  17. package/config.example.json +51 -0
  18. package/dist/compact-before.mjs +7839 -0
  19. package/dist/doctor.mjs +8263 -0
  20. package/dist/help-topic.mjs +82 -0
  21. package/dist/init-project.mjs +993 -0
  22. package/dist/install-runtime.mjs +969 -0
  23. package/dist/lessons-cli.mjs +7258 -0
  24. package/dist/obs-cli.mjs +7025 -0
  25. package/dist/price-lookup.mjs +221 -0
  26. package/dist/prompt-submit.mjs +7860 -0
  27. package/dist/refresh-model-prices.mjs +158 -0
  28. package/dist/response-after.mjs +7879 -0
  29. package/dist/run.mjs +7830 -0
  30. package/dist/session-end.mjs +7937 -0
  31. package/dist/session-start.mjs +8056 -0
  32. package/dist/shim.mjs +52 -0
  33. package/dist/stop.mjs +8494 -0
  34. package/dist/subagent-start.mjs +7861 -0
  35. package/dist/subagent-stop.mjs +7851 -0
  36. package/dist/support.mjs +7045 -0
  37. package/dist/tlc-cli.mjs +8063 -0
  38. package/dist/tool-after.mjs +8113 -0
  39. package/dist/tool-before.mjs +7985 -0
  40. package/dist/tool-failure.mjs +7850 -0
  41. package/dist/uninstall-runtime.mjs +1307 -0
  42. package/docs/architecture.md +270 -0
  43. package/docs/concepts.md +562 -0
  44. package/docs/coverage.md +55 -0
  45. package/docs/decisions/ad-001.md +32 -0
  46. package/docs/decisions/ad-002.md +51 -0
  47. package/docs/decisions/ad-003.md +30 -0
  48. package/docs/decisions/ad-004.md +37 -0
  49. package/docs/decisions/ad-005.md +36 -0
  50. package/docs/decisions/ad-006.md +49 -0
  51. package/docs/decisions/ad-007.md +36 -0
  52. package/docs/decisions/ad-008.md +54 -0
  53. package/docs/decisions/ad-009.md +61 -0
  54. package/docs/decisions/ad-010.md +45 -0
  55. package/docs/decisions/ad-011.md +59 -0
  56. package/docs/decisions/ad-012.md +71 -0
  57. package/docs/decisions/ad-013.md +87 -0
  58. package/docs/decisions/ad-014.md +56 -0
  59. package/docs/decisions/ad-015.md +33 -0
  60. package/docs/decisions/ad-016.md +98 -0
  61. package/docs/decisions/ad-017.md +65 -0
  62. package/docs/decisions/ad-018.md +77 -0
  63. package/docs/decisions/ad-019.md +75 -0
  64. package/docs/decisions/ad-020.md +88 -0
  65. package/docs/decisions/ad-021.md +57 -0
  66. package/docs/decisions/ad-022.md +120 -0
  67. package/docs/decisions/ad-023.md +87 -0
  68. package/docs/decisions/ad-024.md +85 -0
  69. package/docs/decisions/ad-025.md +92 -0
  70. package/docs/decisions/ad-026.md +131 -0
  71. package/docs/decisions/ad-027.md +90 -0
  72. package/docs/decisions/ad-028.md +90 -0
  73. package/docs/decisions/ad-029.md +73 -0
  74. package/docs/decisions/ad-030.md +97 -0
  75. package/docs/decisions/ad-031.md +94 -0
  76. package/docs/decisions/ad-032.md +83 -0
  77. package/docs/decisions/ad-033.md +89 -0
  78. package/docs/decisions/ad-034.md +86 -0
  79. package/docs/decisions/ad-035.md +86 -0
  80. package/docs/decisions/ad-036.md +68 -0
  81. package/docs/decisions/ad-037.md +47 -0
  82. package/docs/decisions/ad-038.md +52 -0
  83. package/docs/decisions/ad-039.md +69 -0
  84. package/docs/decisions/ad-040.md +89 -0
  85. package/docs/decisions/ad-041.md +98 -0
  86. package/docs/decisions/ad-042.md +82 -0
  87. package/docs/decisions/ad-043.md +79 -0
  88. package/docs/decisions/ad-044.md +61 -0
  89. package/docs/decisions/ad-045.md +94 -0
  90. package/docs/decisions/ad-046.md +111 -0
  91. package/docs/decisions/ad-047.md +96 -0
  92. package/docs/decisions/ad-048.md +85 -0
  93. package/docs/decisions/ad-049.md +66 -0
  94. package/docs/decisions/ad-050.md +94 -0
  95. package/docs/decisions/ad-051.md +69 -0
  96. package/docs/decisions/ad-052.md +69 -0
  97. package/docs/decisions/ad-053.md +78 -0
  98. package/docs/decisions/ad-054.md +98 -0
  99. package/docs/decisions/ad-055.md +74 -0
  100. package/docs/decisions/ad-056.md +85 -0
  101. package/docs/decisions/ad-057.md +68 -0
  102. package/docs/decisions/ad-058.md +97 -0
  103. package/docs/decisions/ad-059.md +82 -0
  104. package/docs/decisions/ad-060.md +75 -0
  105. package/docs/decisions/ad-061.md +68 -0
  106. package/docs/decisions/ad-062.md +72 -0
  107. package/docs/decisions/ad-063.md +84 -0
  108. package/docs/decisions/ad-064.md +79 -0
  109. package/docs/decisions/ad-065.md +81 -0
  110. package/docs/decisions/ad-066.md +111 -0
  111. package/docs/decisions/ad-067.md +64 -0
  112. package/docs/decisions/ad-068.md +79 -0
  113. package/docs/decisions/ad-069.md +74 -0
  114. package/docs/decisions/ad-070.md +86 -0
  115. package/docs/decisions/ad-071.md +93 -0
  116. package/docs/decisions/ad-072.md +82 -0
  117. package/docs/decisions/ad-073.md +102 -0
  118. package/docs/decisions/ad-074.md +91 -0
  119. package/docs/decisions/ad-075.md +79 -0
  120. package/docs/decisions/ad-076.md +102 -0
  121. package/docs/decisions/ad-077.md +94 -0
  122. package/docs/decisions/ad-078.md +84 -0
  123. package/docs/decisions/ad-079.md +73 -0
  124. package/docs/decisions/ad-080.md +86 -0
  125. package/docs/decisions/ad-081.md +70 -0
  126. package/docs/decisions/ad-082.md +79 -0
  127. package/docs/decisions/ad-083.md +88 -0
  128. package/docs/decisions/index.md +111 -0
  129. package/docs/diagnose.md +248 -0
  130. package/docs/index.md +52 -0
  131. package/docs/init.md +101 -0
  132. package/docs/lessons.md +294 -0
  133. package/docs/log.md +139 -0
  134. package/docs/measure.md +178 -0
  135. package/docs/providers/claude-code.md +118 -0
  136. package/docs/providers/cursor.md +110 -0
  137. package/docs/providers/index.md +71 -0
  138. package/docs/troubleshooting.md +138 -0
  139. package/model-aliases.json +12 -0
  140. package/model-prices.cursor.json +410 -0
  141. package/model-prices.json +1 -0
  142. package/package.json +57 -0
  143. package/skills/harness-init/SKILL.md +241 -0
  144. package/skills/harness-init/evals/trigger_evals.json +86 -0
  145. package/skills/harness-init/references/capabilities.md +113 -0
  146. package/src/contracts/capabilities.ts +30 -0
  147. package/src/contracts/decision.ts +21 -0
  148. package/src/contracts/effort.ts +15 -0
  149. package/src/contracts/harness-event.ts +87 -0
  150. package/src/contracts/index.ts +6 -0
  151. package/src/contracts/policy-defaults.ts +14 -0
  152. package/src/contracts/wiring.ts +20 -0
  153. package/src/core/attest/attest.service.ts +0 -0
  154. package/src/core/capability/capability.service.ts +62 -0
  155. package/src/core/capability/capability.store.ts +51 -0
  156. package/src/core/capability/capability.types.ts +56 -0
  157. package/src/core/comment-policy/comment-policy.service.ts +307 -0
  158. package/src/core/comment-policy/comment-policy.types.ts +6 -0
  159. package/src/core/comment-policy/comment-resolvability.ts +134 -0
  160. package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
  161. package/src/core/comment-policy/comment-syntax.store.ts +65 -0
  162. package/src/core/comment-policy/comment-syntax.types.ts +24 -0
  163. package/src/core/core.facade.ts +461 -0
  164. package/src/core/duplication/duplication.service.ts +219 -0
  165. package/src/core/floor/floor.catalog.ts +54 -0
  166. package/src/core/floor/floor.paths.ts +90 -0
  167. package/src/core/floor/floor.policy-surface.ts +311 -0
  168. package/src/core/floor/floor.service.ts +306 -0
  169. package/src/core/floor/floor.tokenize.ts +179 -0
  170. package/src/core/floor/floor.verb.ts +28 -0
  171. package/src/core/floor/index.ts +1 -0
  172. package/src/core/gate/gate.artifact.ts +157 -0
  173. package/src/core/gate/gate.command.ts +130 -0
  174. package/src/core/gate/gate.findings.ts +201 -0
  175. package/src/core/gate/gate.inputs.ts +0 -0
  176. package/src/core/gate/gate.lock.ts +283 -0
  177. package/src/core/gate/gate.service.ts +28 -0
  178. package/src/core/gate/gate.types.ts +67 -0
  179. package/src/core/handoff/handoff.service.ts +43 -0
  180. package/src/core/handoff/handoff.store.ts +59 -0
  181. package/src/core/handoff/handoff.types.ts +89 -0
  182. package/src/core/index.ts +52 -0
  183. package/src/core/integrity/state-seal.ts +81 -0
  184. package/src/core/lesson/lesson.authored.ts +96 -0
  185. package/src/core/lesson/lesson.credit.ts +51 -0
  186. package/src/core/lesson/lesson.garden.ts +283 -0
  187. package/src/core/lesson/lesson.link.ts +93 -0
  188. package/src/core/lesson/lesson.score.ts +61 -0
  189. package/src/core/lesson/lesson.select.ts +209 -0
  190. package/src/core/lesson/lesson.service.ts +110 -0
  191. package/src/core/lesson/lesson.store.ts +317 -0
  192. package/src/core/lesson/lesson.sync.ts +70 -0
  193. package/src/core/lesson/lesson.types.ts +92 -0
  194. package/src/core/lesson/lesson.validity.ts +44 -0
  195. package/src/core/observability/observability.report.ts +326 -0
  196. package/src/core/observability/observability.service.ts +328 -0
  197. package/src/core/observability/observability.store.ts +269 -0
  198. package/src/core/observability/observability.types.ts +193 -0
  199. package/src/core/observability/observability.why.ts +206 -0
  200. package/src/core/observe/observe.service.ts +76 -0
  201. package/src/core/plan/plan.detect.ts +47 -0
  202. package/src/core/plan/plan.service.ts +81 -0
  203. package/src/core/plan/plan.types.ts +25 -0
  204. package/src/core/policy/policy.defaults.ts +103 -0
  205. package/src/core/policy/policy.guard.ts +34 -0
  206. package/src/core/policy/policy.integrity.ts +238 -0
  207. package/src/core/policy/policy.loader.ts +118 -0
  208. package/src/core/policy/policy.operator.ts +82 -0
  209. package/src/core/policy/policy.posture.ts +79 -0
  210. package/src/core/policy/policy.rails.ts +45 -0
  211. package/src/core/policy/policy.types.ts +157 -0
  212. package/src/core/presence/presence.service.ts +111 -0
  213. package/src/core/presence/presence.store.ts +59 -0
  214. package/src/core/presence/presence.types.ts +9 -0
  215. package/src/core/release/release.decisions.ts +147 -0
  216. package/src/core/release/release.seen.ts +38 -0
  217. package/src/core/shell-policy/shell-policy.service.ts +215 -0
  218. package/src/core/shell-policy/shell-policy.stall.ts +57 -0
  219. package/src/core/shell-policy/shell-policy.types.ts +26 -0
  220. package/src/core/ship/ship.ledger.ts +76 -0
  221. package/src/core/ship/ship.service.ts +137 -0
  222. package/src/core/ship/ship.types.ts +19 -0
  223. package/src/core/stagnation/stagnation.resolution.ts +87 -0
  224. package/src/core/stagnation/stagnation.service.ts +20 -0
  225. package/src/core/stagnation/stagnation.store.ts +49 -0
  226. package/src/core/stagnation/stagnation.types.ts +12 -0
  227. package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
  228. package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
  229. package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
  230. package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
  231. package/src/core/supply-chain/supply-chain.service.ts +217 -0
  232. package/src/core/turn/turn.activity.ts +94 -0
  233. package/src/core/turn/turn.autopilot.ts +131 -0
  234. package/src/core/turn/turn.failure-signals.ts +193 -0
  235. package/src/core/turn/turn.loop-counter.ts +71 -0
  236. package/src/core/turn/turn.types.ts +14 -0
  237. package/src/core/untrusted/untrusted.detect.ts +53 -0
  238. package/src/core/untrusted/untrusted.recall.ts +87 -0
  239. package/src/core/untrusted/untrusted.service.ts +129 -0
  240. package/src/core/untrusted/untrusted.store.ts +75 -0
  241. package/src/core/untrusted/untrusted.types.ts +42 -0
  242. package/src/entrypoints/compact-before.ts +14 -0
  243. package/src/entrypoints/prompt-submit.ts +25 -0
  244. package/src/entrypoints/response-after.ts +60 -0
  245. package/src/entrypoints/run.ts +179 -0
  246. package/src/entrypoints/session-end.ts +58 -0
  247. package/src/entrypoints/session-start.ts +172 -0
  248. package/src/entrypoints/shim.ts +46 -0
  249. package/src/entrypoints/stop.ts +876 -0
  250. package/src/entrypoints/subagent-start.ts +16 -0
  251. package/src/entrypoints/subagent-stop.ts +32 -0
  252. package/src/entrypoints/support.ts +182 -0
  253. package/src/entrypoints/tool-after.ts +111 -0
  254. package/src/entrypoints/tool-before.ts +186 -0
  255. package/src/entrypoints/tool-failure.ts +26 -0
  256. package/src/platform/backoff.ts +49 -0
  257. package/src/platform/cli-output.ts +30 -0
  258. package/src/platform/env-scope.ts +22 -0
  259. package/src/platform/fs-atomic.ts +142 -0
  260. package/src/platform/fs-jsonl.ts +24 -0
  261. package/src/platform/git.ts +187 -0
  262. package/src/platform/paths.ts +80 -0
  263. package/src/platform/pricing.ts +229 -0
  264. package/src/platform/process.ts +64 -0
  265. package/src/platform/sanitize.ts +23 -0
  266. package/src/platform/screen.ts +46 -0
  267. package/src/platform/style.ts +103 -0
  268. package/src/providers/claude/claude.capabilities.ts +25 -0
  269. package/src/providers/claude/claude.detect.ts +14 -0
  270. package/src/providers/claude/claude.inbound.ts +252 -0
  271. package/src/providers/claude/claude.lessons-view.ts +33 -0
  272. package/src/providers/claude/claude.outbound.ts +65 -0
  273. package/src/providers/claude/claude.policy-defaults.ts +9 -0
  274. package/src/providers/claude/claude.transcript.ts +62 -0
  275. package/src/providers/claude/claude.wiring.ts +245 -0
  276. package/src/providers/claude/index.ts +17 -0
  277. package/src/providers/cursor/cursor.capabilities.ts +28 -0
  278. package/src/providers/cursor/cursor.detect.ts +14 -0
  279. package/src/providers/cursor/cursor.inbound.ts +203 -0
  280. package/src/providers/cursor/cursor.lessons-view.ts +30 -0
  281. package/src/providers/cursor/cursor.outbound.ts +43 -0
  282. package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
  283. package/src/providers/cursor/cursor.wiring.ts +214 -0
  284. package/src/providers/cursor/index.ts +17 -0
  285. package/src/providers/index.ts +9 -0
  286. package/src/providers/provider.degrade.ts +152 -0
  287. package/src/providers/provider.port.ts +20 -0
  288. package/src/providers/provider.registry.ts +28 -0
  289. package/tools/doctor.ts +631 -0
  290. package/tools/help-topic.ts +74 -0
  291. package/tools/init-project.ts +277 -0
  292. package/tools/install-runtime.ts +164 -0
  293. package/tools/lessons-cli.ts +474 -0
  294. package/tools/obs-cli.ts +175 -0
  295. package/tools/price-lookup.ts +57 -0
  296. package/tools/refresh-model-prices.ts +210 -0
  297. package/tools/test-env.mjs +28 -0
  298. package/tools/test-env.names.d.mts +1 -0
  299. package/tools/test-env.names.mjs +14 -0
  300. package/tools/uninstall-runtime.ts +463 -0
@@ -0,0 +1,876 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Decision, HarnessEvent } from "../contracts/index.ts";
4
+ import { coreFacade, type LastGateArtifact, type PendingLessonCredit, type Policy } from "../core/index.ts";
5
+ import {
6
+ filterCodeTargets,
7
+ filterTestTargets,
8
+ listAddedLines,
9
+ listChangedRepoFiles,
10
+ listTrackedFiles,
11
+ runCommand,
12
+ } from "../platform/git.ts";
13
+ import { flagsDir } from "../platform/paths.ts";
14
+ import type { Handler, HandlerContext } from "./run.ts";
15
+ import { main } from "./run.ts";
16
+ import { formatLessonsBlock, obsConfigFor, sessionIdFromKey } from "./support.ts";
17
+
18
+ const STAGNATION_FOLLOWUP = [
19
+ "BLOCKED: identical validation fingerprint repeated — no progress between attempts.",
20
+ "TRIED: same gate failure signature as the previous stop loop.",
21
+ "NEED: change approach. Do not repeat the same fix. Inspect root cause, try a different path, or escalate with BLOCKED/TRIED/NEED.",
22
+ ].join("\n");
23
+
24
+ /**
25
+ * hazard: `gate.outcome` was consumed in two places — the rollup counter and the session report's
26
+ * "Gates pass/fail" line — and emitted by nothing. Both read structurally zero, so the report printed a
27
+ * truthful-looking `0 / 0` for every gate this harness has ever run
28
+ * ([/decisions/ad-027.md](/decisions/ad-027.md)).
29
+ *
30
+ * why: recorded here rather than at each call site, so a gate added later cannot be forgotten. Every gate goes
31
+ * through this function; a gate that does not is not run under the lock either.
32
+ */
33
+ function recordGateOutcome(args: {
34
+ root: string;
35
+ provider: string;
36
+ sessionKey: string;
37
+ policy: Policy;
38
+ artifact: LastGateArtifact;
39
+ reused: boolean;
40
+ }): void {
41
+ coreFacade.observability.recordObs(args.root, obsConfigFor(args.policy), {
42
+ provider: args.provider,
43
+ kind: "gate.outcome",
44
+ sessionKey: args.sessionKey,
45
+ attrs: {
46
+ gate: args.artifact.gate,
47
+ passed: args.artifact.passed,
48
+ exit_code: args.artifact.exitCode,
49
+ duration_ms: args.artifact.durationMs,
50
+ file_count: args.artifact.files.length,
51
+ // why: so `obs report` and `attest` can answer "what environment did this gate run under" after the fact,
52
+ // without the follow-up having had to say it.
53
+ scoped_env: (args.artifact.scopedEnv ?? []).join(",") || "none",
54
+ // why: a reused verdict costs no time, so counting it as a run would make the total gate time read lower
55
+ // than it is and hide the saving instead of showing it ([/decisions/ad-045.md](/decisions/ad-045.md)).
56
+ reused: args.reused,
57
+ },
58
+ });
59
+ }
60
+
61
+ /**
62
+ * why: a gate that deferred has to be findable afterwards. Without a record the only trace was one turn's reply,
63
+ * which is why the same neighbour collision was reported three times as if each were new
64
+ * ([/decisions/ad-073.md](/decisions/ad-073.md)).
65
+ */
66
+ function recordGateDeferred(args: {
67
+ root: string;
68
+ provider: string;
69
+ sessionKey: string;
70
+ gate: string;
71
+ holder: string;
72
+ policy: Policy;
73
+ }): void {
74
+ coreFacade.observability.recordObs(args.root, obsConfigFor(args.policy), {
75
+ provider: args.provider,
76
+ kind: "gate.outcome",
77
+ sessionKey: args.sessionKey,
78
+ attrs: {
79
+ gate: args.gate,
80
+ // invariant: not `passed: false`. A deferred gate produced no verdict, and recording one as a failure would
81
+ // put a failure in the report that nothing failed.
82
+ deferred_to: args.holder,
83
+ rule: "grind",
84
+ },
85
+ });
86
+ }
87
+
88
+ /**
89
+ * The verdict a gate would produce, without producing it twice.
90
+ *
91
+ * why: keyed on a content hash of the command and the files, which is the monorepo-tooling rule — same inputs,
92
+ * replay the result. A read-only turn in a repository with uncommitted work re-ran the whole suite on every
93
+ * question, because the trigger read the state of the tree rather than what the turn did
94
+ * ([/decisions/ad-045.md](/decisions/ad-045.md)).
95
+ */
96
+ /**
97
+ * invariant: a union, so a deferred gate cannot be read as a passing artifact. A shape with an optional artifact
98
+ * would have let `undefined` mean "fine" at three call sites that reach straight for `artifact.passed`
99
+ * ([/decisions/ad-073.md](/decisions/ad-073.md)).
100
+ */
101
+ type GateRun =
102
+ | { kind: "ran"; artifact: LastGateArtifact; reused: boolean }
103
+ | { kind: "deferred"; holder: string };
104
+
105
+ /**
106
+ * hazard: `GATE_LOCK_WAIT_MS` is 120 000 and the Stop hook is registered with `timeoutSeconds: 120`, so waiting
107
+ * the library default leaves nothing for the gate the wait exists to run — the host kills the hook first. This is
108
+ * the share of the budget a neighbour may spend before the turn stops waiting for it.
109
+ */
110
+ export const STOP_LOCK_WAIT_MS = 10_000;
111
+
112
+ /**
113
+ * why: a test that proves the wait has to wait, and a suite that pays ten seconds for it pays that on every gate
114
+ * run in every environment. This is the only seam and it is read here, so the production default is a constant
115
+ * nobody can reconfigure from a project ([/decisions/ad-073.md](/decisions/ad-073.md)).
116
+ */
117
+ export function stopLockWaitMs(env: NodeJS.ProcessEnv = process.env): number {
118
+ const declared = Number(env.TLC_TEST_GATE_LOCK_WAIT_MS);
119
+ return Number.isFinite(declared) && declared > 0 ? declared : STOP_LOCK_WAIT_MS;
120
+ }
121
+
122
+ /**
123
+ * Grades the lessons that were injected the last time this gate failed. `helped` means the gate the lessons were
124
+ * chosen for then passed; `neutral` means it failed again.
125
+ *
126
+ * invariant: consumed exactly once. The pending credit is cleared whether or not any lesson matched, so a single
127
+ * injection cannot be graded twice by two later runs of the same gate.
128
+ *
129
+ * hazard: the gate name is compared. Without it, lessons injected for `lint` would be credited by whichever gate
130
+ * ran next, which is `test` in this handler and would read as help the lesson never gave.
131
+ */
132
+ async function creditPendingLessons(args: {
133
+ root: string;
134
+ provider: string;
135
+ pending: PendingLessonCredit | undefined;
136
+ gate: string;
137
+ passed: boolean;
138
+ }): Promise<void> {
139
+ const { pending } = args;
140
+ if (!pending || pending.gate !== args.gate || pending.ids.length === 0) {
141
+ return;
142
+ }
143
+ await coreFacade.lesson.creditLessons(args.root, pending.ids, args.passed ? "helped" : "neutral");
144
+ await coreFacade.handoff.patchHandoff(args.root, args.provider, {
145
+ slice: { pending_lesson_credit: undefined },
146
+ });
147
+ }
148
+
149
+ async function runLockedGate(args: {
150
+ root: string;
151
+ provider: string;
152
+ session: string;
153
+ gate: "lint" | "test" | "docs";
154
+ command: string[];
155
+ argvFiles: string[];
156
+ recordFiles: string[];
157
+ sessionKey: string;
158
+ policy: Policy;
159
+ pendingCredit: PendingLessonCredit | undefined;
160
+ }): Promise<GateRun> {
161
+ const command = [...args.command, ...args.argvFiles];
162
+ const inputs = coreFacade.gate.computeInputsHash(args.root, args.recordFiles, command);
163
+ const cached = coreFacade.gate.cachedVerdict(coreFacade.gate.readLastGate(args.root), args.gate, inputs);
164
+
165
+ let artifact: LastGateArtifact;
166
+ if (cached !== null) {
167
+ artifact = cached;
168
+ } else {
169
+ try {
170
+ artifact = await coreFacade.gate.withGateLock(
171
+ args.root,
172
+ args.provider,
173
+ args.session,
174
+ async () => {
175
+ const result = await runCommand(args.root, args.command, args.argvFiles);
176
+ return coreFacade.gate.writeLastGate({
177
+ root: args.root,
178
+ gate: args.gate,
179
+ exitCode: result.exitCode,
180
+ command,
181
+ files: args.recordFiles,
182
+ durationMs: result.durationMs,
183
+ output: result.output,
184
+ ...(inputs.complete ? { inputsHash: inputs.hash } : {}),
185
+ });
186
+ },
187
+ { waitMs: stopLockWaitMs() },
188
+ );
189
+ } catch (error) {
190
+ // why: a neighbour in the same checkout is running these very commands over this very tree, so the property
191
+ // is being verified — by somebody else. Blocking this turn tells the one participant that cannot act.
192
+ if (!(error instanceof coreFacade.gate.GateLockTimeoutError)) {
193
+ throw error;
194
+ }
195
+ const holder = coreFacade.gate.describeHolder(args.root) ?? "another session";
196
+ recordGateDeferred({ ...args, holder });
197
+ return { kind: "deferred", holder };
198
+ }
199
+ }
200
+
201
+ // invariant: recorded outside the lock. A measurement must not widen the window in which one gate blocks another.
202
+ recordGateOutcome({ ...args, artifact, reused: cached !== null });
203
+ await creditPendingLessons({
204
+ root: args.root,
205
+ provider: args.provider,
206
+ pending: args.pendingCredit,
207
+ gate: args.gate,
208
+ passed: artifact.passed,
209
+ });
210
+ return { kind: "ran", artifact, reused: cached !== null };
211
+ }
212
+
213
+ async function failGate(args: {
214
+ root: string;
215
+ provider: string;
216
+ sessionKey: string;
217
+ gate: string;
218
+ artifact: LastGateArtifact;
219
+ loopCount: number;
220
+ maxLoops: number;
221
+ policy: Policy;
222
+ }): Promise<Decision> {
223
+ const { policy } = args;
224
+ const intel = policy.intelligence;
225
+ const fingerprint = coreFacade.stagnation.computeFingerprint({
226
+ files: args.artifact.files,
227
+ gate: args.gate,
228
+ exitCode: args.artifact.exitCode,
229
+ output: args.artifact.outputTail,
230
+ });
231
+ const hits = coreFacade.stagnation.trackFingerprint(args.root, args.sessionKey, fingerprint);
232
+ const category = coreFacade.gate.isCommandResolutionFailure({
233
+ exitCode: args.artifact.exitCode,
234
+ output: args.artifact.outputTail,
235
+ })
236
+ ? "config"
237
+ : coreFacade.turn.classifyGateFailure(args.gate);
238
+ const freshGaps = coreFacade.gate.gapsFromArtifact({ artifact: args.artifact, category });
239
+ const handoff = coreFacade.handoff.readHandoff(args.root, args.provider);
240
+ const gaps = intel.progressiveContext
241
+ ? coreFacade.turn.mergeGaps(handoff.previous_gaps, freshGaps)
242
+ : freshGaps;
243
+ const suggestion = coreFacade.turn.suggestionFor(category, args.gate);
244
+ const effectiveCategory = hits >= 2 ? "stagnation" : category;
245
+ const plan = intel.autopilot
246
+ ? coreFacade.turn.resolveAutopilot({
247
+ category: effectiveCategory,
248
+ gate: hits >= 2 ? "stagnation" : args.gate,
249
+ mode: policy.mode,
250
+ loopCount: args.loopCount,
251
+ maxLoops: args.maxLoops,
252
+ failingFiles: coreFacade.gate.filesFromOutput(args.artifact.outputTail, args.root),
253
+ changedFiles: args.artifact.files,
254
+ })
255
+ : null;
256
+
257
+ await coreFacade.handoff.patchHandoff(args.root, args.provider, {
258
+ slice: {
259
+ last_gate_result: "fail",
260
+ last_fingerprint: fingerprint,
261
+ fingerprint_hits: hits,
262
+ last_failure_category: intel.failureClassification ? effectiveCategory : undefined,
263
+ previous_gaps: intel.gapFeedback ? gaps : undefined,
264
+ blockers: `${args.gate} gate failed (${effectiveCategory}).`,
265
+ next_action: plan?.next_action ?? suggestion,
266
+ },
267
+ });
268
+
269
+ if (hits >= 2 && intel.lessons.enabled) {
270
+ await coreFacade.lesson.recordLessonFromFailure({
271
+ projectDir: args.root,
272
+ gate: args.gate,
273
+ category,
274
+ fingerprint,
275
+ output: args.artifact.outputTail,
276
+ sessionKey: args.sessionKey,
277
+ });
278
+ }
279
+
280
+ // why: the same failure identity, resolved before. Offered as a record of what happened rather than a list to
281
+ // edit — a previous resolution is evidence, and AD-024 established that a plan names files from evidence and
282
+ // never from proximity. Absent history changes nothing.
283
+ const resolution = coreFacade.stagnation.resolutionFor(args.root, fingerprint);
284
+ const historyLine = resolution ? coreFacade.stagnation.resolutionHistoryLine(resolution) : "";
285
+
286
+ const selected = intel.lessons.enabled
287
+ ? await coreFacade.lesson.selectLessons({
288
+ projectDir: args.root,
289
+ config: intel.lessons,
290
+ mode: "retry",
291
+ gate: args.gate,
292
+ text: hits >= 2 ? `stagnation ${args.artifact.outputTail}` : args.artifact.outputTail,
293
+ })
294
+ : { lessons: [], usedIds: [], omitted: 0 };
295
+ const lessonsBlock = formatLessonsBlock(
296
+ selected.lessons,
297
+ "Lessons for this gate (ranked — apply before inventing a new plan):",
298
+ selected.omitted,
299
+ );
300
+
301
+ // why: written after the lessons are chosen and before the turn resumes, so the next run of this same gate is
302
+ // the thing that grades them ([/decisions/ad-039.md](/decisions/ad-039.md)).
303
+ if (selected.usedIds.length > 0) {
304
+ await coreFacade.lesson.markGradeable(args.root, selected.usedIds);
305
+ await coreFacade.handoff.patchHandoff(args.root, args.provider, {
306
+ slice: {
307
+ pending_lesson_credit: {
308
+ gate: args.gate,
309
+ ids: selected.usedIds,
310
+ at: new Date().toISOString(),
311
+ },
312
+ },
313
+ });
314
+ }
315
+
316
+ if (hits >= 2) {
317
+ const stagnationGaps = intel.gapFeedback
318
+ ? [
319
+ ...gaps,
320
+ {
321
+ id: "stagnation-0",
322
+ gate: "stagnation",
323
+ category: "stagnation" as const,
324
+ summary: STAGNATION_FOLLOWUP,
325
+ },
326
+ ]
327
+ : [];
328
+ const body = [STAGNATION_FOLLOWUP];
329
+ if (intel.gapFeedback) {
330
+ body.push(
331
+ "",
332
+ coreFacade.turn.formatGapFeedback(
333
+ stagnationGaps,
334
+ coreFacade.turn.suggestionFor("stagnation", "stagnation"),
335
+ ),
336
+ );
337
+ }
338
+ if (historyLine) {
339
+ body.push("", historyLine);
340
+ }
341
+ if (lessonsBlock) {
342
+ body.push("", lessonsBlock);
343
+ }
344
+ if (plan) {
345
+ body.push("", coreFacade.turn.formatAutopilotBlock(plan));
346
+ }
347
+ return { kind: "continue", text: body.join("\n") };
348
+ }
349
+
350
+ const parts = [
351
+ `BLOCKED: ${args.gate} failed (loop ${args.loopCount}/${args.maxLoops}).`,
352
+ `TRIED: ${args.gate} on changed files.`,
353
+ `NEED: ${plan?.next_action ?? suggestion}`,
354
+ ];
355
+ if (intel.progressiveContext) {
356
+ parts.push(
357
+ "",
358
+ coreFacade.turn.formatProgressiveContext({
359
+ loopCount: args.loopCount,
360
+ maxLoops: args.maxLoops,
361
+ gate: args.gate,
362
+ category,
363
+ gaps,
364
+ gateOutput: args.artifact.outputTail,
365
+ suggestion: plan?.next_action ?? suggestion,
366
+ // why: the environment the gate actually ran under, and the command that settles it outside the hook.
367
+ // Named from the second attempt only ([/decisions/ad-060.md](/decisions/ad-060.md)).
368
+ scopedEnv: args.artifact.scopedEnv ?? [],
369
+ command: args.artifact.command,
370
+ }),
371
+ );
372
+ } else {
373
+ parts.push("", args.artifact.outputTail);
374
+ if (intel.gapFeedback && gaps.length > 0) {
375
+ parts.push("", coreFacade.turn.formatGapFeedback(gaps, suggestion));
376
+ }
377
+ }
378
+ if (historyLine) {
379
+ parts.push("", historyLine);
380
+ }
381
+ if (lessonsBlock) {
382
+ parts.push("", lessonsBlock);
383
+ }
384
+ if (plan) {
385
+ parts.push("", coreFacade.turn.formatAutopilotBlock(plan));
386
+ }
387
+ return { kind: "continue", text: parts.join("\n") };
388
+ }
389
+
390
+ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerContext): Promise<Decision> => {
391
+ const { policy, capabilities } = ctx;
392
+ const root = event.projectDir;
393
+ const provider = event.provider;
394
+ const sessionKey = event.sessionKey;
395
+ const session = sessionIdFromKey(event);
396
+ const status = event.status ?? "completed";
397
+ const maxLoops = policy.grind.maxLoops;
398
+ const loopCount = capabilities.nativeLoopCounter
399
+ ? (event.loopCount ?? 0)
400
+ : coreFacade.turn.nextLoop(root, sessionKey);
401
+
402
+ /**
403
+ * why: the stop reads the handoff to decide rather than to tell the model, so a diverged file is a different
404
+ * risk — a decision taken from planted text. Withholding here means deciding from the file's absence, which is
405
+ * the same answer a first turn gets ([/decisions/ad-080.md](/decisions/ad-080.md)).
406
+ */
407
+ const stopSeal = coreFacade.handoff.handoffInjectable(root);
408
+ const handoff = stopSeal.ok
409
+ ? coreFacade.handoff.readHandoff(root, provider)
410
+ : ({} as ReturnType<typeof coreFacade.handoff.readHandoff>);
411
+ // hazard: read before the file list, because the list is diffed against it. A turn that commits moves `HEAD`
412
+ // past its own changes, and every gate below then saw an empty diff and skipped — the comment gate in a repo
413
+ // whose task was "schema v2 + tests + commit" ([/decisions/ad-058.md](/decisions/ad-058.md)).
414
+ //
415
+ // invariant: absent, this is the string `HEAD`, which is exactly the previous behaviour.
416
+ const turnBase = handoff.turn_base_sha ?? "HEAD";
417
+ /**
418
+ * why: collected rather than returned. A turn may defer more than one gate to the same neighbour, and the reply
419
+ * says so once at the end instead of three times ([/decisions/ad-073.md](/decisions/ad-073.md)).
420
+ */
421
+ const deferred: string[] = [];
422
+ const changedFiles = await listChangedRepoFiles(root, turnBase);
423
+ const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
424
+ const testTargets = filterTestTargets(changedFiles);
425
+ // why: read from the snapshot taken before this handler patches anything, so a credit written by the previous
426
+ // stop is still visible when the gate it belongs to runs below.
427
+ const pendingCredit = handoff.pending_lesson_credit;
428
+
429
+ await coreFacade.handoff.patchHandoff(root, provider, {
430
+ slice: { last_stop_status: status, last_changed_files: changedFiles, last_gate_result: "skipped" },
431
+ });
432
+
433
+ const skipVerify = existsSync(join(flagsDir(root), "skip-verify"));
434
+ const cap = coreFacade.turn.checkLoopCap(loopCount, maxLoops);
435
+
436
+ if (skipVerify || status !== "completed" || cap.capReached) {
437
+ if (cap.capReached) {
438
+ await coreFacade.handoff.patchHandoff(root, provider, {
439
+ slice: {
440
+ blockers: `Grind cap hit (${maxLoops} stop loops). Fix manually or pause gates.`,
441
+ next_action: "Inspect failures, fix root cause, then continue.",
442
+ last_failure_category: "budget",
443
+ },
444
+ });
445
+ }
446
+ return { kind: "abstain" };
447
+ }
448
+
449
+ const intel = policy.intelligence;
450
+ const unfinishedWork =
451
+ Boolean(handoff.blockers) ||
452
+ Boolean(handoff.previous_gaps?.length) ||
453
+ Boolean(handoff.pending?.length) ||
454
+ Boolean(handoff.in_progress?.length);
455
+ if (
456
+ intel.idleTurnGate &&
457
+ coreFacade.turn.endedWithoutActing({
458
+ activity: coreFacade.turn.readTurnActivity(root, event.sessionKey),
459
+ changedFiles: changedFiles.length,
460
+ hasOpenWork: unfinishedWork,
461
+ })
462
+ ) {
463
+ // hazard: this used to write `blockers`, which is one of the four fields `unfinishedWork` reads — so one
464
+ // firing manufactured its own precondition and the rail then blocked every later turn regardless of what the
465
+ // agent did. Two defects compounded: the activity counter could not rise either, so nothing cleared it and
466
+ // the operator saw the same BLOCKED four times in a row ([/decisions/ad-059.md](/decisions/ad-059.md)).
467
+ //
468
+ // invariant: this rail records what it saw and never writes a field it reads. `next_action` is not one of
469
+ // them, and the follow-up text carries the instruction anyway.
470
+ await coreFacade.handoff.patchHandoff(root, provider, {
471
+ slice: {
472
+ last_failure_category: "agent-quality",
473
+ next_action: "Attempt the work, or proceed under a stated assumption.",
474
+ },
475
+ });
476
+ return { kind: "continue", text: coreFacade.turn.idleTurnMessage() };
477
+ }
478
+
479
+ const budgetPressure =
480
+ loopCount >= intel.budgetContinueAfterLoops ||
481
+ (typeof event.contextUsagePercent === "number" && event.contextUsagePercent >= 85);
482
+
483
+ if (intel.budgetContinue && unfinishedWork && budgetPressure) {
484
+ await coreFacade.handoff.patchHandoff(root, provider, {
485
+ slice: {
486
+ last_failure_category: "budget",
487
+ next_action: coreFacade.turn.suggestionFor("budget", "budget"),
488
+ blockers: handoff.blockers ?? "Budget/continue signal: do not end early.",
489
+ },
490
+ });
491
+ return {
492
+ kind: "continue",
493
+ text: [
494
+ "BLOCKED: continue working — do not summarize or end this turn early.",
495
+ `TRIED: stop loop ${loopCount}.`,
496
+ `NEED: ${coreFacade.turn.suggestionFor("budget", "budget")}`,
497
+ ].join("\n"),
498
+ };
499
+ }
500
+
501
+ if (policy.grind.enabled && policy.grind.lintCommand && codeTargets.length > 0) {
502
+ const run = await runLockedGate({
503
+ root,
504
+ provider,
505
+ session,
506
+ pendingCredit,
507
+ sessionKey,
508
+ policy,
509
+ gate: "lint",
510
+ command: policy.grind.lintCommand,
511
+ argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.lintCommand, policy.grind.appendFiles)
512
+ ? codeTargets
513
+ : [],
514
+ recordFiles: codeTargets,
515
+ });
516
+ if (run.kind === "deferred") {
517
+ deferred.push(run.holder);
518
+ } else if (!run.artifact.passed) {
519
+ return failGate({
520
+ root,
521
+ provider,
522
+ sessionKey,
523
+ gate: "lint",
524
+ artifact: run.artifact,
525
+ loopCount,
526
+ maxLoops,
527
+ policy,
528
+ });
529
+ }
530
+ }
531
+
532
+ if (policy.grind.enabled && policy.grind.testCommand) {
533
+ // why: changed code is enough, at every posture. The narrow form ran the suite only when a test file changed,
534
+ // which skips exactly the change that most needs testing — and it made verification depend on a surfacing
535
+ // preference. Still gated by grind.enabled.
536
+ const shouldRunTests = testTargets.length > 0 || codeTargets.length > 0;
537
+ if (shouldRunTests) {
538
+ const recordFiles = testTargets.length > 0 ? testTargets : codeTargets;
539
+ const run = await runLockedGate({
540
+ root,
541
+ provider,
542
+ session,
543
+ pendingCredit,
544
+ sessionKey,
545
+ policy,
546
+ gate: "test",
547
+ command: policy.grind.testCommand,
548
+ argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.testCommand, policy.grind.appendFiles)
549
+ ? testTargets
550
+ : [],
551
+ recordFiles,
552
+ });
553
+ if (run.kind === "deferred") {
554
+ deferred.push(run.holder);
555
+ } else if (!run.artifact.passed) {
556
+ return failGate({
557
+ root,
558
+ provider,
559
+ sessionKey,
560
+ gate: "test",
561
+ artifact: run.artifact,
562
+ loopCount,
563
+ maxLoops,
564
+ policy,
565
+ });
566
+ }
567
+ }
568
+ }
569
+
570
+ // invariant: observation runs before the enforcing branch and returns nothing. It answers the question a firing
571
+ // rate cannot — was the rule ever needed — by running the checker while the prose is absent. A measurement that
572
+ // can change what it measures is not a measurement ([/decisions/ad-027.md](/decisions/ad-027.md)).
573
+ if (
574
+ codeTargets.length > 0 &&
575
+ coreFacade.observe.shouldObserve(policy.observe, "comments", policy.comments.enabled)
576
+ ) {
577
+ const hits = await coreFacade.commentPolicy.scanAddedComments(
578
+ root,
579
+ codeTargets,
580
+ policy.comments.mode,
581
+ turnBase,
582
+ );
583
+ coreFacade.observability.recordObs(root, obsConfigFor(policy), {
584
+ provider,
585
+ kind: "policy.observe",
586
+ sessionKey,
587
+ attrs: {
588
+ ...coreFacade.observe.observeAttrs({
589
+ rail: "comments",
590
+ violations: hits.length,
591
+ proseInjected: policy.comments.enabled,
592
+ }),
593
+ rule: "comments",
594
+ // why: a language the catalog does not carry produces no findings, which reads identically to "the
595
+ // property held". Naming the extensions is the difference between a clean reading and a blind spot.
596
+ unknown_extensions: coreFacade.commentPolicy.unknownExtensions(codeTargets).join(",") || "none",
597
+ },
598
+ });
599
+ }
600
+
601
+ if (policy.comments.enabled && policy.comments.onViolation === "followup" && codeTargets.length > 0) {
602
+ const hits = await coreFacade.commentPolicy.scanAddedComments(
603
+ root,
604
+ codeTargets,
605
+ policy.comments.mode,
606
+ turnBase,
607
+ );
608
+ if (hits.length > 0) {
609
+ await coreFacade.handoff.patchHandoff(root, provider, {
610
+ slice: {
611
+ last_gate_result: "fail",
612
+ blockers: `This turn added ${hits.length} undeclared comment line(s).`,
613
+ next_action: coreFacade.turn.suggestionFor("verification", "comments"),
614
+ },
615
+ });
616
+ return {
617
+ kind: "continue",
618
+ text: coreFacade.commentPolicy.commentViolationMessage(hits, policy.comments.mode),
619
+ };
620
+ }
621
+ }
622
+
623
+ /**
624
+ * why: a dependency added in a turn is code that runs on every later turn, in CI, and on every machine that
625
+ * installs the project. Two mechanical failures are worth a stop: a manifest that moved without its lockfile,
626
+ * and a specifier that names no version ([/decisions/ad-075.md](/decisions/ad-075.md)).
627
+ */
628
+ if (policy.supplyChain.enabled && changedFiles.length > 0) {
629
+ const manifests = changedFiles.filter((path) => coreFacade.supplyChain.isManifest(path));
630
+ if (manifests.length > 0) {
631
+ const added = await listAddedLines(root, manifests, turnBase);
632
+ const outcome = coreFacade.supplyChain.inspectSupplyChain({
633
+ changedFiles,
634
+ added,
635
+ readManifest: (relativePath) => {
636
+ try {
637
+ return readFileSync(join(root, relativePath), "utf8");
638
+ } catch {
639
+ return null;
640
+ }
641
+ },
642
+ });
643
+ if (outcome.findings.length > 0) {
644
+ await coreFacade.handoff.patchHandoff(root, provider, {
645
+ slice: {
646
+ last_gate_result: "fail",
647
+ blockers: `This turn changed the dependency graph in ${outcome.findings.length} way(s) that outlive it.`,
648
+ next_action: coreFacade.turn.suggestionFor("verification", "supplyChain"),
649
+ },
650
+ });
651
+ return {
652
+ kind: "continue",
653
+ text: coreFacade.supplyChain.supplyChainMessage(outcome.findings),
654
+ };
655
+ }
656
+ }
657
+ }
658
+
659
+ /**
660
+ * why: the same diff scope the comment gate uses, asking a different question — did this turn write something
661
+ * the project already has? Two copies of a run drift apart, and the second copy is where the drift starts
662
+ * ([/decisions/ad-071.md](/decisions/ad-071.md)).
663
+ */
664
+ if (policy.duplication.enabled && codeTargets.length > 0) {
665
+ const added = await listAddedLines(root, codeTargets, turnBase);
666
+ const tracked = await listTrackedFiles(root);
667
+ const scan = coreFacade.duplication.scanProject(
668
+ tracked,
669
+ (relativePath) => {
670
+ try {
671
+ return readFileSync(join(root, relativePath), "utf8");
672
+ } catch {
673
+ return null;
674
+ }
675
+ },
676
+ policy.duplication.minRun,
677
+ );
678
+ const hits = coreFacade.duplication.findDuplications(added, scan.index, policy.duplication.minRun);
679
+ if (hits.length > 0) {
680
+ await coreFacade.handoff.patchHandoff(root, provider, {
681
+ slice: {
682
+ last_gate_result: "fail",
683
+ blockers: `This turn added ${hits.length} run(s) the project already has.`,
684
+ next_action: coreFacade.turn.suggestionFor("verification", "duplication"),
685
+ },
686
+ });
687
+ return { kind: "continue", text: coreFacade.duplication.duplicationMessage(hits) };
688
+ }
689
+ }
690
+
691
+ // invariant: this is the grind pattern. The project brings the structural tool — drift, oasdiff, ast-grep
692
+ // — and the harness runs it through the same lock, artifact writer and failure path as lint and test.
693
+ // Inferring staleness from directory mapping was measured at 82-100% false reports and removed.
694
+ if (policy.docs.command && policy.docs.command.length > 0) {
695
+ const run = await runLockedGate({
696
+ root,
697
+ provider,
698
+ session,
699
+ pendingCredit,
700
+ sessionKey,
701
+ policy,
702
+ gate: "docs",
703
+ command: policy.docs.command,
704
+ argvFiles: [],
705
+ recordFiles: changedFiles,
706
+ });
707
+ if (run.kind === "deferred") {
708
+ deferred.push(run.holder);
709
+ } else if (!run.artifact.passed) {
710
+ if (policy.docs.severity === "deny") {
711
+ return failGate({
712
+ root,
713
+ provider,
714
+ sessionKey,
715
+ gate: "docs",
716
+ artifact: run.artifact,
717
+ loopCount,
718
+ maxLoops,
719
+ policy,
720
+ });
721
+ }
722
+ return {
723
+ kind: "context",
724
+ text: [
725
+ "ADVISORY: the documentation gate reported.",
726
+ `TRIED: ${policy.docs.command.join(" ")}`,
727
+ "NEED: update what it names, or accept it knowingly — this does not block the stop.",
728
+ "",
729
+ run.artifact.outputTail,
730
+ ].join("\n"),
731
+ };
732
+ }
733
+ }
734
+
735
+ // invariant: the plan gate runs before the ship gate. A turn that changed files nobody planned has an
736
+ // invalid scope, which makes any evidence it produced evidence for the wrong change.
737
+ const planDecision = coreFacade.plan.evaluatePlanGate({
738
+ enabled: policy.planGate.enabled,
739
+ declaredAt: handoff.plan_at,
740
+ windowMinutes: policy.planGate.windowMinutes,
741
+ planned: handoff.plan_paths ?? [],
742
+ deviations: handoff.plan_deviations ?? [],
743
+ changedFiles,
744
+ });
745
+ if (planDecision.kind !== "abstain") {
746
+ await coreFacade.handoff.patchHandoff(root, provider, {
747
+ slice: {
748
+ last_gate_result: "fail",
749
+ last_failure_category: "policy",
750
+ blockers: "Changed files fall outside the declared HARNESS_PLAN.",
751
+ next_action: "Revert what the plan did not call for, or justify each path with a stated reason.",
752
+ },
753
+ });
754
+ return planDecision;
755
+ }
756
+
757
+ const recentShipClaim =
758
+ handoff.last_ship_claim_kind === "structured" &&
759
+ coreFacade.ship.recentShipClaimActive(handoff.last_ship_claim_at, policy.shipGate.claimWindowMinutes);
760
+
761
+ const emptyDiffDecision = coreFacade.ship.evaluateEmptyDiffAntiShip({
762
+ enabled: policy.shipGate.enabled && policy.shipGate.emptyDiffAntiShip,
763
+ recentShipClaim,
764
+ changedFilesCount: changedFiles.length,
765
+ });
766
+ if (emptyDiffDecision.kind !== "abstain") {
767
+ await coreFacade.handoff.patchHandoff(root, provider, {
768
+ slice: {
769
+ last_gate_result: "fail",
770
+ blockers: "Structured ship claim with empty diff.",
771
+ next_action: coreFacade.turn.suggestionFor("ship-evidence", "empty-diff"),
772
+ },
773
+ });
774
+ coreFacade.ship.appendShipLedger(root, {
775
+ provider,
776
+ event: "challenge",
777
+ claimKind: "structured",
778
+ gate: "empty-diff",
779
+ detail: handoff.last_ship_claim_snippet,
780
+ });
781
+ return emptyDiffDecision;
782
+ }
783
+
784
+ const shipEvidenceDecision = coreFacade.ship.evaluateShipEvidenceGate({
785
+ enabled: policy.shipGate.enabled,
786
+ recentShipClaim,
787
+ changedFiles,
788
+ runtimePathPrefixes: policy.shipGate.runtimePathPrefixes,
789
+ runtimePathExcludes: policy.shipGate.runtimePathExcludes,
790
+ evidenceDir: policy.shipGate.evidenceDir,
791
+ evidenceMaxAgeHours: policy.shipGate.evidenceMaxAgeHours,
792
+ // why: the changed-file list is already in hand, so ordering the evidence against the code costs a stat per
793
+ // file and no git call.
794
+ evidenceNotBeforeMs: coreFacade.ship.newestChangeMs(root, changedFiles),
795
+ });
796
+ if (shipEvidenceDecision.kind !== "abstain") {
797
+ await coreFacade.handoff.patchHandoff(root, provider, {
798
+ slice: {
799
+ last_gate_result: "fail",
800
+ blockers: "HARNESS_SHIP_CLAIM without recent production evidence on runtime changes.",
801
+ next_action: coreFacade.turn.suggestionFor("ship-evidence", "ship"),
802
+ },
803
+ });
804
+ coreFacade.ship.appendShipLedger(root, {
805
+ provider,
806
+ event: "challenge",
807
+ claimKind: "structured",
808
+ gate: "ship",
809
+ files: changedFiles.slice(0, 12),
810
+ evidenceDir: policy.shipGate.evidenceDir,
811
+ detail: handoff.last_ship_claim_snippet,
812
+ });
813
+ return shipEvidenceDecision;
814
+ }
815
+
816
+ if (
817
+ policy.shipGate.enabled &&
818
+ recentShipClaim &&
819
+ policy.shipGate.evidenceDir &&
820
+ coreFacade.ship.hasRecentEvidence(policy.shipGate.evidenceDir, policy.shipGate.evidenceMaxAgeHours)
821
+ ) {
822
+ coreFacade.ship.appendShipLedger(root, {
823
+ provider,
824
+ event: "pass",
825
+ claimKind: "structured",
826
+ gate: "ship",
827
+ evidenceDir: policy.shipGate.evidenceDir,
828
+ detail: handoff.last_ship_claim_snippet,
829
+ });
830
+ }
831
+
832
+ // why: the pairing of a failure with what resolved it is captured here, immediately before the record that
833
+ // holds the failure identity is cleared. This is the one moment both halves exist
834
+ // ([/decisions/ad-028.md](/decisions/ad-028.md)).
835
+ if (handoff.last_fingerprint && handoff.last_gate_result === "fail" && changedFiles.length > 0) {
836
+ coreFacade.stagnation.recordResolution(root, handoff.last_fingerprint, {
837
+ files: changedFiles,
838
+ at: new Date().toISOString(),
839
+ gate: handoff.last_failure_category ?? "gate",
840
+ });
841
+ }
842
+
843
+ coreFacade.stagnation.clearFingerprint(root, sessionKey);
844
+ coreFacade.shellPolicy.clearShellStall(root, sessionKey);
845
+ coreFacade.turn.resetLoop(root, sessionKey);
846
+ /**
847
+ * invariant: a deferred gate is `skipped`, never `pass`. Nothing verified this turn — a neighbour session is
848
+ * verifying the same tree — and writing `pass` would put a verdict in the handoff that no gate produced
849
+ * ([/decisions/ad-073.md](/decisions/ad-073.md)).
850
+ *
851
+ * why: recorded and not narrated. `continue` is the only stop-time channel that carries text and it renders as
852
+ * `{"decision":"block"}`, so telling the turn would mean blocking it — the defect being fixed. `gate.outcome` is
853
+ * a `why` kind, so `tlc harness why` answers it after the fact.
854
+ */
855
+ const holders = [...new Set(deferred)];
856
+ await coreFacade.handoff.patchHandoff(root, provider, {
857
+ slice: {
858
+ last_gate_result: holders.length > 0 ? "skipped" : "pass",
859
+ blockers: undefined,
860
+ previous_gaps: undefined,
861
+ last_failure_category: undefined,
862
+ next_action:
863
+ holders.length > 0
864
+ ? `The grind gate deferred to ${holders.join(", ")} — that session is running it over this tree.`
865
+ : changedFiles.length > 0
866
+ ? "Continue or commit when ready."
867
+ : undefined,
868
+ fingerprint_hits: 0,
869
+ },
870
+ });
871
+ return { kind: "abstain" };
872
+ };
873
+
874
+ if (import.meta.main) {
875
+ await main(stopHandler);
876
+ }