@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,51 @@
1
+ /**
2
+ * A dependency manifest, its lockfile, and how a version is written in it.
3
+ *
4
+ * why: one table, the way the comment gate keeps one table of comment delimiters. A manifest is recognised by
5
+ * filename and nothing else, so adding an ecosystem is a row rather than a parser
6
+ * ([/decisions/ad-075.md](/decisions/ad-075.md)).
7
+ *
8
+ * invariant: a filename absent from here produces no findings and is reported as unknown. Guessing that a file
9
+ * called `deps.txt` pins versions the way `requirements.txt` does is how a checker starts refusing honest work.
10
+ */
11
+ export type ManifestEntry = {
12
+ manifest: string;
13
+ /** Null when the ecosystem has no separate lockfile, so `unlocked` cannot apply. */
14
+ lockfile: string | null;
15
+ /**
16
+ * How a dependency line reads. `json-object` is `"name": "spec"`; `requirement` is `name==spec` or `name>=spec`
17
+ * on its own line; `toml-table` is `name = "spec"`; `directive` is `require name spec`.
18
+ */
19
+ shape: "json-object" | "requirement" | "toml-table" | "directive";
20
+ };
21
+
22
+ /**
23
+ * hazard: PHP is absent, and not for a technical reason. Its manifest filename contains a word this project's
24
+ * boundary check forbids anywhere under `src/core` because it is also a provider's agent name
25
+ * ([/decisions/ad-004.md](/decisions/ad-004.md)). The architecture rule is worth more than one row, so the row
26
+ * goes and the reason is stated rather than a suppression invented
27
+ * ([/decisions/ad-075.md](/decisions/ad-075.md)).
28
+ */
29
+ export const MANIFESTS: readonly ManifestEntry[] = [
30
+ { manifest: "package.json", lockfile: "package-lock.json", shape: "json-object" },
31
+ { manifest: "requirements.txt", lockfile: null, shape: "requirement" },
32
+ { manifest: "pyproject.toml", lockfile: "poetry.lock", shape: "toml-table" },
33
+ { manifest: "Cargo.toml", lockfile: "Cargo.lock", shape: "toml-table" },
34
+ { manifest: "go.mod", lockfile: "go.sum", shape: "directive" },
35
+ { manifest: "Gemfile", lockfile: "Gemfile.lock", shape: "directive" },
36
+ ];
37
+
38
+ export function manifestFor(relativePath: string): ManifestEntry | null {
39
+ const name = relativePath.split(/[\\/]/).pop() ?? relativePath;
40
+ return MANIFESTS.find((entry) => entry.manifest === name) ?? null;
41
+ }
42
+
43
+ /** why: the alternative lockfiles an ecosystem accepts. A project using pnpm has locked just as firmly as one using npm. */
44
+ export const ALTERNATE_LOCKFILES: Readonly<Record<string, readonly string[]>> = {
45
+ "package.json": ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "npm-shrinkwrap.json", "bun.lockb"],
46
+ "pyproject.toml": ["poetry.lock", "pdm.lock", "uv.lock"],
47
+ };
48
+
49
+ export function lockfilesFor(entry: ManifestEntry): readonly string[] {
50
+ return ALTERNATE_LOCKFILES[entry.manifest] ?? (entry.lockfile === null ? [] : [entry.lockfile]);
51
+ }
@@ -0,0 +1,217 @@
1
+ import type { AddedLine } from "../../platform/git.ts";
2
+ import { lockfilesFor, type ManifestEntry, manifestFor } from "./supply-chain.catalog.ts";
3
+
4
+ /** why: the stop asks this before reading any diff, so a turn that touched no manifest costs nothing. */
5
+ export function isManifest(relativePath: string): boolean {
6
+ return manifestFor(relativePath) !== null;
7
+ }
8
+
9
+ /**
10
+ * What a turn did to the dependency graph.
11
+ *
12
+ * why: a dependency added in a turn is code that runs on every later turn, in CI, and on every machine that
13
+ * installs the project — and it arrives with none of the review the turn's own diff gets. Two mechanical failures
14
+ * are worth a finding; a new dependency as such is not, because a rail that fires on ordinary work is a rail the
15
+ * operator switches off ([/decisions/ad-075.md](/decisions/ad-075.md)).
16
+ */
17
+ export type SupplyFinding = {
18
+ kind: "unlocked" | "unpinned";
19
+ file: string;
20
+ line: number;
21
+ detail: string;
22
+ };
23
+
24
+ /** invariant: a specifier that names no version. A range is pinned enough — the lockfile decides the bytes. */
25
+ const UNPINNED_SPECS = new Set(["latest", "*", "x", "X", "", "main", "master", "HEAD"]);
26
+
27
+ /**
28
+ * why: shape-driven rather than parsed. Reading `"name": "spec"` out of an added line needs no JSON parser and
29
+ * survives a diff that shows one line of a larger object — which is the only form a diff ever shows.
30
+ */
31
+ export function dependencyOf(
32
+ text: string,
33
+ shape: ManifestEntry["shape"],
34
+ ): { name: string; spec: string } | null {
35
+ const line = text.trim().replace(/,$/, "");
36
+ switch (shape) {
37
+ case "json-object": {
38
+ const match = /^"([^"]+)"\s*:\s*"([^"]*)"$/.exec(line);
39
+ return match ? { name: match[1] as string, spec: match[2] as string } : null;
40
+ }
41
+ case "toml-table": {
42
+ const match = /^([A-Za-z0-9._-]+)\s*=\s*"([^"]*)"$/.exec(line);
43
+ return match ? { name: match[1] as string, spec: match[2] as string } : null;
44
+ }
45
+ case "requirement": {
46
+ if (line === "" || line.startsWith("#") || line.startsWith("-")) {
47
+ return null;
48
+ }
49
+ const match = /^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(?:[=<>~!]=?\s*(.+))?$/.exec(line);
50
+ return match ? { name: match[1] as string, spec: (match[2] ?? "").trim() } : null;
51
+ }
52
+ default: {
53
+ // `require github.com/x/y v1.2.3`, `gem "rails", "7.0"`
54
+ const match = /^(?:require|gem)\s+["']?([^"'\s]+)["']?(?:\s*,?\s*["']?([^"'\s]+)["']?)?$/.exec(line);
55
+ return match ? { name: match[1] as string, spec: (match[2] ?? "").trim() } : null;
56
+ }
57
+ }
58
+ }
59
+
60
+ export function isUnpinned(spec: string): boolean {
61
+ const trimmed = spec.trim();
62
+ if (UNPINNED_SPECS.has(trimmed)) {
63
+ return true;
64
+ }
65
+ // invariant: a range is pinned enough, because the lockfile decides the bytes. `^1.2.3` and `~2.0` pass; a bare
66
+ // `>=1` does not, because nothing bounds it above.
67
+ return /^>=?\s*[\d.]+$/.test(trimmed);
68
+ }
69
+
70
+ export type SupplyInput = {
71
+ /** Every path the turn changed, so a lockfile that moved is visible without reading it. */
72
+ changedFiles: readonly string[];
73
+ /** The lines the turn added, in the manifests among those paths. */
74
+ added: readonly AddedLine[];
75
+ /**
76
+ * The manifest as it stands, or null when it cannot be read.
77
+ *
78
+ * hazard: a diff shows one line, so the line alone cannot say which object it sits in. Calibrated against this
79
+ * repository's history, the textual shape read `"name": "harness-toolkit"` from a rename commit as a dependency
80
+ * — and would read every `scripts` entry the same way. The declared names are the only way to tell a dependency
81
+ * from metadata ([/decisions/ad-075.md](/decisions/ad-075.md)).
82
+ */
83
+ readManifest?: (relativePath: string) => string | null;
84
+ };
85
+
86
+ const DEPENDENCY_SECTIONS = [
87
+ "dependencies",
88
+ "devDependencies",
89
+ "peerDependencies",
90
+ "optionalDependencies",
91
+ "require",
92
+ "require-dev",
93
+ ] as const;
94
+
95
+ /**
96
+ * why: parsed, not pattern-matched. A JSON manifest is JSON, and the question "is this key a dependency" has an
97
+ * exact answer that a regex over one diff line cannot reach.
98
+ */
99
+ export function declaredDependencies(manifestText: string | null): Set<string> | null {
100
+ if (manifestText === null) {
101
+ return null;
102
+ }
103
+ let parsed: unknown;
104
+ try {
105
+ parsed = JSON.parse(manifestText);
106
+ } catch {
107
+ return null;
108
+ }
109
+ if (parsed === null || typeof parsed !== "object") {
110
+ return null;
111
+ }
112
+ const names = new Set<string>();
113
+ const record = parsed as Record<string, unknown>;
114
+ for (const section of DEPENDENCY_SECTIONS) {
115
+ const block = record[section];
116
+ if (block !== null && typeof block === "object" && !Array.isArray(block)) {
117
+ for (const name of Object.keys(block)) {
118
+ names.add(name);
119
+ }
120
+ }
121
+ }
122
+ return names;
123
+ }
124
+
125
+ export type SupplyOutcome = { findings: SupplyFinding[]; unknownManifests: string[] };
126
+
127
+ export function inspectSupplyChain(input: SupplyInput): SupplyOutcome {
128
+ const findings: SupplyFinding[] = [];
129
+ const changed = new Set(input.changedFiles.map((path) => path.split(/[\\/]/).pop() ?? path));
130
+ const manifestsTouched = new Map<string, ManifestEntry>();
131
+
132
+ for (const path of input.changedFiles) {
133
+ const entry = manifestFor(path);
134
+ if (entry !== null) {
135
+ manifestsTouched.set(path, entry);
136
+ }
137
+ }
138
+
139
+ for (const [path, entry] of manifestsTouched) {
140
+ const addedHere = input.added.filter((line) => line.file === path);
141
+ // invariant: for a JSON manifest the declared names decide. An unreadable manifest yields no findings rather
142
+ // than guessing, which is the conservative direction — a missed dependency is quieter than a refused rename.
143
+ const declared =
144
+ entry.shape === "json-object" ? declaredDependencies(input.readManifest?.(path) ?? null) : null;
145
+ const dependencies = addedHere
146
+ .map((line) => ({ line, dependency: dependencyOf(line.text, entry.shape) }))
147
+ .filter((row): row is { line: AddedLine; dependency: { name: string; spec: string } } => {
148
+ if (row.dependency === null) {
149
+ return false;
150
+ }
151
+ if (entry.shape !== "json-object") {
152
+ return true;
153
+ }
154
+ return declared?.has(row.dependency.name) === true;
155
+ });
156
+
157
+ // invariant: no added dependency means nothing to answer for. A manifest whose version bumped, or whose
158
+ // scripts changed, has not touched the dependency graph.
159
+ if (dependencies.length === 0) {
160
+ continue;
161
+ }
162
+
163
+ const locks = lockfilesFor(entry);
164
+ if (locks.length > 0 && !locks.some((lock) => changed.has(lock))) {
165
+ const head = dependencies[0] as { line: AddedLine };
166
+ findings.push({
167
+ kind: "unlocked",
168
+ file: path,
169
+ line: head.line.line,
170
+ detail: `${path} gained a dependency and none of ${locks.join(", ")} moved, so what installs is decided at install time`,
171
+ });
172
+ }
173
+
174
+ for (const { line, dependency } of dependencies) {
175
+ if (isUnpinned(dependency.spec)) {
176
+ findings.push({
177
+ kind: "unpinned",
178
+ file: path,
179
+ line: line.line,
180
+ detail: `${dependency.name} is specified as \`${dependency.spec || "(no version)"}\`, so tomorrow's bytes are not today's`,
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ const unknownManifests = input.changedFiles
187
+ .filter((path) => {
188
+ const name = path.split(/[\\/]/).pop() ?? path;
189
+ return /^(?:.*\.)?(?:lock|manifest)$/.test(name) && manifestFor(path) === null;
190
+ })
191
+ .sort();
192
+
193
+ return { findings, unknownManifests };
194
+ }
195
+
196
+ export function supplyChainMessage(findings: readonly SupplyFinding[]): string {
197
+ const unlocked = findings.filter((finding) => finding.kind === "unlocked");
198
+ const unpinned = findings.filter((finding) => finding.kind === "unpinned");
199
+ return [
200
+ `BLOCKED: this turn changed the dependency graph in ${findings.length} way(s) that outlive it.`,
201
+ "TRIED: compared the manifest lines this turn added against the commit it started from, and checked",
202
+ "whether the paired lockfile moved with them.",
203
+ ...(unlocked.length > 0
204
+ ? [
205
+ "NEED: run the ecosystem's install so the lockfile records what resolves, and commit it with the manifest.",
206
+ ]
207
+ : []),
208
+ ...(unpinned.length > 0
209
+ ? ["NEED: name a version. `latest` and `*` mean the bytes that arrive tomorrow were never reviewed."]
210
+ : []),
211
+ "If a floating specifier is deliberate, say which and why in one line and continue.",
212
+ "",
213
+ ...findings
214
+ .slice(0, 10)
215
+ .map((finding) => `${finding.file}:${finding.line} [${finding.kind}] ${finding.detail}`),
216
+ ].join("\n");
217
+ }
@@ -0,0 +1,94 @@
1
+ import { readSignalEvents } from "../observability/observability.store.ts";
2
+ import type { ObsEvent } from "../observability/observability.types.ts";
3
+
4
+ /**
5
+ * hazard: this set was right and the file it was read from was not. A tool call that succeeds resolves to the
6
+ * **debug** plane, so `obs.jsonl` — the signal plane — holds none of them. Measured on this repository's own
7
+ * state: `obs.jsonl` carried 0 `tool.end` and 0 `shell.end`, while `debug.jsonl` carried 322, 1909, and 981
8
+ * `file.edit`. So the counter read zero for every turn whose work went well, and the rail's central claim — that
9
+ * it counts what the harness recorded and therefore cannot be talked around — was inverted: nothing could
10
+ * satisfy it ([/decisions/ad-059.md](/decisions/ad-059.md)).
11
+ */
12
+ export const TOOL_KINDS = new Set([
13
+ "tool.start",
14
+ "tool.end",
15
+ "tool.fail",
16
+ "shell.start",
17
+ "shell.end",
18
+ "mcp.start",
19
+ "mcp.end",
20
+ "file.edit",
21
+ "file.read",
22
+ ]);
23
+ const TURN_START = "prompt.submit";
24
+
25
+ // why: the signal plane holds the turn boundary and the debug plane holds the work. Neither alone answers
26
+ // "did this turn do anything".
27
+ export const ACTIVITY_PLANES = ["obs.jsonl", "debug.jsonl"] as const;
28
+
29
+ export type TurnActivity = {
30
+ toolCalls: number;
31
+ sawTurnStart: boolean;
32
+ };
33
+
34
+ function forSession(event: ObsEvent, sessionKey: string): boolean {
35
+ return event.session_id === sessionKey;
36
+ }
37
+
38
+ /**
39
+ * invariant: the window boundary is a timestamp, not a position. `prompt.submit` only ever lands on the signal
40
+ * plane while the events counted inside the window come from both, and two files cannot share an index.
41
+ */
42
+ export function activitySince(events: readonly ObsEvent[], sessionKey: string): TurnActivity {
43
+ const mine = events.filter((event) => forSession(event, sessionKey));
44
+ let startTs: string | null = null;
45
+ for (const event of mine) {
46
+ if (event.kind === TURN_START && (startTs === null || event.ts > startTs)) {
47
+ startTs = event.ts;
48
+ }
49
+ }
50
+ const boundary = startTs;
51
+ // hazard: `>` dropped any event sharing the boundary's millisecond, and `toISOString` has exactly that
52
+ // resolution — two records written back to back land on the same stamp. It surfaced as a test that passed
53
+ // twice and failed on the third run of the same suite, which is the shape of a race rather than a bug in the
54
+ // rule. `>=` includes the boundary instant; `prompt.submit` is not a counted kind, so including it costs
55
+ // nothing.
56
+ const window = boundary === null ? mine : mine.filter((event) => event.ts >= boundary);
57
+ return {
58
+ toolCalls: window.filter((event) => TOOL_KINDS.has(event.kind)).length,
59
+ sawTurnStart: boundary !== null,
60
+ };
61
+ }
62
+
63
+ export function readTurnActivity(root: string, sessionKey: string, limit = 500): TurnActivity {
64
+ return activitySince(
65
+ ACTIVITY_PLANES.flatMap((plane) => readSignalEvents(root, plane, limit)),
66
+ sessionKey,
67
+ );
68
+ }
69
+
70
+ export type IdleTurnInput = {
71
+ activity: TurnActivity;
72
+ changedFiles: number;
73
+ hasOpenWork: boolean;
74
+ };
75
+
76
+ export function endedWithoutActing(input: IdleTurnInput): boolean {
77
+ if (!input.hasOpenWork) {
78
+ return false;
79
+ }
80
+ if (!input.activity.sawTurnStart) {
81
+ return false;
82
+ }
83
+ return input.activity.toolCalls === 0 && input.changedFiles === 0;
84
+ }
85
+
86
+ export function idleTurnMessage(): string {
87
+ return [
88
+ "BLOCKED: this turn ended with open work, no tool call, and no file change.",
89
+ "TRIED: counted tool events since the last prompt in this session — nothing ran.",
90
+ "NEED: attempt the work. If a decision is genuinely blocking, state the assumption you are",
91
+ "proceeding under in one line and continue; escalate only for an irreversible action, a real",
92
+ "dead-end after searching, or ambiguity that would make the result useless if guessed wrong.",
93
+ ].join("\n");
94
+ }
@@ -0,0 +1,131 @@
1
+ import type { FailureCategory } from "../gate/gate.types.ts";
2
+ import type { OperatorMode } from "../policy/policy.types.ts";
3
+ import { suggestionFor } from "./turn.failure-signals.ts";
4
+
5
+ export type AutopilotPlan = {
6
+ next_action: string;
7
+ steps: string[];
8
+ };
9
+
10
+ // hazard: this line used to read `Focus files: <changed files>` under a category whose plan says "fix each
11
+ // item explicitly". The changed files come from the diff, so under a failure they had nothing to do with, the
12
+ // plan pointed an agent at innocent code — measured naming a file no test imports while the gate output named
13
+ // the three real ones. Only evidence may read as an instruction; the diff has to say what it is.
14
+ function fileLine(failing: string[] | undefined, changed: string[] | undefined): string | null {
15
+ if (failing && failing.length > 0) {
16
+ return `Failing files (named by the gate output): ${failing.slice(0, 8).join(", ")}.`;
17
+ }
18
+ if (changed && changed.length > 0) {
19
+ return `Files the gate ran (from the diff, not necessarily the cause): ${changed.slice(0, 8).join(", ")}.`;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * invariant: these render only from the default branch below, which is reached after a gate has already failed —
26
+ * late in the work by construction. That makes this the one place where the deadline for a question is a fact
27
+ * rather than a guess, so every posture states the same settle-and-state form and none invites a question about
28
+ * ambiguity. Asking at this point is measurably worse than deciding
29
+ * ([/decisions/ad-026.md](/decisions/ad-026.md)).
30
+ *
31
+ * `paired` keeps its check-in, because that one is about an action it is *about to take*, not about ambiguity
32
+ * it should have raised earlier.
33
+ */
34
+ const POSTURE_STEP: Record<OperatorMode, string> = {
35
+ paired:
36
+ "Fix the reported issue with tool-backed evidence, showing your reasoning, and check in before any sizable non-destructive move. The work is already under way, so settle any remaining ambiguity yourself and state the assumption.",
37
+ solo: "Fix the reported issue with tool-backed evidence; do not invent success. The work is already under way, so settle remaining ambiguity by taking the most reasonable reading and stating the assumption; escalate only an irreversible action or a real dead-end.",
38
+ focus:
39
+ "Keep going until the gates pass. Settle ambiguity yourself and state the assumption; escalate only for an irreversible action or a real dead-end, with BLOCKED / TRIED / NEED.",
40
+ };
41
+
42
+ export function resolveAutopilot(args: {
43
+ category: FailureCategory;
44
+ gate: string;
45
+ mode: OperatorMode;
46
+ loopCount: number;
47
+ maxLoops: number;
48
+ /** Files the gate output itself named — evidence of where the failure is. */
49
+ failingFiles?: string[];
50
+ /** Files the gate ran against, from the diff. Context, never a culprit. */
51
+ changedFiles?: string[];
52
+ }): AutopilotPlan {
53
+ const filesHint = fileLine(args.failingFiles, args.changedFiles);
54
+ const base = suggestionFor(args.category, args.gate);
55
+
56
+ switch (args.category) {
57
+ case "verification":
58
+ return {
59
+ next_action: base,
60
+ steps: [
61
+ `Do not claim done. Gate ${args.gate} is still failing (loop ${args.loopCount + 1}/${args.maxLoops}).`,
62
+ "Read the PREVIOUS_GAPS list and fix each item explicitly.",
63
+ "Do not add suppressions, delete tests, or weaken the gate.",
64
+ filesHint ?? "Re-run only against the changed files the gate used.",
65
+ "After edits, continue — the stop hook will re-check.",
66
+ ].filter(Boolean) as string[],
67
+ };
68
+ case "stagnation":
69
+ return {
70
+ next_action: base,
71
+ steps: [
72
+ "STOP repeating the same edit/command pattern.",
73
+ "Diagnose root cause with a different tool or smaller repro.",
74
+ "If still blocked after one new approach, emit BLOCKED / TRIED / NEED to the owner.",
75
+ ],
76
+ };
77
+ case "ship-evidence":
78
+ return {
79
+ next_action: base,
80
+ steps: [
81
+ "Do not claim shipped/done yet.",
82
+ args.gate === "empty-diff"
83
+ ? "Either implement the missing work (produce a real diff) or explain why zero changes is correct."
84
+ : "Produce production evidence and cite 90-verdict.txt before claiming done.",
85
+ "Then continue — ship gate will re-check on the next stop.",
86
+ ],
87
+ };
88
+ case "budget":
89
+ return {
90
+ next_action: base,
91
+ steps: [
92
+ "Do not summarize or wrap up.",
93
+ "Prefer tool calls that advance unfinished handoff work.",
94
+ "Address PREVIOUS_GAPS if present before anything else.",
95
+ ],
96
+ };
97
+ case "policy":
98
+ return {
99
+ next_action: base,
100
+ steps: [
101
+ "Change approach to comply with policy (model allowlist, shell stall, explore read-only).",
102
+ "Do not retry the denied action with the same arguments.",
103
+ ],
104
+ };
105
+ case "config":
106
+ return {
107
+ next_action: base,
108
+ steps: ["Run harness doctor.", "Fix .tlc/harness/config.json commands/paths.", "Retry the task."],
109
+ };
110
+ default:
111
+ return {
112
+ next_action: base,
113
+ steps: [
114
+ // why: the step states the active posture's interruption threshold, which is what posture governs.
115
+ // It says nothing about gates — those do not vary by posture.
116
+ POSTURE_STEP[args.mode],
117
+ filesHint,
118
+ ].filter(Boolean) as string[],
119
+ };
120
+ }
121
+ }
122
+
123
+ export function formatAutopilotBlock(plan: AutopilotPlan): string {
124
+ const lines = plan.steps.map((step, i) => `${i + 1}. ${step}`);
125
+ return [
126
+ "AUTOPILOT (runtime-decided — execute in order; do not invent a different plan):",
127
+ ...lines,
128
+ "",
129
+ `NEXT_ACTION: ${plan.next_action}`,
130
+ ].join("\n");
131
+ }
@@ -0,0 +1,193 @@
1
+ import type { FailureCategory, GateGap } from "../gate/gate.types.ts";
2
+
3
+ export function classifyGateFailure(gate: string): FailureCategory {
4
+ if (gate === "lint" || gate === "test" || gate === "comments") {
5
+ return "verification";
6
+ }
7
+ if (gate === "ship" || gate === "empty-diff") {
8
+ return "ship-evidence";
9
+ }
10
+ if (gate === "stagnation") {
11
+ return "stagnation";
12
+ }
13
+ if (gate === "budget") {
14
+ return "budget";
15
+ }
16
+ if (gate === "policy" || gate === "shell-stall") {
17
+ return "policy";
18
+ }
19
+ return "agent-quality";
20
+ }
21
+
22
+ export function suggestionFor(category: FailureCategory, gate: string): string {
23
+ switch (category) {
24
+ case "verification":
25
+ return `Fix the ${gate} findings without suppressions or deleted tests; re-run until the gate passes.`;
26
+ case "stagnation":
27
+ return "Change approach — do not repeat the same failing edit. Inspect root cause or escalate with BLOCKED/TRIED/NEED.";
28
+ case "ship-evidence":
29
+ return "Produce real evidence (or make a real diff) before claiming done/shipped.";
30
+ case "policy":
31
+ return "Respect harness policy (models, shell, explore read-only). Adjust config only if the owner asked.";
32
+ case "budget":
33
+ return "Keep working on the task — do not summarize or end the turn early.";
34
+ case "config":
35
+ return "Check .tlc/harness/config.json commands/paths; run harness doctor.";
36
+ default:
37
+ return "Fix the reported issue and continue; do not invent success.";
38
+ }
39
+ }
40
+
41
+ export function buildGaps(args: {
42
+ gate: string;
43
+ output: string;
44
+ category: FailureCategory;
45
+ max?: number;
46
+ }): GateGap[] {
47
+ const max = args.max ?? 8;
48
+ const lines = args.output
49
+ .split("\n")
50
+ .map((l) => l.trim())
51
+ .filter((l) => l.length > 0 && !l.startsWith(">"));
52
+ const picked = lines.slice(-max);
53
+ if (picked.length === 0) {
54
+ return [
55
+ {
56
+ id: `${args.gate}-0`,
57
+ gate: args.gate,
58
+ category: args.category,
59
+ summary: `${args.gate} failed`,
60
+ },
61
+ ];
62
+ }
63
+ return picked.map((line, index) => ({
64
+ id: `${args.gate}-${index}`,
65
+ gate: args.gate,
66
+ category: args.category,
67
+ summary: line.slice(0, 200),
68
+ detail: line.length > 200 ? line.slice(0, 500) : undefined,
69
+ }));
70
+ }
71
+
72
+ export function formatGapFeedback(gaps: GateGap[], suggestion: string): string {
73
+ const body = gaps.map((g, i) => `${i + 1}. [${g.gate}/${g.category}] ${g.summary}`).join("\n");
74
+ return ["PREVIOUS_GAPS (fix these explicitly — do not ignore):", body, "", `NEXT: ${suggestion}`].join(
75
+ "\n",
76
+ );
77
+ }
78
+
79
+ export const CARRIED_GAP_LIMIT = 5;
80
+
81
+ /**
82
+ * why: `intelligence.progressiveHandoff` promised to carry gaps into the next session bootstrap, and nothing read
83
+ * the flag. `stop` wrote `previous_gaps` onto the handoff and `session.start` read `blockers` and `next_action`
84
+ * back out but never the gaps, so a resumed session started blind to the gate that was failing when the previous
85
+ * one ended — the one thing the handoff existed to carry.
86
+ *
87
+ * invariant: past tense, and named as the state the previous session ended in rather than as a list to fix now.
88
+ * The gate may already pass; only the next run of it says so, and the same list phrased as an order would send
89
+ * the turn to edit code on the strength of a stale verdict ([/decisions/ad-028.md](/decisions/ad-028.md)).
90
+ */
91
+ export function formatCarriedGaps(gaps: readonly GateGap[], limit = CARRIED_GAP_LIMIT): string {
92
+ if (gaps.length === 0) {
93
+ return "";
94
+ }
95
+ const shown = gaps.slice(0, limit);
96
+ const lines = [
97
+ "Gaps open when the previous session ended (history, not a task list — run the gate to see what still holds):",
98
+ ...shown.map((gap, index) => `${index + 1}. [${gap.gate}/${gap.category}] ${gap.summary}`),
99
+ ];
100
+ const dropped = gaps.length - shown.length;
101
+ if (dropped > 0) {
102
+ lines.push(`(${dropped} more not shown — \`tlc harness handoff\` lists all of them.)`);
103
+ }
104
+ return lines.join("\n");
105
+ }
106
+
107
+ export function mergeGaps(prior: GateGap[] | undefined, current: GateGap[], max = 12): GateGap[] {
108
+ const seen = new Set<string>();
109
+ const out: GateGap[] = [];
110
+ for (const gap of [...(prior ?? []), ...current]) {
111
+ const key = `${gap.gate}|${gap.summary}`;
112
+ if (seen.has(key)) {
113
+ continue;
114
+ }
115
+ seen.add(key);
116
+ out.push(gap);
117
+ if (out.length >= max) {
118
+ break;
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * why: the note appears from the second attempt, never the first. A project-scoping variable is set on every hook
126
+ * invocation under some providers, so mentioning it on every failure would be an alarm that fires on a healthy
127
+ * run — which is what [/decisions/ad-034.md](/decisions/ad-034.md) removed from the update digest. By the second
128
+ * attempt the cheap explanations are spent, and "perhaps it is not your code" has earned its space: this cost four
129
+ * stop loops of editing code that was not broken ([/decisions/ad-060.md](/decisions/ad-060.md)).
130
+ *
131
+ * invariant: it states the fact and the command that settles it. It never claims the variable caused the failure,
132
+ * because nothing here can know that.
133
+ */
134
+ export function formatScopedEnvNote(scopedEnv: readonly string[], command: readonly string[]): string {
135
+ if (scopedEnv.length === 0) {
136
+ return "";
137
+ }
138
+ return [
139
+ `NOTE: this gate ran with ${scopedEnv.join(", ")} set by the hook.`,
140
+ " A suite that builds fixtures in temporary directories reads the real project under those, so a failure",
141
+ " here can be the environment rather than the code. Confirm outside the hook before editing:",
142
+ ` ${command.join(" ")}`,
143
+ " If it passes there, the gate command is the thing to change — and only the operator can change it.",
144
+ ].join("\n");
145
+ }
146
+
147
+ export function formatProgressiveContext(args: {
148
+ loopCount: number;
149
+ maxLoops: number;
150
+ gate: string;
151
+ category: FailureCategory;
152
+ gaps: GateGap[];
153
+ gateOutput: string;
154
+ suggestion: string;
155
+ scopedEnv?: readonly string[];
156
+ command?: readonly string[];
157
+ }): string {
158
+ const attempt = args.loopCount + 1;
159
+ const level = args.loopCount <= 0 ? 1 : args.loopCount === 1 ? 2 : 3;
160
+ const parts: string[] = [
161
+ `PROGRESSIVE_CONTEXT level=${level} attempt=${attempt}/${args.maxLoops} gate=${args.gate} category=${args.category}`,
162
+ ];
163
+
164
+ if (level >= 2) {
165
+ parts.push(
166
+ "PRIOR ATTEMPT FAILED — do not repeat the same fix. The gaps below include earlier failures; address all of them.",
167
+ );
168
+ }
169
+ if (level >= 3) {
170
+ parts.push(
171
+ "ESCALATION: two+ stop loops without clearance. Change strategy (different files, smaller patch, or BLOCKED/TRIED/NEED). Do not re-apply the last failing edit.",
172
+ );
173
+ }
174
+ if (level >= 2) {
175
+ const note = formatScopedEnvNote(args.scopedEnv ?? [], args.command ?? []);
176
+ if (note) {
177
+ parts.push("", note);
178
+ }
179
+ }
180
+
181
+ const gapLimit = level === 1 ? 6 : level === 2 ? 10 : 12;
182
+ const outputLines = level === 1 ? 40 : level === 2 ? 80 : 120;
183
+ const trimmedGaps = args.gaps.slice(0, gapLimit);
184
+ parts.push("", formatGapFeedback(trimmedGaps, args.suggestion));
185
+
186
+ const rawLines = args.gateOutput.split("\n");
187
+ const outputSlice = rawLines.slice(-outputLines).join("\n").trim();
188
+ if (outputSlice) {
189
+ parts.push("", `GATE_OUTPUT (truncated for level ${level}):`, outputSlice);
190
+ }
191
+
192
+ return parts.join("\n");
193
+ }