@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,147 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * why: the substance of a changelog already exists in this repository as thirty decision records, each carrying why,
6
+ * the trade-offs and what was refused. What was missing is the mapping from "I updated" to "these landed". Reading
7
+ * the records is that mapping, and it needs no second index to maintain
8
+ * ([/decisions/ad-031.md](/decisions/ad-031.md)).
9
+ *
10
+ * invariant: a `migration` note is what marks a decision as needing operator action. Anything finer would be the
11
+ * harness guessing whether a given config is affected, and `doctor` already answers that precisely — the note says
12
+ * what to do, the doctor says whether it applies to you.
13
+ */
14
+ export type DecisionSummary = {
15
+ id: string;
16
+ title: string;
17
+ /** Present only when the decision requires the operator to change something. */
18
+ migration?: string;
19
+ /** why: the OKF bundle's `log.md` groups by ISO date, and the record already carries the date it was taken. */
20
+ timestamp?: string;
21
+ /** why: bundle-relative, so a link to an archived record points where the record actually is. */
22
+ path: string;
23
+ };
24
+
25
+ function frontmatterField(text: string, field: string): string | undefined {
26
+ // why: line-scoped, matching how `check-docs-bundle` reads the same files. The values here are single-line
27
+ // quoted strings by convention, and the bundle check is what enforces that.
28
+ const match = new RegExp(`^${field}:\\s*"?(.+?)"?\\s*$`, "m").exec(text);
29
+ const value = match?.[1]?.trim();
30
+ if (value === undefined || value === "") {
31
+ return undefined;
32
+ }
33
+ // hazard: an escaped quote inside the value survived the outer-quote strip and reached the operator as a literal
34
+ // `\"` in their terminal. Seen in a real update run ([/decisions/ad-034.md](/decisions/ad-034.md)).
35
+ return value.replace(/\\(["'\\])/g, "$1");
36
+ }
37
+
38
+ export function decisionsDir(repoRoot: string): string {
39
+ return join(repoRoot, "docs", "decisions");
40
+ }
41
+
42
+ /** why: an absent docs directory is an empty list, not an error. A linked checkout may not carry docs at all. */
43
+ export function readDecision(repoRoot: string, file: string): DecisionSummary | null {
44
+ const path = join(decisionsDir(repoRoot), file);
45
+ if (!existsSync(path)) {
46
+ return null;
47
+ }
48
+ let text: string;
49
+ try {
50
+ text = readFileSync(path, "utf8");
51
+ } catch {
52
+ return null;
53
+ }
54
+ const title = frontmatterField(text, "title");
55
+ if (title === undefined) {
56
+ return null;
57
+ }
58
+ // invariant: the id is the file's own name, so an archived record keeps the id it shipped under.
59
+ const id = (file.split(/[\\/]/).pop() ?? file).replace(/\.md$/, "").toUpperCase();
60
+ const migration = frontmatterField(text, "migration");
61
+ const timestamp = frontmatterField(text, "timestamp");
62
+ return {
63
+ id,
64
+ title,
65
+ path: `/decisions/${file.split(/[\\/]/).join("/")}`,
66
+ ...(migration === undefined ? {} : { migration }),
67
+ ...(timestamp === undefined ? {} : { timestamp }),
68
+ };
69
+ }
70
+
71
+ export function readDecisions(repoRoot: string, files: string[]): DecisionSummary[] {
72
+ return files
73
+ .filter((file) => /(?:^|[\\/])ad-\d+\.md$/.test(file))
74
+ .map((file) => readDecision(repoRoot, file))
75
+ .filter((decision): decision is DecisionSummary => decision !== null)
76
+ .sort((a, b) => a.id.localeCompare(b.id));
77
+ }
78
+
79
+ /**
80
+ * why: an archived record still shipped, so it keeps its row in the changelog and in the dated log. Only the
81
+ * decisions index distinguishes active from archived, because only the index claims to describe what currently
82
+ * binds ([/decisions/ad-072.md](/decisions/ad-072.md)).
83
+ */
84
+ export const ARCHIVED_DIR = "archived";
85
+
86
+ export function allDecisionFiles(repoRoot: string): string[] {
87
+ const dir = decisionsDir(repoRoot);
88
+ const found: string[] = [];
89
+ for (const sub of ["", ARCHIVED_DIR]) {
90
+ const full = sub === "" ? dir : join(dir, sub);
91
+ if (!existsSync(full)) {
92
+ continue;
93
+ }
94
+ try {
95
+ for (const file of readdirSync(full)) {
96
+ if (/^ad-\d+\.md$/.test(file)) {
97
+ found.push(sub === "" ? file : join(sub, file));
98
+ }
99
+ }
100
+ } catch {
101
+ // why: an unreadable directory is an empty one. A linked checkout may not carry docs at all.
102
+ }
103
+ }
104
+ return found;
105
+ }
106
+
107
+ export function needsAction(decisions: readonly DecisionSummary[]): DecisionSummary[] {
108
+ return decisions.filter((decision) => decision.migration !== undefined);
109
+ }
110
+
111
+ /** why: `AD-031 — Some title` → `AD-031`, so an id can trail a sentence instead of leading it. */
112
+ function idOf(decision: DecisionSummary): string {
113
+ return decision.id;
114
+ }
115
+
116
+ /**
117
+ * hazard: the first version led with each decision's *title*, which describes the author's reasoning rather than the
118
+ * operator's situation, and printed `NEEDS YOUR ACTION` for every note. In a real update run both notes said "run
119
+ * `tlc harness doctor`" — which `update` then did automatically, three lines below — and neither applied to that
120
+ * project. An alarm that fires on every update is one the reader learns to scroll past, and the next one that matters
121
+ * goes with it ([/decisions/ad-034.md](/decisions/ad-034.md)).
122
+ *
123
+ * invariant: the note is the headline and the decision id trails it. The heading appears only when a note exists, and
124
+ * it says what a note now means: something `doctor` cannot detect for you.
125
+ */
126
+ export function formatDecisionDigest(decisions: readonly DecisionSummary[]): string {
127
+ if (decisions.length === 0) {
128
+ return "";
129
+ }
130
+ const action = needsAction(decisions);
131
+ const lines: string[] = [
132
+ `Harness updated. ${decisions.length} decision(s) landed; doctor runs below and reports what applies here.`,
133
+ ];
134
+ if (action.length > 0) {
135
+ lines.push("", `Needs a change doctor cannot detect for you (${action.length}):`);
136
+ for (const decision of action) {
137
+ lines.push(` ${decision.migration} (${idOf(decision)})`);
138
+ }
139
+ }
140
+ const rest = decisions.filter((decision) => decision.migration === undefined);
141
+ if (rest.length > 0) {
142
+ lines.push("", `Also landed: ${rest.map(idOf).join(", ")} — docs/decisions/index.md`);
143
+ } else {
144
+ lines.push("", "Full reasoning: docs/decisions/index.md");
145
+ }
146
+ return lines.join("\n");
147
+ }
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeJsonAtomic } from "../../platform/fs-atomic.ts";
4
+ import { projectStateDir } from "../../platform/paths.ts";
5
+
6
+ /**
7
+ * why: mirrors the capability seen marker rather than inventing a second mechanism. That marker already established
8
+ * both the shape and the reason — an announcement that repeats becomes noise, and noise is filtered out by the
9
+ * reader, which is how a real warning gets missed ([/decisions/ad-031.md](/decisions/ad-031.md)).
10
+ *
11
+ * invariant: per project, not per machine. Two repositories on one install each hear about a decision once, because
12
+ * whether a decision matters is a property of the project's config, not of the machine.
13
+ */
14
+ export type ReleaseSeen = { revision: string; updatedAt?: string };
15
+
16
+ function seenPath(projectDir: string): string {
17
+ return join(projectStateDir(projectDir), "release-seen.json");
18
+ }
19
+
20
+ export function readReleaseSeen(projectDir: string): ReleaseSeen | null {
21
+ const path = seenPath(projectDir);
22
+ if (!existsSync(path)) {
23
+ return null;
24
+ }
25
+ try {
26
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as ReleaseSeen;
27
+ return typeof parsed?.revision === "string" && parsed.revision !== "" ? parsed : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export async function writeReleaseSeen(projectDir: string, revision: string): Promise<void> {
34
+ await writeJsonAtomic(seenPath(projectDir), {
35
+ revision,
36
+ updatedAt: new Date().toISOString(),
37
+ } satisfies ReleaseSeen);
38
+ }
@@ -0,0 +1,215 @@
1
+ import type { Decision } from "../../contracts/decision.ts";
2
+ import { type ShellWord, tokenizeShell } from "../floor/floor.tokenize.ts";
3
+ import type { OperatorMode } from "../policy/policy.types.ts";
4
+ import { trackShellCommand } from "./shell-policy.stall.ts";
5
+ import type { ShellEffectClass } from "./shell-policy.types.ts";
6
+
7
+ const WRAPPERS = new Set(["command", "doas", "env", "nice", "nohup", "sudo", "time", "xargs"]);
8
+ const MACHINE = new Set(["halt", "poweroff", "reboot", "shutdown"]);
9
+ const NETWORK = new Set(["curl", "ftp", "gh", "nc", "ncat", "rsync", "scp", "sftp", "ssh", "telnet", "wget"]);
10
+ // invariant: a verb belongs in WRITE when it can remove or overwrite a path that already exists. `cp` and `mv`
11
+ // qualify because the destination may exist and the harness cannot know whether it does — conservative wherever
12
+ // the filesystem is unknowable. `tee` qualifies with or without `-a`, because the flagless form truncates and a
13
+ // rule whose failure mode is silent data loss does not get a special case.
14
+ const WRITE = new Set(["cp", "mv", "rm", "rmdir", "tee", "truncate"]);
15
+ // hazard: these lived in the preserving tier for one revision, on the reasoning that they lose no bytes. True and
16
+ // beside the point: they decide who can read or execute a path, `-R` applies that to a whole tree, and the result
17
+ // appears in no diff. Losing content and widening access are separate questions.
18
+ const PRIVILEGE = new Set(["chmod", "chown"]);
19
+ const DEVICE = /^\/dev\/(sd|nvme|vd|hd|disk)/;
20
+
21
+ // invariant: classification reads head verbs from the shared tokenizer, never patterns in the raw
22
+ // string, so quoted text and heredoc bodies stay data rather than commands.
23
+ function classifySegment(words: ShellWord[]): ShellEffectClass {
24
+ let index = 0;
25
+ while (index < words.length) {
26
+ const word = words[index];
27
+ if (!word) {
28
+ break;
29
+ }
30
+ if (WRAPPERS.has(word.text) || word.text.startsWith("-") || word.text.includes("=")) {
31
+ index += 1;
32
+ continue;
33
+ }
34
+ const verb = word.text.split("/").pop() ?? word.text;
35
+ const args = words.slice(index + 1);
36
+ const argText = args.map((arg) => arg.text);
37
+
38
+ if (MACHINE.has(verb) || verb === "mkfs" || verb.startsWith("mkfs.")) {
39
+ return "destructive";
40
+ }
41
+ if (verb === "dd" && argText.some((arg) => arg.startsWith("of=") && DEVICE.test(arg.slice(3)))) {
42
+ return "destructive";
43
+ }
44
+ if (
45
+ verb === "diskutil" &&
46
+ argText.some((arg) => arg.startsWith("erase") || arg.startsWith("partition"))
47
+ ) {
48
+ return "destructive";
49
+ }
50
+ if (verb === "rm" && argText.some((arg) => arg === "/" || arg === "/*" || arg.startsWith("../../"))) {
51
+ return "destructive";
52
+ }
53
+ if (NETWORK.has(verb)) {
54
+ return "network";
55
+ }
56
+ if ((verb === "git" || verb === "docker") && argText.includes("push")) {
57
+ return "network";
58
+ }
59
+ if (WRITE.has(verb) || (verb === "sed" && argText.includes("-i"))) {
60
+ return "write";
61
+ }
62
+ if (PRIVILEGE.has(verb)) {
63
+ return "privilege";
64
+ }
65
+ // hazard: these two used to collapse into one branch, so an append was indistinguishable from an overwrite.
66
+ // `>` truncates the target and `>>` does not, which is exactly the line this class exists to draw.
67
+ if (argText.includes(">")) {
68
+ return "write";
69
+ }
70
+ return argText.includes(">>") ? "write-preserving" : "read";
71
+ }
72
+ return "read";
73
+ }
74
+
75
+ const ORDER: ShellEffectClass[] = [
76
+ "read",
77
+ "write-preserving",
78
+ "write",
79
+ "privilege",
80
+ "network",
81
+ "destructive",
82
+ ];
83
+
84
+ export function classifyShell(command: string): ShellEffectClass {
85
+ let worst: ShellEffectClass = "read";
86
+ for (const segment of tokenizeShell(command)) {
87
+ const found = classifySegment(segment.words);
88
+ if (ORDER.indexOf(found) > ORDER.indexOf(worst)) {
89
+ worst = found;
90
+ }
91
+ }
92
+ return worst;
93
+ }
94
+
95
+ export function isCatastrophic(command: string): boolean {
96
+ return classifyShell(command) === "destructive";
97
+ }
98
+
99
+ function stallFollowup(command: string, hits: number): string {
100
+ return [
101
+ `BLOCKED: shell stall — the same command was attempted ${hits} times.`,
102
+ `TRIED: \`${command.slice(0, 160)}\``,
103
+ "NEED: change approach. Do not repeat this command. Diagnose why it failed, use a different tool/path, or escalate with BLOCKED/TRIED/NEED.",
104
+ ].join("\n");
105
+ }
106
+
107
+ export type EvaluateShellCommandArgs = {
108
+ command: string;
109
+ sessionKey: string;
110
+ projectDir: string;
111
+ /** The active operator posture. Only `paired` lowers the interruption threshold. */
112
+ mode: OperatorMode;
113
+ catastrophicAsk: boolean;
114
+ stallDetection: boolean;
115
+ stallRepeatThreshold: number;
116
+ };
117
+
118
+ /**
119
+ * why: `paired` promises a check-in before a sizable non-destructive move, and `classifyShell` answers what
120
+ * "sizable" means — a command that can lose something (`write`) or that leaves the machine (`network`).
121
+ *
122
+ * hazard: this used to include the whole `write` class, which asked about appending to a file. Repeated approval
123
+ * of routine actions becomes a keystroke rather than a decision, and a habituated reviewer is the delivery
124
+ * mechanism for the one action that mattered — approval fatigue is a security defect, not an ergonomics
125
+ * complaint. Only `write-preserving` left the tier ([/decisions/ad-026.md](/decisions/ad-026.md)).
126
+ *
127
+ * hazard: `chmod` and `chown` left it too for one revision, and should not have. They lose no bytes, which was
128
+ * the criterion, and they still decide who can reach a path — the one change that shows up in no diff. `privilege`
129
+ * is a member here for that reason.
130
+ *
131
+ * invariant: this is a separate rule from `catastrophicAsk`, not an override of it. That switch keeps deciding
132
+ * `destructive` at every posture; posture decides the tier below it. A posture that switched a capability off
133
+ * would be the defect the posture feature exists to remove.
134
+ *
135
+ * hazard: the threshold is deliberately not applied to tool edits. An `Edit` of one line is a write too, and
136
+ * asking before every one turns a posture into a permission prompt.
137
+ */
138
+ const PAIRED_ASK: ReadonlySet<ShellEffectClass> = new Set(["write", "privilege", "network"]);
139
+
140
+ // why: named here rather than at the call sites so the recorded rate and the rule that produced it cannot drift
141
+ // apart. An operator reading "seven asks" needs to know which switch to reach for.
142
+ export const SHELL_RULES = {
143
+ catastrophic: "shell-catastrophic",
144
+ posture: "shell-posture-paired",
145
+ stall: "shell-stall",
146
+ } as const;
147
+
148
+ // why: three tiers ask, and each asks a different question. One sentence covering all of them would leave the
149
+ // operator weighing the wrong risk, which fails in the same direction as not asking at all.
150
+ const AT_STAKE: Record<string, string> = {
151
+ network: "reaches the network, so it leaves this machine and cannot be pulled back",
152
+ privilege: "changes who can reach a path, and that will not appear in any diff",
153
+ write: "can overwrite or remove a path that already exists",
154
+ };
155
+
156
+ function atStake(effect: ShellEffectClass): string {
157
+ return AT_STAKE[effect] ?? "changes something outside this turn";
158
+ }
159
+
160
+ function pairedPreCheck(command: string, mode: OperatorMode): Decision | null {
161
+ if (mode !== "paired") {
162
+ return null;
163
+ }
164
+ const effect = classifyShell(command);
165
+ if (!PAIRED_ASK.has(effect)) {
166
+ return null;
167
+ }
168
+ // hazard: the reason used to end "leave the posture with `tlc harness mode solo`" — an instruction aimed at the
169
+ // agent, which the floor refuses from inside a session. `reason` is the agent's half and `userNote` is the
170
+ // operator's; the way out of a posture belongs in the operator's ([/decisions/ad-030.md](/decisions/ad-030.md)).
171
+ return {
172
+ kind: "ask",
173
+ reason: `Posture paired: this command ${atStake(effect)}, and the operator asked to see these before they run. Wait for their answer — the posture is theirs to change, not yours.`,
174
+ userNote: `Paired posture: this ${effect} command ${atStake(effect)}. Approve it, or leave the posture with \`tlc harness mode solo\`.`,
175
+ rule: SHELL_RULES.posture,
176
+ };
177
+ }
178
+
179
+ export function evaluateShellCommand(args: EvaluateShellCommandArgs): Decision {
180
+ const command = args.command;
181
+ if (!command) {
182
+ return { kind: "allow" };
183
+ }
184
+
185
+ // why: destructive is decided first and identically at every posture — it is the one stop that never
186
+ // narrows. The paired rule governs what sits below it.
187
+ if (args.catastrophicAsk && isCatastrophic(command)) {
188
+ return {
189
+ kind: "ask",
190
+ reason:
191
+ "The command was flagged as potentially catastrophic. Prefer scoped paths inside the repo or reversible operations.",
192
+ userNote: "This shell command can destroy data outside the workspace. Approve only if you intend it.",
193
+ rule: SHELL_RULES.catastrophic,
194
+ };
195
+ }
196
+
197
+ const preCheck = pairedPreCheck(command, args.mode);
198
+ if (preCheck) {
199
+ return preCheck;
200
+ }
201
+
202
+ if (args.stallDetection) {
203
+ const hits = trackShellCommand(args.projectDir, args.sessionKey, command);
204
+ if (hits >= args.stallRepeatThreshold) {
205
+ return {
206
+ kind: "deny",
207
+ reason: stallFollowup(command, hits),
208
+ userNote: `Harness blocked a repeated shell command (${hits}x).`,
209
+ rule: SHELL_RULES.stall,
210
+ };
211
+ }
212
+ }
213
+
214
+ return { kind: "allow" };
215
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { projectStateDir } from "../../platform/paths.ts";
4
+ import type { ShellStallEntry, ShellStallStore } from "./shell-policy.types.ts";
5
+
6
+ function storePath(root: string): string {
7
+ return join(projectStateDir(root), "shell-stall.json");
8
+ }
9
+
10
+ function readStore(root: string): ShellStallStore {
11
+ const path = storePath(root);
12
+ if (!existsSync(path)) {
13
+ return {};
14
+ }
15
+ try {
16
+ return JSON.parse(readFileSync(path, "utf8")) as ShellStallStore;
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function writeStore(root: string, store: ShellStallStore): void {
23
+ try {
24
+ mkdirSync(projectStateDir(root), { recursive: true });
25
+ writeFileSync(storePath(root), `${JSON.stringify(store, null, 2)}\n`, "utf8");
26
+ } catch {}
27
+ }
28
+
29
+ function normalizeCommand(command: string): string {
30
+ return command.trim().replace(/\s+/g, " ").slice(0, 300);
31
+ }
32
+
33
+ export function trackShellCommand(root: string, sessionKey: string, command: string): number {
34
+ const normalized = normalizeCommand(command);
35
+ if (!normalized) {
36
+ return 0;
37
+ }
38
+ const store = readStore(root);
39
+ const current: ShellStallEntry = store[sessionKey] ?? { hits: 0 };
40
+ const next: ShellStallEntry =
41
+ current.lastCommand === normalized
42
+ ? { lastCommand: normalized, hits: current.hits + 1 }
43
+ : { lastCommand: normalized, hits: 1 };
44
+ store[sessionKey] = next;
45
+ writeStore(root, store);
46
+ return next.hits;
47
+ }
48
+
49
+ export function clearShellStall(root: string, sessionKey: string): void {
50
+ const store = readStore(root);
51
+ store[sessionKey] = { hits: 0 };
52
+ writeStore(root, store);
53
+ }
54
+
55
+ export function shellStallHits(root: string, sessionKey: string): number {
56
+ return readStore(root)[sessionKey]?.hits ?? 0;
57
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * why: `write-preserving` sits between `read` and `write` because "changes something" and "can lose something"
3
+ * are different questions, and only the second is worth interrupting an operator for. Repeated approval of the
4
+ * first becomes reflex, and a habituated reviewer is how the one consequential action gets waved through
5
+ * ([/decisions/ad-026.md](/decisions/ad-026.md)).
6
+ *
7
+ * hazard: `privilege` exists because that split, on its own, was the wrong axis for `chmod` and `chown`. Neither
8
+ * loses a byte, so both were classified as preserving and stopped being asked about — which let
9
+ * `chmod -R 777 .` through unremarked. Access is a third question, and the one whose answer never shows up in a
10
+ * diff. It ranks above `write` for that reason: an overwrite inside a repository is recoverable and visible,
11
+ * a widened permission is neither.
12
+ */
13
+ export type ShellEffectClass =
14
+ | "read"
15
+ | "write-preserving"
16
+ | "write"
17
+ | "privilege"
18
+ | "network"
19
+ | "destructive";
20
+
21
+ export type ShellStallEntry = {
22
+ lastCommand?: string;
23
+ hits: number;
24
+ };
25
+
26
+ export type ShellStallStore = Record<string, ShellStallEntry>;
@@ -0,0 +1,76 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { appendRecord, readTail } from "../../platform/fs-jsonl.ts";
4
+ import { projectStateDir } from "../../platform/paths.ts";
5
+ import type { ShipLedgerRow } from "./ship.types.ts";
6
+
7
+ export function shipLedgerPath(root: string): string {
8
+ return join(projectStateDir(root), "ship-ledger.jsonl");
9
+ }
10
+
11
+ export function appendShipLedger(root: string, row: Omit<ShipLedgerRow, "ts"> & { ts?: string }): void {
12
+ const full: ShipLedgerRow = { ...row, ts: row.ts ?? new Date().toISOString() };
13
+ appendRecord(shipLedgerPath(root), full);
14
+ }
15
+
16
+ export function readShipLedger(root: string): ShipLedgerRow[] {
17
+ return readTail<ShipLedgerRow>(shipLedgerPath(root), Number.MAX_SAFE_INTEGER);
18
+ }
19
+
20
+ /**
21
+ * hazard: this asked one question — is the verdict fresh? — and freshness is not what makes evidence evidence. A
22
+ * verdict written ten minutes ago passed while the code it supposedly certified changed five minutes ago. Evidence
23
+ * that predates the change proves nothing about it, and the gate accepted it silently
24
+ * ([/decisions/ad-027.md](/decisions/ad-027.md)).
25
+ *
26
+ * invariant: ordering is checked first and age second. Age still earns its place — it catches a verdict left over
27
+ * from last week when nothing changed at all — but only ordering can see the case above.
28
+ *
29
+ * why: `notBeforeMs` is a parameter rather than something read here, so the caller supplies the timestamp it
30
+ * already has. When it is absent the age window decides alone, which is the pre-existing behaviour: a missing
31
+ * input must not fail closed on a gate that blocks a stop.
32
+ */
33
+ export function hasRecentEvidence(evidenceDir: string, maxAgeHours: number, notBeforeMs?: number): boolean {
34
+ if (!existsSync(evidenceDir)) {
35
+ return false;
36
+ }
37
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
38
+ const now = Date.now();
39
+ for (const entry of readdirSync(evidenceDir)) {
40
+ const verdictPath = join(evidenceDir, entry, "90-verdict.txt");
41
+ if (!existsSync(verdictPath)) {
42
+ continue;
43
+ }
44
+ try {
45
+ const writtenAt = statSync(verdictPath).mtimeMs;
46
+ if (notBeforeMs !== undefined && writtenAt < notBeforeMs) {
47
+ continue;
48
+ }
49
+ if (now - writtenAt > maxAgeMs) {
50
+ continue;
51
+ }
52
+ if (/\bPASS\b/i.test(readFileSync(verdictPath, "utf8"))) {
53
+ return true;
54
+ }
55
+ } catch {}
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * why: the newest mtime among the files the turn changed. The stop path already has that list, so ordering costs
62
+ * a stat per changed file and no git call. Returns undefined when there is nothing to compare against, which is
63
+ * the signal to let the age window decide.
64
+ */
65
+ export function newestChangeMs(root: string, relativePaths: string[]): number | undefined {
66
+ let newest: number | undefined;
67
+ for (const relative of relativePaths) {
68
+ try {
69
+ const at = statSync(join(root, relative)).mtimeMs;
70
+ if (newest === undefined || at > newest) {
71
+ newest = at;
72
+ }
73
+ } catch {}
74
+ }
75
+ return newest;
76
+ }