@lannguyensi/harness 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/dist/cli/add/index.d.ts +14 -0
  5. package/dist/cli/add/index.js +71 -0
  6. package/dist/cli/add/index.js.map +1 -0
  7. package/dist/cli/add/mutate.d.ts +39 -0
  8. package/dist/cli/add/mutate.js +36 -0
  9. package/dist/cli/add/mutate.js.map +1 -0
  10. package/dist/cli/adopt/derive.d.ts +38 -0
  11. package/dist/cli/adopt/derive.js +94 -0
  12. package/dist/cli/adopt/derive.js.map +1 -0
  13. package/dist/cli/adopt/index.d.ts +20 -0
  14. package/dist/cli/adopt/index.js +156 -0
  15. package/dist/cli/adopt/index.js.map +1 -0
  16. package/dist/cli/apply/apply.d.ts +49 -0
  17. package/dist/cli/apply/apply.js +333 -0
  18. package/dist/cli/apply/apply.js.map +1 -0
  19. package/dist/cli/apply/generate-memory-index.d.ts +17 -0
  20. package/dist/cli/apply/generate-memory-index.js +167 -0
  21. package/dist/cli/apply/generate-memory-index.js.map +1 -0
  22. package/dist/cli/apply/generate-settings.d.ts +15 -0
  23. package/dist/cli/apply/generate-settings.js +87 -0
  24. package/dist/cli/apply/generate-settings.js.map +1 -0
  25. package/dist/cli/apply/index.d.ts +1 -0
  26. package/dist/cli/apply/index.js +2 -0
  27. package/dist/cli/apply/index.js.map +1 -0
  28. package/dist/cli/audit.d.ts +36 -0
  29. package/dist/cli/audit.js +121 -0
  30. package/dist/cli/audit.js.map +1 -0
  31. package/dist/cli/describe.d.ts +13 -0
  32. package/dist/cli/describe.js +26 -0
  33. package/dist/cli/describe.js.map +1 -0
  34. package/dist/cli/diff/engine.d.ts +21 -0
  35. package/dist/cli/diff/engine.js +161 -0
  36. package/dist/cli/diff/engine.js.map +1 -0
  37. package/dist/cli/diff/git.d.ts +6 -0
  38. package/dist/cli/diff/git.js +32 -0
  39. package/dist/cli/diff/git.js.map +1 -0
  40. package/dist/cli/diff/index.d.ts +15 -0
  41. package/dist/cli/diff/index.js +39 -0
  42. package/dist/cli/diff/index.js.map +1 -0
  43. package/dist/cli/diff/since-apply.d.ts +57 -0
  44. package/dist/cli/diff/since-apply.js +255 -0
  45. package/dist/cli/diff/since-apply.js.map +1 -0
  46. package/dist/cli/doctor/format.d.ts +2 -0
  47. package/dist/cli/doctor/format.js +126 -0
  48. package/dist/cli/doctor/format.js.map +1 -0
  49. package/dist/cli/doctor/index.d.ts +14 -0
  50. package/dist/cli/doctor/index.js +281 -0
  51. package/dist/cli/doctor/index.js.map +1 -0
  52. package/dist/cli/doctor/types.d.ts +46 -0
  53. package/dist/cli/doctor/types.js +2 -0
  54. package/dist/cli/doctor/types.js.map +1 -0
  55. package/dist/cli/dry-run.d.ts +46 -0
  56. package/dist/cli/dry-run.js +168 -0
  57. package/dist/cli/dry-run.js.map +1 -0
  58. package/dist/cli/exit-codes.d.ts +10 -0
  59. package/dist/cli/exit-codes.js +15 -0
  60. package/dist/cli/exit-codes.js.map +1 -0
  61. package/dist/cli/explain.d.ts +14 -0
  62. package/dist/cli/explain.js +97 -0
  63. package/dist/cli/explain.js.map +1 -0
  64. package/dist/cli/export.d.ts +31 -0
  65. package/dist/cli/export.js +84 -0
  66. package/dist/cli/export.js.map +1 -0
  67. package/dist/cli/index.d.ts +8 -0
  68. package/dist/cli/index.js +549 -0
  69. package/dist/cli/index.js.map +1 -0
  70. package/dist/cli/init/index.d.ts +17 -0
  71. package/dist/cli/init/index.js +57 -0
  72. package/dist/cli/init/index.js.map +1 -0
  73. package/dist/cli/init/templates.d.ts +4 -0
  74. package/dist/cli/init/templates.js +175 -0
  75. package/dist/cli/init/templates.js.map +1 -0
  76. package/dist/cli/list.d.ts +12 -0
  77. package/dist/cli/list.js +118 -0
  78. package/dist/cli/list.js.map +1 -0
  79. package/dist/cli/loader.d.ts +24 -0
  80. package/dist/cli/loader.js +74 -0
  81. package/dist/cli/loader.js.map +1 -0
  82. package/dist/cli/main.d.ts +2 -0
  83. package/dist/cli/main.js +6 -0
  84. package/dist/cli/main.js.map +1 -0
  85. package/dist/cli/policy/intercept.d.ts +34 -0
  86. package/dist/cli/policy/intercept.js +172 -0
  87. package/dist/cli/policy/intercept.js.map +1 -0
  88. package/dist/cli/remove/index.d.ts +18 -0
  89. package/dist/cli/remove/index.js +95 -0
  90. package/dist/cli/remove/index.js.map +1 -0
  91. package/dist/cli/remove/mutate.d.ts +9 -0
  92. package/dist/cli/remove/mutate.js +68 -0
  93. package/dist/cli/remove/mutate.js.map +1 -0
  94. package/dist/cli/validate/checks.d.ts +23 -0
  95. package/dist/cli/validate/checks.js +253 -0
  96. package/dist/cli/validate/checks.js.map +1 -0
  97. package/dist/cli/validate/index.d.ts +18 -0
  98. package/dist/cli/validate/index.js +50 -0
  99. package/dist/cli/validate/index.js.map +1 -0
  100. package/dist/cli/validate/types.d.ts +7 -0
  101. package/dist/cli/validate/types.js +5 -0
  102. package/dist/cli/validate/types.js.map +1 -0
  103. package/dist/index.d.ts +15 -0
  104. package/dist/index.js +16 -0
  105. package/dist/index.js.map +1 -0
  106. package/dist/io/atomic-write.d.ts +8 -0
  107. package/dist/io/atomic-write.js +30 -0
  108. package/dist/io/atomic-write.js.map +1 -0
  109. package/dist/io/harness-lock.d.ts +33 -0
  110. package/dist/io/harness-lock.js +260 -0
  111. package/dist/io/harness-lock.js.map +1 -0
  112. package/dist/io/last-apply.d.ts +20 -0
  113. package/dist/io/last-apply.js +123 -0
  114. package/dist/io/last-apply.js.map +1 -0
  115. package/dist/io/lock.d.ts +11 -0
  116. package/dist/io/lock.js +33 -0
  117. package/dist/io/lock.js.map +1 -0
  118. package/dist/io/patch.d.ts +10 -0
  119. package/dist/io/patch.js +8 -0
  120. package/dist/io/patch.js.map +1 -0
  121. package/dist/io/restart-hints.d.ts +5 -0
  122. package/dist/io/restart-hints.js +59 -0
  123. package/dist/io/restart-hints.js.map +1 -0
  124. package/dist/io/three-state.d.ts +7 -0
  125. package/dist/io/three-state.js +20 -0
  126. package/dist/io/three-state.js.map +1 -0
  127. package/dist/io/validate-before-write.d.ts +12 -0
  128. package/dist/io/validate-before-write.js +23 -0
  129. package/dist/io/validate-before-write.js.map +1 -0
  130. package/dist/overrides/index.d.ts +2 -0
  131. package/dist/overrides/index.js +3 -0
  132. package/dist/overrides/index.js.map +1 -0
  133. package/dist/overrides/machines.d.ts +12 -0
  134. package/dist/overrides/machines.js +46 -0
  135. package/dist/overrides/machines.js.map +1 -0
  136. package/dist/overrides/merge.d.ts +6 -0
  137. package/dist/overrides/merge.js +173 -0
  138. package/dist/overrides/merge.js.map +1 -0
  139. package/dist/policies/duration.d.ts +5 -0
  140. package/dist/policies/duration.js +50 -0
  141. package/dist/policies/duration.js.map +1 -0
  142. package/dist/policies/extract.d.ts +50 -0
  143. package/dist/policies/extract.js +190 -0
  144. package/dist/policies/extract.js.map +1 -0
  145. package/dist/policies/index.d.ts +5 -0
  146. package/dist/policies/index.js +6 -0
  147. package/dist/policies/index.js.map +1 -0
  148. package/dist/policies/ledger-client.d.ts +39 -0
  149. package/dist/policies/ledger-client.js +378 -0
  150. package/dist/policies/ledger-client.js.map +1 -0
  151. package/dist/policies/requires.d.ts +44 -0
  152. package/dist/policies/requires.js +146 -0
  153. package/dist/policies/requires.js.map +1 -0
  154. package/dist/policies/timestamp.d.ts +14 -0
  155. package/dist/policies/timestamp.js +36 -0
  156. package/dist/policies/timestamp.js.map +1 -0
  157. package/dist/probes/mcp.d.ts +29 -0
  158. package/dist/probes/mcp.js +226 -0
  159. package/dist/probes/mcp.js.map +1 -0
  160. package/dist/probes/memory.d.ts +24 -0
  161. package/dist/probes/memory.js +89 -0
  162. package/dist/probes/memory.js.map +1 -0
  163. package/dist/runtime/index.d.ts +3 -0
  164. package/dist/runtime/index.js +4 -0
  165. package/dist/runtime/index.js.map +1 -0
  166. package/dist/runtime/intercept.d.ts +53 -0
  167. package/dist/runtime/intercept.js +181 -0
  168. package/dist/runtime/intercept.js.map +1 -0
  169. package/dist/runtime/ledger-record.d.ts +43 -0
  170. package/dist/runtime/ledger-record.js +239 -0
  171. package/dist/runtime/ledger-record.js.map +1 -0
  172. package/dist/runtime/session-id.d.ts +10 -0
  173. package/dist/runtime/session-id.js +37 -0
  174. package/dist/runtime/session-id.js.map +1 -0
  175. package/dist/schema/extract.d.ts +5 -0
  176. package/dist/schema/extract.js +23 -0
  177. package/dist/schema/extract.js.map +1 -0
  178. package/dist/schema/grounding.d.ts +65 -0
  179. package/dist/schema/grounding.js +21 -0
  180. package/dist/schema/grounding.js.map +1 -0
  181. package/dist/schema/hooks.d.ts +86 -0
  182. package/dist/schema/hooks.js +42 -0
  183. package/dist/schema/hooks.js.map +1 -0
  184. package/dist/schema/index.d.ts +961 -0
  185. package/dist/schema/index.js +55 -0
  186. package/dist/schema/index.js.map +1 -0
  187. package/dist/schema/memory.d.ts +131 -0
  188. package/dist/schema/memory.js +38 -0
  189. package/dist/schema/memory.js.map +1 -0
  190. package/dist/schema/policies.d.ts +412 -0
  191. package/dist/schema/policies.js +53 -0
  192. package/dist/schema/policies.js.map +1 -0
  193. package/dist/schema/requires.d.ts +115 -0
  194. package/dist/schema/requires.js +57 -0
  195. package/dist/schema/requires.js.map +1 -0
  196. package/dist/schema/tools.d.ts +283 -0
  197. package/dist/schema/tools.js +66 -0
  198. package/dist/schema/tools.js.map +1 -0
  199. package/package.json +63 -0
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # harness
2
+
3
+ **Declarative control plane for agent harnesses.**
4
+
5
+ One zod-validated YAML manifest for grounding, tools, memory, hooks, and policies — plus a CLI that describes, validates, diffs, applies, audits, and *enforces*.
6
+
7
+ > Most config tools tell you what an agent is configured to use. `harness` tells you what an agent is *allowed to do*, under this exact context, and why.
8
+
9
+ `harness` collapses the six-to-eight surfaces a working agent harness leaks across (`settings.json`, `CLAUDE.md`, memory frontmatter, MCP registrations, per-project overrides, hook scripts) into a single source of truth. Today (`v0.4.0`) policies fire end-to-end: a `mcp__agent-tasks__pull_requests_merge` call against a session without a `review:${PR_NUMBER}` ledger entry refuses; `harness explain review-before-merge --trace` shows exactly why. Phase 6 adds an *Understanding Gate* (agents confirm task interpretation before editing); Phase 7 adds a *Risk Gate* that blocks `DROP TABLE` against a prod target — even when the model would happily run it.
10
+
11
+ ## Try it in 60 seconds
12
+
13
+ ```bash
14
+ git clone https://github.com/LanNguyenSi/harness && cd harness
15
+ npm install && npm run build
16
+
17
+ # Statically predict which policies fire for a tool call (no ledger, no LLM)
18
+ node dist/cli/main.js dry-run "merge PR 42" \
19
+ --tool mcp__agent-tasks__pull_requests_merge \
20
+ --tool-args '{"prNumber":42}' \
21
+ --config docs/examples/full-manifest.yaml
22
+ ```
23
+
24
+ `dry-run` reads the reference manifest (`docs/examples/full-manifest.yaml`), runs the trigger matcher, substitutes `${PR_NUMBER}=42` through the JSONPath-restricted extract DSL, and tells you exactly which hooks would fire and which policies would match — before any ledger I/O.
25
+
26
+ ## What a run looks like
27
+
28
+ ```yaml
29
+ prompt: merge PR 42
30
+ tool: mcp__agent-tasks__pull_requests_merge
31
+ toolArgs:
32
+ prNumber: 42
33
+ Hooks that would fire:
34
+ - event: SessionStart
35
+ name: git-preflight
36
+ - event: PreToolUse
37
+ name: require-review-evidence
38
+ - event: PreToolUse
39
+ name: require-dogfood-evidence
40
+ - event: PreToolUse
41
+ name: require-preflight-evidence
42
+ Policies that match:
43
+ - name: review-before-merge
44
+ ledgerQuery: review:42
45
+ requires:
46
+ ledger_tag: review:${PR_NUMBER}
47
+ enforcement: block
48
+ triggerEvent: PreToolUse
49
+ - name: two-reviewers-required
50
+ ledgerQuery: review:42
51
+ requires:
52
+ ledger_tag: review:${PR_NUMBER}
53
+ count:
54
+ min: 2
55
+ enforcement: warn
56
+ triggerEvent: PreToolUse
57
+ Policies that COULD match (need --tool):
58
+ - name: dogfood-before-release
59
+ triggerEvent: PreToolUse
60
+ reason: --tool "mcp__agent-tasks__pull_requests_merge" does not contain trigger.match "Bash"
61
+ - name: preflight-before-investigation
62
+ triggerEvent: PreToolUse
63
+ reason: --tool "mcp__agent-tasks__pull_requests_merge" does not contain trigger.match "Bash"
64
+ Memories that would route:
65
+ - path: ~/.claude/projects/{project}/memory
66
+ scope: project
67
+ ```
68
+
69
+ When the matching policy actually fires (via `harness policy intercept`, wired by `harness apply` into `settings.json` as a `PreToolUse` hook), and the evidence ledger has no `review:42` entry, the runtime emits Claude Code's deny shape on stdout:
70
+
71
+ ```json
72
+ {"decision":"deny","reason":"review-before-merge: no matching ledger entry for tag `review:42`"}
73
+ ```
74
+
75
+ After the entry is recorded, the same call is silently allowed. Every fire writes a `policy_decision` row that `harness audit` and `harness explain --trace` replay:
76
+
77
+ ```
78
+ $ node dist/cli/main.js audit --since 1h --policy review-before-merge --session sess-1 --config docs/examples/full-manifest.yaml
79
+
80
+ timestamp policy outcome reason
81
+ ------------------------ ------------------- ------- ---------------------------------------------
82
+ 2026-04-30T18:30:00.000Z review-before-merge deny no matching ledger entry for tag `review:42`
83
+ 2026-04-30T18:31:00.000Z review-before-merge allow 1 matching ledger entries for tag `review:42`
84
+ ```
85
+
86
+ ## Next steps
87
+
88
+ | If you want to... | Read |
89
+ |------|------|
90
+ | Understand the YAML shape, CLI surface, drift handling, `requires` schema | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) |
91
+ | See phase-by-phase scope, deliverables, acceptance criteria, exit gates | [`docs/ROADMAP.md`](docs/ROADMAP.md) |
92
+ | Read the long-form positioning (three pillars, ecosystem map, gaps) | [`docs/VISION.md`](docs/VISION.md) |
93
+ | Browse a manifest covering every field | [`docs/examples/full-manifest.yaml`](docs/examples/full-manifest.yaml) |
94
+ | Track what's shipping and what's deferred | [`CHANGELOG.md`](CHANGELOG.md) |
95
+
96
+ ## Common commands
97
+
98
+ ```bash
99
+ node dist/cli/main.js init --template full --config /tmp/harness-demo/harness.yaml
100
+ node dist/cli/main.js describe --config /tmp/harness-demo/harness.yaml --pillar tools
101
+ node dist/cli/main.js doctor --config /tmp/harness-demo/harness.yaml --shallow
102
+ node dist/cli/main.js validate --config /tmp/harness-demo/harness.yaml
103
+ node dist/cli/main.js apply --config /tmp/harness-demo/harness.yaml # regenerate settings.json + MEMORY.md, write harness.lock
104
+ node dist/cli/main.js diff --since-apply --config /tmp/harness-demo/harness.yaml
105
+ node dist/cli/main.js explain review-before-merge --trace --config docs/examples/full-manifest.yaml
106
+ node dist/cli/main.js audit --since 24h --config docs/examples/full-manifest.yaml
107
+ ```
108
+
109
+ ## What's next
110
+
111
+ Two structurally larger themes are queued after Phase 5's polish:
112
+
113
+ **Phase 6 — Understanding Gate.** Before an agent edits files, runs shell, commits, or opens a PR, it must produce an *Understanding Report* (its interpretation of the task: derived todos, acceptance criteria, assumptions, out-of-scope, risks). The user confirms, corrects, or "grills me until precise enough". Only after explicit approval is recorded in the evidence ledger may write-capable tools fire. Ships as the first `harness` *Policy Pack* — a reusable bundle of instruction template + hooks + policies + permission profiles. Long-form design lives in the internal `lava-ice-logs` logbook (2026-04-30).
114
+
115
+ **Phase 7 — Risk Gate.** Today's policy model evaluates a rule per matching trigger and returns a binary block/allow. Phase 7 makes harness reason about *the action itself*: an Action Envelope (tool + raw input + session + runtime context) is enriched by a Context Resolver (production / staging / dev / unknown), classified by a Risk Classifier (severity + categories + reversibility), then matched against policies whose `when:` clauses can reference `risk.severity_at_least`, `environment.name`, and similar. The decision space extends to `allow / warn / require_approval / deny`. Motivating use case: prevent `DROP TABLE users`, `kubectl delete namespace prod`, `terraform destroy` against an unverified production target before they reach the runtime — even if the model would have happily run them. Long-form design lives in the internal `lava-ice-logs` logbook (2026-04-30).
116
+
117
+ Both build on Phase 4's `policy intercept` runtime backbone; neither replaces it.
118
+
119
+ > Bring your favorite agent harness. Add governance.
120
+
121
+ ## Status
122
+
123
+ - [x] Repo bootstrap (LICENSE, .gitignore)
124
+ - [x] README + VISION — repo legible
125
+ - [x] ARCHITECTURE — YAML shape + CLI surface agreed
126
+ - [x] ROADMAP — phases 1–4 with acceptance criteria
127
+ - [x] Phase 1 — read-only inventory (`describe`, `validate`, `doctor`, `list`, `explain`, `diff`) — released as [`v0.1.0`](CHANGELOG.md#010---2026-04-29)
128
+ - [x] Phase 2 — managed edits (`init`, `add`, `remove`, `adopt`, `export`) — released as [`v0.2.0`](CHANGELOG.md#020---2026-04-29)
129
+ - [x] Phase 3 — declarative truth (`apply`, `diff --since-apply`, `harness.lock`) — released as [`v0.3.0`](CHANGELOG.md#030---2026-04-30)
130
+ - [x] Phase 4 — policy layer (`policy intercept`, `explain --trace`, `audit`, `dry-run`, requires-evaluator + extract DSL + grounding-mcp adapter) — released as [`v0.4.0`](CHANGELOG.md#040---2026-04-30)
131
+ - [ ] Phase 5 — polish + dogfood lessons (`apply --strict-lock`, `validate --check-lock`, sessionId default, `--verbose` deny diagnostics, sysexits normalisation, real-Claude-Code dogfood)
132
+ - [ ] Phase 6 — Understanding Gate Policy Pack (agents must expose and confirm task understanding before write-capable tools fire)
133
+ - [ ] Phase 7 — Risk Gate (Action Envelope + Risk Classifier + `allow / warn / require_approval / deny` for destructive-action prevention)
134
+
135
+ ## Why this exists
136
+
137
+ A working agent harness today has six to eight configuration surfaces, each with its own schema and lifecycle: `~/.claude/settings.json`, `CLAUDE.md` (per repo + root), `~/.claude/projects/*/memory/*.md` with frontmatter, `~/.claude/keybindings.json`, MCP server registrations in `~/.claude.json`, skill directories, per-project overrides, and external CLIs that behave differently per project.
138
+
139
+ There is no single place that answers *"what can this agent do right now, and why is that configured that way?"*. Drift between sessions is invisible until it breaks something. Humans editing one surface don't know which other surfaces they need to touch. A fresh agent instance has no way to audit its own setup.
140
+
141
+ Our entry point into this problem: on 2026-04-23, an `agent-grounding` checkout that was 16 commits behind origin led two tasks to be incorrectly called "stale". The check that would have caught it already exists — [`agent-preflight`](https://github.com/LanNguyenSi/agent-preflight) runs `git fetch` + `git status` (alongside lint, typecheck, test, audit) and emits a structured `ready` + confidence-score result. The missing piece wasn't the check itself, it was the deterministic *trigger*: a `SessionStart` hook that invokes `preflight run` and a policy that gates further work on the result. Building that wiring needs an agreed-upon place for harness config to live first. That conversation is the origin of this repo.
142
+
143
+ ## Related
144
+
145
+ - [`agent-grounding`](https://github.com/LanNguyenSi/agent-grounding) — grounding primitives (evidence-ledger, claim-gate, review-claim-gate); `grounding-mcp` is the canonical client surface harness queries through `queryLedgerByTag` (Phase 4 #3).
146
+ - [`agent-memory`](https://github.com/LanNguyenSi/agent-memory) — memory surfaces the control plane inventories.
147
+ - [`agent-tasks`](https://github.com/LanNguyenSi/agent-tasks) — the MCP-registered task platform whose registration + health appear in `harness describe`.
148
+ - [`agent-preflight`](https://github.com/LanNguyenSi/agent-preflight) — local preflight validator; the canonical implementation of preflight-hook content harness wires (see `docs/ARCHITECTURE.md` §5 for the canonical hook-script shape and §6 for the Phase 4 policy that gates further work on a `preflight:${REPO}` ledger entry).
149
+ - [`codebase-oracle`](https://github.com/LanNguyenSi/codebase-oracle) — one of the MCP surfaces being registered.
150
+ - [`dev-tools`](https://github.com/LanNguyenSi/dev-tools) — `git-batch-cli`, a day-to-day tool whose inventory appears in `harness describe`.
151
+
152
+ ## License
153
+
154
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ import { type AddEntry } from "./mutate.js";
2
+ export interface AddOptions {
3
+ configPath?: string;
4
+ homeDir?: string;
5
+ dryRun?: boolean;
6
+ }
7
+ export interface AddResult {
8
+ path: string;
9
+ type: AddEntry["type"];
10
+ name: string;
11
+ diff: string;
12
+ applied: boolean;
13
+ }
14
+ export declare function add(action: AddEntry, opts?: AddOptions): Promise<AddResult>;
@@ -0,0 +1,71 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { atomicWriteFile } from "../../io/atomic-write.js";
6
+ import { withFileLock } from "../../io/lock.js";
7
+ import { unifiedDiff } from "../../io/patch.js";
8
+ import { formatValidationErrors, validateBeforeWrite, } from "../../io/validate-before-write.js";
9
+ import { parseManifest } from "../../schema/index.js";
10
+ import { runAssetChecks } from "../validate/checks.js";
11
+ import { fmtDiagnostic } from "../validate/types.js";
12
+ import { EX_FAIL, EX_NOINPUT, HarnessExitError } from "../exit-codes.js";
13
+ import { applyAdd } from "./mutate.js";
14
+ const DEFAULT_BASENAME = "harness.yaml";
15
+ const LOCK_BASENAME = ".harness.lock";
16
+ function resolveTargetPath(opts) {
17
+ if (opts.configPath)
18
+ return path.resolve(opts.configPath);
19
+ return path.join(opts.homeDir ?? path.join(os.homedir(), ".claude"), DEFAULT_BASENAME);
20
+ }
21
+ function entryName(action) {
22
+ return action.type === "skill" ? action.entry : action.entry.name;
23
+ }
24
+ export async function add(action, opts = {}) {
25
+ const target = resolveTargetPath(opts);
26
+ if (!fs.existsSync(target)) {
27
+ throw new HarnessExitError(`harness manifest not found at ${target}; run \`harness init\` first`, EX_NOINPUT);
28
+ }
29
+ const original = fs.readFileSync(target, "utf8");
30
+ const proposed = applyAdd(original, action);
31
+ const diff = unifiedDiff({
32
+ fileName: path.basename(target),
33
+ oldText: original,
34
+ newText: proposed,
35
+ oldHeader: "current",
36
+ newHeader: "proposed",
37
+ });
38
+ // Schema gate.
39
+ const schemaResult = validateBeforeWrite(parseYaml(proposed));
40
+ if (!schemaResult.ok) {
41
+ throw new HarnessExitError(`proposed manifest fails schema validation:\n${formatValidationErrors(schemaResult.errors)}`, EX_FAIL);
42
+ }
43
+ // Asset gate — surfaces hook +x failures, missing required CLIs, etc.
44
+ // We use parseManifest (not the result of validateBeforeWrite) so we have a
45
+ // typed Manifest for runAssetChecks. defaults flow through.
46
+ const manifest = parseManifest(parseYaml(proposed));
47
+ const assetDiagnostics = runAssetChecks(manifest, { homeDir: opts.homeDir }).filter((d) => d.severity === "error");
48
+ if (assetDiagnostics.length > 0) {
49
+ const lines = assetDiagnostics.map(fmtDiagnostic).join("\n");
50
+ throw new HarnessExitError(`proposed manifest fails asset validation:\n${lines}`, EX_FAIL);
51
+ }
52
+ if (opts.dryRun) {
53
+ return { path: target, type: action.type, name: entryName(action), diff, applied: false };
54
+ }
55
+ const lockPath = path.join(path.dirname(target), LOCK_BASENAME);
56
+ await withFileLock(lockPath, () => {
57
+ // Re-read under the lock and re-apply so we never clobber a concurrent
58
+ // commit. The schema/asset checks above ran on the pre-lock snapshot;
59
+ // the post-lock apply re-validates implicitly via parseDocument round-trip
60
+ // and the inserted entry is still added because applyAdd is purely additive.
61
+ const current = fs.readFileSync(target, "utf8");
62
+ const next = applyAdd(current, action);
63
+ const recheck = validateBeforeWrite(parseYaml(next));
64
+ if (!recheck.ok) {
65
+ throw new HarnessExitError(`proposed manifest fails schema validation after lock acquisition:\n${formatValidationErrors(recheck.errors)}`, EX_FAIL);
66
+ }
67
+ atomicWriteFile(target, next);
68
+ });
69
+ return { path: target, type: action.type, name: entryName(action), diff, applied: true };
70
+ }
71
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/cli/add/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,39 @@
1
+ export type AddType = "mcp" | "cli" | "skill" | "hook";
2
+ export interface McpEntry {
3
+ name: string;
4
+ command: string | string[];
5
+ env?: Record<string, string>;
6
+ health?: {
7
+ verb: string;
8
+ timeout_ms?: number;
9
+ };
10
+ enabled?: boolean;
11
+ }
12
+ export interface CliEntry {
13
+ name: string;
14
+ binary: string;
15
+ required?: boolean;
16
+ min_version?: string;
17
+ }
18
+ export interface HookEntry {
19
+ name: string;
20
+ event: string;
21
+ command: string;
22
+ match?: string;
23
+ blocking: boolean | "soft" | "hard";
24
+ budget_ms?: number;
25
+ }
26
+ export type AddEntry = {
27
+ type: "mcp";
28
+ entry: McpEntry;
29
+ } | {
30
+ type: "cli";
31
+ entry: CliEntry;
32
+ } | {
33
+ type: "skill";
34
+ entry: string;
35
+ } | {
36
+ type: "hook";
37
+ entry: HookEntry;
38
+ };
39
+ export declare function applyAdd(yamlText: string, action: AddEntry): string;
@@ -0,0 +1,36 @@
1
+ import { isSeq, parseDocument } from "yaml";
2
+ export function applyAdd(yamlText, action) {
3
+ const doc = parseDocument(yamlText);
4
+ switch (action.type) {
5
+ case "mcp":
6
+ addToSequence(doc, ["tools", "mcp"], action.entry);
7
+ break;
8
+ case "cli":
9
+ addToSequence(doc, ["tools", "cli"], action.entry);
10
+ break;
11
+ case "skill":
12
+ addToSequence(doc, ["tools", "skills", "enabled"], action.entry);
13
+ break;
14
+ case "hook":
15
+ addToSequence(doc, ["hooks"], action.entry);
16
+ break;
17
+ }
18
+ // Match the conventions used elsewhere in the codebase so a no-op round-trip
19
+ // on a manifest authored in our style stays byte-equivalent (lineWidth:0
20
+ // disables 80-col folding on long flow sequences; flowCollectionPadding:false
21
+ // matches the [a, b] style without inner spaces).
22
+ return doc.toString({ flowCollectionPadding: false, lineWidth: 0 });
23
+ }
24
+ function addToSequence(doc, pathSegments, entry) {
25
+ const node = doc.getIn(pathSegments);
26
+ if (node === undefined || node === null) {
27
+ doc.setIn(pathSegments, [entry]);
28
+ return;
29
+ }
30
+ if (isSeq(node)) {
31
+ node.add(entry);
32
+ return;
33
+ }
34
+ throw new Error(`expected a YAML sequence at ${pathSegments.join(".")}, got ${typeof node}`);
35
+ }
36
+ //# sourceMappingURL=mutate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mutate.js","sourceRoot":"","sources":["../../../src/cli/add/mutate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,aAAa,EAAiB,MAAM,MAAM,CAAC;AAkC3D,MAAM,UAAU,QAAQ,CAAC,QAAgB,EAAE,MAAgB;IACzD,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAC;IACpC,QAAQ,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,KAAK,KAAK;YACR,aAAa,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;YACnD,MAAM;QACR,KAAK,KAAK;YACR,aAAa,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;YACnD,MAAM;QACR,KAAK,OAAO;YACV,aAAa,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,SAAS,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;YACjE,MAAM;QACR,KAAK,MAAM;YACT,aAAa,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;YAC5C,MAAM;IACV,CAAC;IACD,6EAA6E;IAC7E,yEAAyE;IACzE,8EAA8E;IAC9E,kDAAkD;IAClD,OAAO,GAAG,CAAC,QAAQ,CAAC,EAAE,qBAAqB,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC;AACtE,CAAC;AAED,SAAS,aAAa,CACpB,GAAoB,EACpB,YAAsB,EACtB,KAAc;IAEd,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;IACrC,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QACxC,GAAG,CAAC,KAAK,CAAC,YAAY,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACjC,OAAO;IACT,CAAC;IACD,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QAChB,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAChB,OAAO;IACT,CAAC;IACD,MAAM,IAAI,KAAK,CACb,+BAA+B,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,OAAO,IAAI,EAAE,CAC5E,CAAC;AACJ,CAAC"}
@@ -0,0 +1,38 @@
1
+ import type { Manifest } from "../../schema/index.js";
2
+ export interface DerivedHook {
3
+ event: string;
4
+ command: string;
5
+ match?: string;
6
+ }
7
+ export interface SettingsHookGroup {
8
+ matcher?: string;
9
+ hooks: {
10
+ type?: string;
11
+ command: string;
12
+ }[];
13
+ }
14
+ export interface SettingsRoot {
15
+ hooks?: Record<string, SettingsHookGroup[]>;
16
+ }
17
+ /**
18
+ * Flatten the nested ~/.claude/settings.json hooks tree into a list of
19
+ * manifest-style hook records so we can diff it against the manifest.
20
+ */
21
+ export declare function parseSettingsHooks(raw: unknown): DerivedHook[];
22
+ /**
23
+ * Project the manifest's hooks[] into the same flat shape so drift comparison
24
+ * is symmetric.
25
+ */
26
+ export declare function manifestProjection(manifest: Manifest): DerivedHook[];
27
+ /**
28
+ * settings.json minus manifest, keyed on (event, command, match).
29
+ * Returns hooks present in settings but not declared in the manifest.
30
+ */
31
+ export declare function computeDrift(settingsHooks: DerivedHook[], manifestHooks: DerivedHook[]): DerivedHook[];
32
+ /**
33
+ * Synthesize a manifest hook name from the derived entry.
34
+ * Strategy: take the command's first token's basename without extension; if
35
+ * that collides with an existing name, append -2, -3, etc. Falls back to
36
+ * `adopted-hook` if the command has no recognisable basename.
37
+ */
38
+ export declare function synthesizeName(d: DerivedHook, taken: Set<string>): string;
@@ -0,0 +1,94 @@
1
+ const KNOWN_EVENTS = new Set([
2
+ "SessionStart",
3
+ "UserPromptSubmit",
4
+ "PreToolUse",
5
+ "PostToolUse",
6
+ "Stop",
7
+ "SubagentStop",
8
+ "PreCompact",
9
+ ]);
10
+ /**
11
+ * Flatten the nested ~/.claude/settings.json hooks tree into a list of
12
+ * manifest-style hook records so we can diff it against the manifest.
13
+ */
14
+ export function parseSettingsHooks(raw) {
15
+ if (!isRecord(raw))
16
+ return [];
17
+ const root = raw;
18
+ if (!root.hooks || !isRecord(root.hooks))
19
+ return [];
20
+ const out = [];
21
+ for (const [event, groups] of Object.entries(root.hooks)) {
22
+ if (!KNOWN_EVENTS.has(event))
23
+ continue;
24
+ if (!Array.isArray(groups))
25
+ continue;
26
+ for (const group of groups) {
27
+ if (!isRecord(group))
28
+ continue;
29
+ const matcher = typeof group.matcher === "string" && group.matcher.length > 0
30
+ ? group.matcher
31
+ : undefined;
32
+ const inner = group.hooks;
33
+ if (!Array.isArray(inner))
34
+ continue;
35
+ for (const h of inner) {
36
+ if (!isRecord(h))
37
+ continue;
38
+ if (typeof h.command !== "string" || h.command.length === 0)
39
+ continue;
40
+ out.push({
41
+ event,
42
+ command: h.command,
43
+ ...(matcher !== undefined ? { match: matcher } : {}),
44
+ });
45
+ }
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ /**
51
+ * Project the manifest's hooks[] into the same flat shape so drift comparison
52
+ * is symmetric.
53
+ */
54
+ export function manifestProjection(manifest) {
55
+ return manifest.hooks.map((h) => {
56
+ const out = { event: h.event, command: h.command };
57
+ if (h.match !== undefined)
58
+ out.match = h.match;
59
+ return out;
60
+ });
61
+ }
62
+ /**
63
+ * settings.json minus manifest, keyed on (event, command, match).
64
+ * Returns hooks present in settings but not declared in the manifest.
65
+ */
66
+ export function computeDrift(settingsHooks, manifestHooks) {
67
+ const declared = new Set(manifestHooks.map(keyOf));
68
+ return settingsHooks.filter((h) => !declared.has(keyOf(h)));
69
+ }
70
+ function keyOf(h) {
71
+ return `${h.event}\x00${h.command}\x00${h.match ?? ""}`;
72
+ }
73
+ /**
74
+ * Synthesize a manifest hook name from the derived entry.
75
+ * Strategy: take the command's first token's basename without extension; if
76
+ * that collides with an existing name, append -2, -3, etc. Falls back to
77
+ * `adopted-hook` if the command has no recognisable basename.
78
+ */
79
+ export function synthesizeName(d, taken) {
80
+ const firstToken = d.command.trim().split(/\s+/)[0] ?? "";
81
+ const last = firstToken.split("/").pop() ?? "";
82
+ const stem = last.replace(/\.[^.]+$/, "");
83
+ const base = stem.length > 0 ? stem : "adopted-hook";
84
+ if (!taken.has(base))
85
+ return base;
86
+ let i = 2;
87
+ while (taken.has(`${base}-${i}`))
88
+ i++;
89
+ return `${base}-${i}`;
90
+ }
91
+ function isRecord(x) {
92
+ return typeof x === "object" && x !== null && !Array.isArray(x);
93
+ }
94
+ //# sourceMappingURL=derive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derive.js","sourceRoot":"","sources":["../../../src/cli/adopt/derive.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ export interface AdoptOptions {
2
+ configPath?: string;
3
+ homeDir?: string;
4
+ yes?: boolean;
5
+ /** Optional injection point for tests; defaults to readline against stdin. */
6
+ prompt?: (message: string) => Promise<string>;
7
+ }
8
+ export interface AdoptResult {
9
+ manifestPath: string;
10
+ settingsPath: string;
11
+ driftCount: number;
12
+ /** The unified diff of the proposed change. Empty when nothing to adopt. */
13
+ diff: string;
14
+ applied: boolean;
15
+ /** Names synthesised for the new manifest entries. */
16
+ adoptedNames: string[];
17
+ /** Human-readable status: "no-drift" | "declined" | "applied". */
18
+ outcome: "no-drift" | "declined" | "applied";
19
+ }
20
+ export declare function adopt(settingsPath: string, opts?: AdoptOptions): Promise<AdoptResult>;
@@ -0,0 +1,156 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import * as readline from "node:readline/promises";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { atomicWriteFile } from "../../io/atomic-write.js";
7
+ import { withFileLock } from "../../io/lock.js";
8
+ import { unifiedDiff } from "../../io/patch.js";
9
+ import { formatValidationErrors, validateBeforeWrite, } from "../../io/validate-before-write.js";
10
+ import { parseManifest } from "../../schema/index.js";
11
+ import { applyAdd } from "../add/mutate.js";
12
+ import { EX_FAIL, EX_NOINPUT, HarnessExitError } from "../exit-codes.js";
13
+ import { computeDrift, manifestProjection, parseSettingsHooks, synthesizeName, } from "./derive.js";
14
+ const DEFAULT_BASENAME = "harness.yaml";
15
+ const LOCK_BASENAME = ".harness.lock";
16
+ function resolveManifestPath(opts) {
17
+ if (opts.configPath)
18
+ return path.resolve(opts.configPath);
19
+ return path.join(opts.homeDir ?? path.join(os.homedir(), ".claude"), DEFAULT_BASENAME);
20
+ }
21
+ async function defaultPrompt(message) {
22
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
23
+ try {
24
+ return await rl.question(message);
25
+ }
26
+ finally {
27
+ rl.close();
28
+ }
29
+ }
30
+ export async function adopt(settingsPath, opts = {}) {
31
+ const manifestPath = resolveManifestPath(opts);
32
+ if (!fs.existsSync(manifestPath)) {
33
+ throw new HarnessExitError(`harness manifest not found at ${manifestPath}; run \`harness init\` first`, EX_NOINPUT);
34
+ }
35
+ if (!fs.existsSync(settingsPath)) {
36
+ throw new HarnessExitError(`cannot adopt: file does not exist: ${settingsPath}`, EX_NOINPUT);
37
+ }
38
+ const originalYaml = fs.readFileSync(manifestPath, "utf8");
39
+ const manifest = parseManifest(parseYaml(originalYaml));
40
+ const projection = manifestProjection(manifest);
41
+ let settingsRaw;
42
+ try {
43
+ settingsRaw = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
44
+ }
45
+ catch (e) {
46
+ throw new HarnessExitError(`cannot adopt: ${settingsPath} is not valid JSON: ${e.message}`, EX_FAIL);
47
+ }
48
+ const settingsHooks = parseSettingsHooks(settingsRaw);
49
+ const drift = computeDrift(settingsHooks, projection);
50
+ if (drift.length === 0) {
51
+ return {
52
+ manifestPath,
53
+ settingsPath,
54
+ driftCount: 0,
55
+ diff: "",
56
+ applied: false,
57
+ adoptedNames: [],
58
+ outcome: "no-drift",
59
+ };
60
+ }
61
+ const taken = new Set(manifest.hooks.map((h) => h.name));
62
+ const adoptedNames = [];
63
+ let proposedYaml = originalYaml;
64
+ for (const d of drift) {
65
+ const name = synthesizeName(d, taken);
66
+ taken.add(name);
67
+ adoptedNames.push(name);
68
+ proposedYaml = applyAdd(proposedYaml, {
69
+ type: "hook",
70
+ entry: buildHookEntry(name, d),
71
+ });
72
+ }
73
+ const diff = unifiedDiff({
74
+ fileName: path.basename(manifestPath),
75
+ oldText: originalYaml,
76
+ newText: proposedYaml,
77
+ oldHeader: "current",
78
+ newHeader: "proposed",
79
+ });
80
+ // Defence-in-depth gate. From a happy-path adopt (well-formed input manifest +
81
+ // well-formed settings.json) every synthesised hook field is already
82
+ // schema-valid by construction (event from KNOWN_EVENTS, non-empty command,
83
+ // disambiguated name, blocking:false). The gate is here to catch structural
84
+ // bugs in synthesizeName / applyAdd that future maintainers might introduce.
85
+ const validation = validateBeforeWrite(parseYaml(proposedYaml));
86
+ if (!validation.ok) {
87
+ throw new HarnessExitError(`proposed manifest fails schema validation:\n${formatValidationErrors(validation.errors)}`, EX_FAIL);
88
+ }
89
+ if (!opts.yes) {
90
+ const promptFn = opts.prompt ?? defaultPrompt;
91
+ const answer = (await promptFn(`${diff}\nApply (y/N)? `)).trim().toLowerCase();
92
+ if (answer !== "y" && answer !== "yes") {
93
+ return {
94
+ manifestPath,
95
+ settingsPath,
96
+ driftCount: drift.length,
97
+ diff,
98
+ applied: false,
99
+ adoptedNames,
100
+ outcome: "declined",
101
+ };
102
+ }
103
+ }
104
+ const lockPath = path.join(path.dirname(manifestPath), LOCK_BASENAME);
105
+ await withFileLock(lockPath, () => {
106
+ const current = fs.readFileSync(manifestPath, "utf8");
107
+ let next = current;
108
+ const currentManifest = parseManifest(parseYaml(current));
109
+ const lockTaken = new Set(currentManifest.hooks.map((h) => h.name));
110
+ for (let i = 0; i < drift.length; i++) {
111
+ const d = drift[i];
112
+ const name = adoptedNames[i];
113
+ // If a concurrent adopt landed the same drift, skip silently rather than
114
+ // duplicating. This makes adopt idempotent across repeated runs.
115
+ // KNOWN GAP: a concurrent adopt that resolved the same drift to a
116
+ // *different* name (e.g. our `foo` vs their `foo-2`) would NOT be caught
117
+ // here — both would land as separate hooks with different names. Schema
118
+ // accepts it (no name collision), but the manifest contains two entries
119
+ // pointing at the same command. Acceptable rarity for Phase 2; revisit
120
+ // when a SHA-based drift identity ships in Phase 3 alongside harness.lock.
121
+ if (lockTaken.has(name))
122
+ continue;
123
+ next = applyAdd(next, { type: "hook", entry: buildHookEntry(name, d) });
124
+ lockTaken.add(name);
125
+ }
126
+ const recheck = validateBeforeWrite(parseYaml(next));
127
+ if (!recheck.ok) {
128
+ throw new HarnessExitError(`proposed manifest fails schema validation after lock acquisition:\n${formatValidationErrors(recheck.errors)}`, EX_FAIL);
129
+ }
130
+ atomicWriteFile(manifestPath, next);
131
+ });
132
+ return {
133
+ manifestPath,
134
+ settingsPath,
135
+ driftCount: drift.length,
136
+ diff,
137
+ applied: true,
138
+ adoptedNames,
139
+ outcome: "applied",
140
+ };
141
+ }
142
+ function buildHookEntry(name, d) {
143
+ // Adopted hooks default to non-blocking so the captured entry doesn't
144
+ // unexpectedly start gating tool calls. The user can promote to soft/hard
145
+ // explicitly if they want enforcement.
146
+ const entry = {
147
+ name,
148
+ event: d.event,
149
+ command: d.command,
150
+ blocking: false,
151
+ };
152
+ if (d.match !== undefined)
153
+ entry.match = d.match;
154
+ return entry;
155
+ }
156
+ //# sourceMappingURL=index.js.map