@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.
- package/CHANGELOG.md +552 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/dist/cli/add/index.d.ts +14 -0
- package/dist/cli/add/index.js +71 -0
- package/dist/cli/add/index.js.map +1 -0
- package/dist/cli/add/mutate.d.ts +39 -0
- package/dist/cli/add/mutate.js +36 -0
- package/dist/cli/add/mutate.js.map +1 -0
- package/dist/cli/adopt/derive.d.ts +38 -0
- package/dist/cli/adopt/derive.js +94 -0
- package/dist/cli/adopt/derive.js.map +1 -0
- package/dist/cli/adopt/index.d.ts +20 -0
- package/dist/cli/adopt/index.js +156 -0
- package/dist/cli/adopt/index.js.map +1 -0
- package/dist/cli/apply/apply.d.ts +49 -0
- package/dist/cli/apply/apply.js +333 -0
- package/dist/cli/apply/apply.js.map +1 -0
- package/dist/cli/apply/generate-memory-index.d.ts +17 -0
- package/dist/cli/apply/generate-memory-index.js +167 -0
- package/dist/cli/apply/generate-memory-index.js.map +1 -0
- package/dist/cli/apply/generate-settings.d.ts +15 -0
- package/dist/cli/apply/generate-settings.js +87 -0
- package/dist/cli/apply/generate-settings.js.map +1 -0
- package/dist/cli/apply/index.d.ts +1 -0
- package/dist/cli/apply/index.js +2 -0
- package/dist/cli/apply/index.js.map +1 -0
- package/dist/cli/audit.d.ts +36 -0
- package/dist/cli/audit.js +121 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/describe.d.ts +13 -0
- package/dist/cli/describe.js +26 -0
- package/dist/cli/describe.js.map +1 -0
- package/dist/cli/diff/engine.d.ts +21 -0
- package/dist/cli/diff/engine.js +161 -0
- package/dist/cli/diff/engine.js.map +1 -0
- package/dist/cli/diff/git.d.ts +6 -0
- package/dist/cli/diff/git.js +32 -0
- package/dist/cli/diff/git.js.map +1 -0
- package/dist/cli/diff/index.d.ts +15 -0
- package/dist/cli/diff/index.js +39 -0
- package/dist/cli/diff/index.js.map +1 -0
- package/dist/cli/diff/since-apply.d.ts +57 -0
- package/dist/cli/diff/since-apply.js +255 -0
- package/dist/cli/diff/since-apply.js.map +1 -0
- package/dist/cli/doctor/format.d.ts +2 -0
- package/dist/cli/doctor/format.js +126 -0
- package/dist/cli/doctor/format.js.map +1 -0
- package/dist/cli/doctor/index.d.ts +14 -0
- package/dist/cli/doctor/index.js +281 -0
- package/dist/cli/doctor/index.js.map +1 -0
- package/dist/cli/doctor/types.d.ts +46 -0
- package/dist/cli/doctor/types.js +2 -0
- package/dist/cli/doctor/types.js.map +1 -0
- package/dist/cli/dry-run.d.ts +46 -0
- package/dist/cli/dry-run.js +168 -0
- package/dist/cli/dry-run.js.map +1 -0
- package/dist/cli/exit-codes.d.ts +10 -0
- package/dist/cli/exit-codes.js +15 -0
- package/dist/cli/exit-codes.js.map +1 -0
- package/dist/cli/explain.d.ts +14 -0
- package/dist/cli/explain.js +97 -0
- package/dist/cli/explain.js.map +1 -0
- package/dist/cli/export.d.ts +31 -0
- package/dist/cli/export.js +84 -0
- package/dist/cli/export.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +549 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init/index.d.ts +17 -0
- package/dist/cli/init/index.js +57 -0
- package/dist/cli/init/index.js.map +1 -0
- package/dist/cli/init/templates.d.ts +4 -0
- package/dist/cli/init/templates.js +175 -0
- package/dist/cli/init/templates.js.map +1 -0
- package/dist/cli/list.d.ts +12 -0
- package/dist/cli/list.js +118 -0
- package/dist/cli/list.js.map +1 -0
- package/dist/cli/loader.d.ts +24 -0
- package/dist/cli/loader.js +74 -0
- package/dist/cli/loader.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +6 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/policy/intercept.d.ts +34 -0
- package/dist/cli/policy/intercept.js +172 -0
- package/dist/cli/policy/intercept.js.map +1 -0
- package/dist/cli/remove/index.d.ts +18 -0
- package/dist/cli/remove/index.js +95 -0
- package/dist/cli/remove/index.js.map +1 -0
- package/dist/cli/remove/mutate.d.ts +9 -0
- package/dist/cli/remove/mutate.js +68 -0
- package/dist/cli/remove/mutate.js.map +1 -0
- package/dist/cli/validate/checks.d.ts +23 -0
- package/dist/cli/validate/checks.js +253 -0
- package/dist/cli/validate/checks.js.map +1 -0
- package/dist/cli/validate/index.d.ts +18 -0
- package/dist/cli/validate/index.js +50 -0
- package/dist/cli/validate/index.js.map +1 -0
- package/dist/cli/validate/types.d.ts +7 -0
- package/dist/cli/validate/types.js +5 -0
- package/dist/cli/validate/types.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/io/atomic-write.d.ts +8 -0
- package/dist/io/atomic-write.js +30 -0
- package/dist/io/atomic-write.js.map +1 -0
- package/dist/io/harness-lock.d.ts +33 -0
- package/dist/io/harness-lock.js +260 -0
- package/dist/io/harness-lock.js.map +1 -0
- package/dist/io/last-apply.d.ts +20 -0
- package/dist/io/last-apply.js +123 -0
- package/dist/io/last-apply.js.map +1 -0
- package/dist/io/lock.d.ts +11 -0
- package/dist/io/lock.js +33 -0
- package/dist/io/lock.js.map +1 -0
- package/dist/io/patch.d.ts +10 -0
- package/dist/io/patch.js +8 -0
- package/dist/io/patch.js.map +1 -0
- package/dist/io/restart-hints.d.ts +5 -0
- package/dist/io/restart-hints.js +59 -0
- package/dist/io/restart-hints.js.map +1 -0
- package/dist/io/three-state.d.ts +7 -0
- package/dist/io/three-state.js +20 -0
- package/dist/io/three-state.js.map +1 -0
- package/dist/io/validate-before-write.d.ts +12 -0
- package/dist/io/validate-before-write.js +23 -0
- package/dist/io/validate-before-write.js.map +1 -0
- package/dist/overrides/index.d.ts +2 -0
- package/dist/overrides/index.js +3 -0
- package/dist/overrides/index.js.map +1 -0
- package/dist/overrides/machines.d.ts +12 -0
- package/dist/overrides/machines.js +46 -0
- package/dist/overrides/machines.js.map +1 -0
- package/dist/overrides/merge.d.ts +6 -0
- package/dist/overrides/merge.js +173 -0
- package/dist/overrides/merge.js.map +1 -0
- package/dist/policies/duration.d.ts +5 -0
- package/dist/policies/duration.js +50 -0
- package/dist/policies/duration.js.map +1 -0
- package/dist/policies/extract.d.ts +50 -0
- package/dist/policies/extract.js +190 -0
- package/dist/policies/extract.js.map +1 -0
- package/dist/policies/index.d.ts +5 -0
- package/dist/policies/index.js +6 -0
- package/dist/policies/index.js.map +1 -0
- package/dist/policies/ledger-client.d.ts +39 -0
- package/dist/policies/ledger-client.js +378 -0
- package/dist/policies/ledger-client.js.map +1 -0
- package/dist/policies/requires.d.ts +44 -0
- package/dist/policies/requires.js +146 -0
- package/dist/policies/requires.js.map +1 -0
- package/dist/policies/timestamp.d.ts +14 -0
- package/dist/policies/timestamp.js +36 -0
- package/dist/policies/timestamp.js.map +1 -0
- package/dist/probes/mcp.d.ts +29 -0
- package/dist/probes/mcp.js +226 -0
- package/dist/probes/mcp.js.map +1 -0
- package/dist/probes/memory.d.ts +24 -0
- package/dist/probes/memory.js +89 -0
- package/dist/probes/memory.js.map +1 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/intercept.d.ts +53 -0
- package/dist/runtime/intercept.js +181 -0
- package/dist/runtime/intercept.js.map +1 -0
- package/dist/runtime/ledger-record.d.ts +43 -0
- package/dist/runtime/ledger-record.js +239 -0
- package/dist/runtime/ledger-record.js.map +1 -0
- package/dist/runtime/session-id.d.ts +10 -0
- package/dist/runtime/session-id.js +37 -0
- package/dist/runtime/session-id.js.map +1 -0
- package/dist/schema/extract.d.ts +5 -0
- package/dist/schema/extract.js +23 -0
- package/dist/schema/extract.js.map +1 -0
- package/dist/schema/grounding.d.ts +65 -0
- package/dist/schema/grounding.js +21 -0
- package/dist/schema/grounding.js.map +1 -0
- package/dist/schema/hooks.d.ts +86 -0
- package/dist/schema/hooks.js +42 -0
- package/dist/schema/hooks.js.map +1 -0
- package/dist/schema/index.d.ts +961 -0
- package/dist/schema/index.js +55 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/memory.d.ts +131 -0
- package/dist/schema/memory.js +38 -0
- package/dist/schema/memory.js.map +1 -0
- package/dist/schema/policies.d.ts +412 -0
- package/dist/schema/policies.js +53 -0
- package/dist/schema/policies.js.map +1 -0
- package/dist/schema/requires.d.ts +115 -0
- package/dist/schema/requires.js +57 -0
- package/dist/schema/requires.js.map +1 -0
- package/dist/schema/tools.d.ts +283 -0
- package/dist/schema/tools.js +66 -0
- package/dist/schema/tools.js.map +1 -0
- 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
|