loop-engineering-harness 1.0.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/README.md +266 -0
- package/bin/cli.js +166 -0
- package/docs/langfuse-dashboards.md +39 -0
- package/package.json +34 -0
- package/src/detect.js +79 -0
- package/src/install.js +58 -0
- package/src/loop.js +181 -0
- package/src/merge.js +173 -0
- package/src/metrics.js +70 -0
- package/src/uninstall.js +63 -0
- package/template/.claude/agents/context-researcher.md +47 -0
- package/template/.claude/agents/planner.md +71 -0
- package/template/.claude/agents/reviewer.md +64 -0
- package/template/.claude/agents/specialist-backend.md +34 -0
- package/template/.claude/agents/specialist-database.md +32 -0
- package/template/.claude/agents/specialist-frontend.md +35 -0
- package/template/.claude/commands/feature.md +147 -0
- package/template/.claude/hooks/artifact-size.py +71 -0
- package/template/.claude/hooks/commit-gate.py +85 -0
- package/template/.claude/hooks/dispatch-gate.py +193 -0
- package/template/.claude/hooks/divergence-monitor.py +98 -0
- package/template/.claude/hooks/harness_lib.py +260 -0
- package/template/.claude/hooks/loop_lib.py +108 -0
- package/template/.claude/hooks/precompact-guard.py +91 -0
- package/template/.claude/hooks/req_lib.py +148 -0
- package/template/.claude/hooks/requirements-gate.py +62 -0
- package/template/.claude/hooks/retrieval-nudge.py +160 -0
- package/template/.claude/hooks/session-rehydrate.py +65 -0
- package/template/.claude/hooks/state-updater.py +119 -0
- package/template/.claude/hooks/trace-emitter.py +128 -0
- package/template/.claude/hooks/version-gate.py +86 -0
- package/template/.claude/hooks/version-tracker.py +67 -0
- package/template/.claude/hooks/version_lib.py +243 -0
- package/template/.claude/scripts/approve-plan.py +91 -0
- package/template/.claude/scripts/changeset.py +18 -0
- package/template/.claude/scripts/extract-deps.py +288 -0
- package/template/.claude/scripts/version.py +144 -0
- package/template/.claude/skills/contract-writing/SKILL.md +50 -0
- package/template/.claude/skills/discovery-interview/SKILL.md +56 -0
- package/template/.claude/skills/review-taxonomy/SKILL.md +51 -0
- package/template/CLAUDE.harness.md +68 -0
- package/template/harness.config.json +58 -0
- package/template/settings.harness.json +87 -0
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Loop Engineering — an agent harness for brownfield Claude Code
|
|
2
|
+
|
|
3
|
+
Turn Claude Code into a disciplined, observable, **continuously-looping** multi-agent
|
|
4
|
+
development pipeline you can drop into an existing codebase without disrupting it.
|
|
5
|
+
|
|
6
|
+
> **The thesis:** policy lives in exit codes and artifacts on disk, not in prompts.
|
|
7
|
+
> Agents exercise judgment; hooks and scripts enforce the rules; files carry the
|
|
8
|
+
> state. Anything an agent could forget or rationalize past is enforced deterministically.
|
|
9
|
+
|
|
10
|
+
Built entirely on Claude Code's native primitives — subagents, skills, hooks, slash
|
|
11
|
+
commands — plus **graphify** (deterministic code knowledge graph) and **Langfuse**
|
|
12
|
+
(trace observability). Design record: [Architecture.md](Architecture.md),
|
|
13
|
+
[Implementation.md](Implementation.md), [Usecase.md](Usecase.md),
|
|
14
|
+
[DevelopmentUpdates.md](DevelopmentUpdates.md), [HookDevelopment.md](HookDevelopment.md).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx loop-engineering-harness detect . # dry run — show exactly what will change
|
|
22
|
+
npx loop-engineering-harness init . # install (prints a plan, asks to confirm)
|
|
23
|
+
npx loop-engineering-harness uninstall . # remove everything; keeps your specs/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
> **Note:** You can also install directly from GitHub without publishing:
|
|
27
|
+
> ```bash
|
|
28
|
+
> npx github:AakashRajendran0926/Loop-Engineering init .
|
|
29
|
+
> ```
|
|
30
|
+
|
|
31
|
+
Brownfield-safe by construction:
|
|
32
|
+
|
|
33
|
+
- **`CLAUDE.md`** gains a delimited `<!-- harness:begin -->…<!-- harness:end -->`
|
|
34
|
+
block below your content — never a rewrite. Upgrades replace only the block.
|
|
35
|
+
- **`.claude/settings.json`** is deep-merged: your hooks are preserved and never
|
|
36
|
+
reordered; harness hooks are appended with a `"_harness": true` marker. Invalid
|
|
37
|
+
JSON aborts the install rather than guessing.
|
|
38
|
+
- **File collisions** (e.g. you already have `agents/reviewer.md`) are written as
|
|
39
|
+
`reviewer.harness.md` with a warning — your file is never clobbered.
|
|
40
|
+
- **A manifest** records exactly what was written, so `uninstall` restores a
|
|
41
|
+
**byte-identical** repo (minus `specs/`, which is your work product).
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## The gate stack — the whole point
|
|
46
|
+
|
|
47
|
+
Every gate is a hook that reads disk and exits non-zero to block. The harness
|
|
48
|
+
summons a human at bad **inputs**, bad **flow**, goal **drift**, failed **outputs**,
|
|
49
|
+
and unversioned **change** — each decided mechanically, never by an agent grading
|
|
50
|
+
itself.
|
|
51
|
+
|
|
52
|
+
| Gate | Fires on | Blocks unless… |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `retrieval-nudge.py` | Grep/Glob | broad search goes graph-first (`graphify query`); auto-inits graphify if absent |
|
|
55
|
+
| `requirements-gate.py` | Task | intent is unambiguous, requirements structured (AC1, AC2…), flow in order, plan covers exactly the acceptance criteria |
|
|
56
|
+
| `dispatch-gate.py` | Task | G0–G6: approval-hash matches, graph fresh, breaker healthy, upstream done, under retry cap, prompt within context budget |
|
|
57
|
+
| `divergence-monitor.py` | Edit/Write | writes stay inside the task's declared footprint (else trips the breaker) |
|
|
58
|
+
| `artifact-size.py` | Edit/Write | context-pack / review stay within their compression budget |
|
|
59
|
+
| `commit-gate.py` | Bash | a passing **integration review** exists for the current diff |
|
|
60
|
+
| `version-gate.py` | Bash | major deps acknowledged, code version + changelog recorded, dev history captured |
|
|
61
|
+
|
|
62
|
+
Supporting hooks: `state-updater.py` (single writer of `state.json` + derived
|
|
63
|
+
breaker `health`), `precompact-guard.py`/`session-rehydrate.py` (compaction
|
|
64
|
+
indifference + resume), `trace-emitter.py` (Langfuse + local traces),
|
|
65
|
+
`version-tracker.py` (dependency deltas).
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Using it — `/feature`
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
/feature add order cancellation with refund handling
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
1. **Discovery interview** (main thread, human) → `discovery.md` with `AC1, AC2…` acceptance criteria
|
|
76
|
+
2. **context-researcher** (isolated window, graph-first) → `context-pack.md` (~2k tokens, sectioned per work area)
|
|
77
|
+
3. **planner** → `contracts/` then `plan.md` (footprints, `satisfies:` coverage tags, `VERSION-IMPACT`, token estimates) → runs `extract-deps.py` → `task-graph.json`
|
|
78
|
+
4. **Plan approval** (human, or `--auto-approve`) → `approvals.json` signs the decomposition; automation is frozen until it matches `plan.md`'s hash
|
|
79
|
+
5. **Specialists** dispatched per the computed schedule — parallel only where footprints are *literally disjoint*, sequential along edges; task-scoped prompts (Rule 1), stateless retries (Rule 3)
|
|
80
|
+
6. **reviewer** per task, then one **integration review** over the combined diff
|
|
81
|
+
7. **Version** the feature (bump + changelog + snapshot)
|
|
82
|
+
8. **Commit** — the commit gate *and* version gate open; the PR is backed by the full artifact chain in `specs/`, not a bare diff
|
|
83
|
+
|
|
84
|
+
`/feature` is the only opt-in — ordinary Claude Code usage in the repo is untouched.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Retrieval is graph-first: graphify → skills → memory
|
|
89
|
+
|
|
90
|
+
`retrieval-nudge.py` redirects broad `Grep`/`Glob` toward a dependency-aware
|
|
91
|
+
`graphify query`. Narrow, file-scoped searches pass through. If no graph exists it
|
|
92
|
+
routes to `/graphify .` (which self-installs and builds one). It blocks a session
|
|
93
|
+
at most **once**, so it steers the first broad search but can never wedge a subagent.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## The human gate — summoned by evidence, before work goes wrong
|
|
98
|
+
|
|
99
|
+
Four conditions freeze automation until a person resolves them
|
|
100
|
+
(`requirements-gate.py`, and `approve-plan.py` refuses to sign a failing plan):
|
|
101
|
+
|
|
102
|
+
| Condition | Detected as |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Ambiguous / unorganized intention | `discovery.md` missing Intent & Scope, or unresolved markers (TBD / ??? / "clarify") |
|
|
105
|
+
| Vague / unstructured requirements | required sections absent, or no structured acceptance criteria (AC1, AC2…) |
|
|
106
|
+
| Wrong flow | implementation dispatched before its phase artifacts exist (discovery → context-pack → plan → task-graph) |
|
|
107
|
+
| Diverted from user requirements | an acceptance criterion covered by no task (under-delivery), or a task citing an AC absent from discovery (scope creep) |
|
|
108
|
+
|
|
109
|
+
A block is not a retry — the message says *re-enter interview mode*. `--force` on
|
|
110
|
+
`approve-plan.py` overrides but is recorded in `approvals.json` as a logged decision.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Version Controller — dependency, code, and development versioning
|
|
115
|
+
|
|
116
|
+
A feature does not commit until it is versioned (`version-tracker.py` +
|
|
117
|
+
`version-gate.py` + [`version.py`](template/.claude/scripts/version.py)):
|
|
118
|
+
|
|
119
|
+
- **Dependency** — the pre-feature dependency set is baselined at approval; every
|
|
120
|
+
manifest edit (`package.json`, `requirements.txt`, `pyproject.toml`, `go.mod`,
|
|
121
|
+
`Cargo.toml`) is diffed and classified major/minor/patch into
|
|
122
|
+
`dependencies.json`. A **major** bump must be acknowledged after review.
|
|
123
|
+
- **Code** — `version.py bump` derives a semver bump from the plan's
|
|
124
|
+
`VERSION-IMPACT`, updates `VERSION`, and writes a `CHANGELOG.md` entry.
|
|
125
|
+
- **Development** — `version.py snapshot` hashes the spec artifacts into
|
|
126
|
+
`history.json` at each milestone (`approved` → `integrated` → …) so a feature's
|
|
127
|
+
evolution is auditable, not overwritten.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python .claude/scripts/version.py status <feature>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Autonomous workflow — the outer loop
|
|
136
|
+
|
|
137
|
+
The driver processes a **feature queue**, running each in a fresh headless
|
|
138
|
+
`claude -p` session to one of three terminal states read back from `state.json`.
|
|
139
|
+
Escalations **park** (with a question for a human) instead of stalling the queue;
|
|
140
|
+
a parked feature resumes from artifacts in a brand-new session — recover-from-
|
|
141
|
+
compaction and resume-from-parking are the same mechanism.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
agent-harness queue add "add order cancellation with refunds"
|
|
145
|
+
agent-harness queue add "nightly CSV export" --auto-approve # headless self-approve
|
|
146
|
+
agent-harness loop # drive the queue to terminal states
|
|
147
|
+
agent-harness queue list # done / parked (+ the questions)
|
|
148
|
+
# ...human appends the answer to specs/<f>/discovery.md...
|
|
149
|
+
agent-harness requeue <feature-id> # resume in a fresh session
|
|
150
|
+
agent-harness metrics # per-feature burndown + tokens-per-passing-task
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The human gate (interview + plan approval) is deliberate — ambiguity is routed to
|
|
154
|
+
people, not retried into confident guesses. `--auto-approve` is a **logged**
|
|
155
|
+
opt-out for trusted/CI features; every downstream gate still enforces. Token
|
|
156
|
+
burndown is fed from each session's real usage into the circuit breaker (halts at
|
|
157
|
+
100% of the approved budget). Dashboards: [docs/langfuse-dashboards.md](docs/langfuse-dashboards.md).
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## The artifact chain
|
|
162
|
+
|
|
163
|
+
The pipeline's real data structure is `specs/<feature>/` — agents are transient,
|
|
164
|
+
artifacts are the pipeline:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
specs/<feature>/
|
|
168
|
+
├── discovery.md # interview output; AC1, AC2… ; appended on escalation
|
|
169
|
+
├── context-pack.md # ~2k-token brief, sectioned per work area
|
|
170
|
+
├── plan.md # tasks + footprints + satisfies: + VERSION-IMPACT + est_tokens
|
|
171
|
+
├── contracts/ # interface agreements, written before any specialist runs
|
|
172
|
+
├── task-graph.json # computed schedule (extract-deps.py)
|
|
173
|
+
├── approvals.json # the signed autonomy boundary (plan hash + budget)
|
|
174
|
+
├── state.json # per-task status, attempts, phase, breaker health (hook-written)
|
|
175
|
+
├── review.<task>.<n>.json # reviewer verdicts, one per attempt
|
|
176
|
+
├── dependencies.json # dependency deltas vs baseline (+ .baseline.json)
|
|
177
|
+
├── version.json # code semver bump for this feature
|
|
178
|
+
├── history.json # development-version snapshots per milestone
|
|
179
|
+
├── divergence.json # footprint violations + token burndown
|
|
180
|
+
└── pipeline-context.md # PreCompact snapshot (load-bearing minimum)
|
|
181
|
+
specs/_queue.json · _answers.json · _active.json # the loop driver's ledgers
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## What gets installed
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
.claude/
|
|
190
|
+
├── settings.json # hook registrations (merged)
|
|
191
|
+
├── agents/ # context-researcher, planner, specialist-{db,backend,frontend}, reviewer
|
|
192
|
+
├── skills/ # discovery-interview, contract-writing, review-taxonomy
|
|
193
|
+
├── commands/feature.md # /feature — pipeline entry point
|
|
194
|
+
├── hooks/ # 12 hooks + harness_lib, loop_lib, req_lib, version_lib
|
|
195
|
+
└── scripts/ # extract-deps.py, approve-plan.py, changeset.py, version.py
|
|
196
|
+
CLAUDE.md # merged harness block
|
|
197
|
+
harness.config.json # every knob (below)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Configuration — `harness.config.json`
|
|
203
|
+
|
|
204
|
+
```jsonc
|
|
205
|
+
{
|
|
206
|
+
"graphify": { "binary": "graphify", "index_path": "graphify-out/" },
|
|
207
|
+
"retries": { "mechanical": 2, "contract": 1, "ambiguity": 0, "security": 0 },
|
|
208
|
+
"dependency_depth": 1,
|
|
209
|
+
"retrieval_nudge": { "mode": "block", "auto_init": true },
|
|
210
|
+
"budgets": { "context_pack": 2000, "review_findings": 500, "dispatch_prompt_max": 8000 },
|
|
211
|
+
"circuit_breaker": { "footprint_violations": { "warn": 1, "halt": 2 },
|
|
212
|
+
"max_escalations_per_feature": 2, "burndown": { "warn_pct": 80, "halt_pct": 100 } },
|
|
213
|
+
"requirements": { "enabled": true, "min_acceptance_criteria": 1, "require_coverage": true },
|
|
214
|
+
"versioning": { "enabled": true, "require_major_dep_ack": true, "require_code_bump": true, "require_dev_history": true },
|
|
215
|
+
"loop": { "queue": "specs/_queue.json", "answer_queue": "specs/_answers.json", "auto_approve": false },
|
|
216
|
+
"observability": { "provider": "langfuse", "enabled": true, "host": "env:LANGFUSE_HOST",
|
|
217
|
+
"public_key": "env:LANGFUSE_PUBLIC_KEY", "secret_key": "env:LANGFUSE_SECRET_KEY" },
|
|
218
|
+
"gates": { "commit_requires_integration_review": true, "commit_requires_version": true }
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Secrets are `env:` references only — never literal keys on disk. Any gate is a
|
|
223
|
+
config toggle; bypasses are logged decisions, not silent workarounds.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Observability & the flywheel
|
|
228
|
+
|
|
229
|
+
`trace-emitter.py` streams one Langfuse trace per session (trace id = `session_id`),
|
|
230
|
+
with subagent spans, tool child-spans, and review verdicts as scores. Every failed
|
|
231
|
+
review and escalation lands in a regression dataset labeled with `failure_class`.
|
|
232
|
+
If Langfuse is unreachable it falls back to `.claude/traces/` — observability never
|
|
233
|
+
becomes an availability dependency. Offline view: `agent-harness metrics`.
|
|
234
|
+
|
|
235
|
+
## Standalone tool
|
|
236
|
+
|
|
237
|
+
Try the deterministic dependency extractor with zero agents:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
python .claude/scripts/extract-deps.py --files src/auth/ # "what does this change touch?"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Testing
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
npm test # node tests/run.js — 84 checks across extract-deps determinism,
|
|
247
|
+
# every hook (stdin fixtures), the loop driver (mock runner),
|
|
248
|
+
# metrics, the Version Controller, and installer byte-identity
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Requirements
|
|
252
|
+
|
|
253
|
+
- **Node ≥ 16** (installer + loop driver; zero runtime dependencies)
|
|
254
|
+
- **Python 3.8+** on PATH (`python`, `python3`, or `py`) for hooks/scripts
|
|
255
|
+
- **graphify** for graph-first retrieval — `/graphify` self-installs it on first run
|
|
256
|
+
- **Claude Code** (for the `claude -p` headless loop) and, optionally, **Langfuse**
|
|
257
|
+
|
|
258
|
+
## Reversibility
|
|
259
|
+
|
|
260
|
+
`uninstall` removes the `_harness` hooks, the harness files, and the delimited
|
|
261
|
+
`CLAUDE.md` block, and leaves `specs/`. A pre-populated `.claude/` returns
|
|
262
|
+
byte-identical. Reversibility is an adoption feature, not an afterthought.
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Loop Engineering harness installer.
|
|
4
|
+
// agent-harness init [target] [--yes] [--python <cmd>]
|
|
5
|
+
// agent-harness detect [target]
|
|
6
|
+
// agent-harness uninstall [target] [--yes]
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
const { detect, formatReport } = require('../src/detect');
|
|
11
|
+
const { install } = require('../src/install');
|
|
12
|
+
const { uninstall } = require('../src/uninstall');
|
|
13
|
+
const loop = require('../src/loop');
|
|
14
|
+
const { computeMetrics, formatMetrics } = require('../src/metrics');
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const out = { _: [], yes: false, python: null, dryRun: false, once: false, autoApprove: false };
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === '--yes' || a === '-y') out.yes = true;
|
|
21
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
22
|
+
else if (a === '--once') out.once = true;
|
|
23
|
+
else if (a === '--auto-approve') out.autoApprove = true;
|
|
24
|
+
else if (a === '--python') out.python = argv[++i];
|
|
25
|
+
else if (a.startsWith('--python=')) out.python = a.slice(9);
|
|
26
|
+
else out._.push(a);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function confirm(question) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
if (!process.stdin.isTTY) return resolve(false);
|
|
34
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
35
|
+
rl.question(question, (ans) => { rl.close(); resolve(/^y(es)?$/i.test(ans.trim())); });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main() {
|
|
40
|
+
const args = parseArgs(process.argv.slice(2));
|
|
41
|
+
const cmd = args._[0] || 'help';
|
|
42
|
+
const target = path.resolve(args._[1] || '.');
|
|
43
|
+
|
|
44
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
45
|
+
console.log([
|
|
46
|
+
'Loop Engineering — agent harness for brownfield Claude Code repos',
|
|
47
|
+
'',
|
|
48
|
+
'Usage:',
|
|
49
|
+
' agent-harness init [target] Install into a repo (prints a plan first)',
|
|
50
|
+
' agent-harness detect [target] Show the install plan, write nothing',
|
|
51
|
+
' agent-harness uninstall [target] Remove the harness (keeps specs/)',
|
|
52
|
+
'',
|
|
53
|
+
' agent-harness queue add "<desc>" Add a feature to the work queue',
|
|
54
|
+
' agent-harness queue list Show the queue and parked features',
|
|
55
|
+
' agent-harness loop Run the autonomous driver over the queue',
|
|
56
|
+
' agent-harness requeue <id> Re-queue a parked feature after answering',
|
|
57
|
+
' agent-harness metrics Per-feature burndown + tokens-per-task',
|
|
58
|
+
'',
|
|
59
|
+
'Flags: --yes | -y skip confirmation --python <cmd> force interpreter',
|
|
60
|
+
' --once one feature then stop --auto-approve headless self-approval',
|
|
61
|
+
' --dry-run plan the loop, run nothing',
|
|
62
|
+
].join('\n'));
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (cmd === 'queue') {
|
|
67
|
+
const sub = args._[1];
|
|
68
|
+
const root = path.resolve(args._[2] || '.');
|
|
69
|
+
if (sub === 'add') {
|
|
70
|
+
const desc = args._[2];
|
|
71
|
+
if (!desc) { console.error('usage: agent-harness queue add "<description>"'); return 1; }
|
|
72
|
+
const id = loop.addToQueue(process.cwd(), desc, { autoApprove: args.autoApprove, now: new Date().toISOString() });
|
|
73
|
+
console.log(`queued '${id}'`);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
if (sub === 'list' || !sub) {
|
|
77
|
+
const q = loop.loadQueue(root);
|
|
78
|
+
const ans = require('fs').existsSync(loop.answersPath(root))
|
|
79
|
+
? JSON.parse(require('fs').readFileSync(loop.answersPath(root), 'utf8')) : { parked: [] };
|
|
80
|
+
console.log('Queue:');
|
|
81
|
+
for (const f of q.features) console.log(` [${f.status}] ${f.id} — ${f.description}`);
|
|
82
|
+
if (ans.parked && ans.parked.length) {
|
|
83
|
+
console.log('\nParked (need a human):');
|
|
84
|
+
for (const p of ans.parked) console.log(` ${p.id} (${p.terminal}): ${p.question}`);
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
console.error(`unknown: queue ${sub}`); return 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (cmd === 'requeue') {
|
|
92
|
+
loop.requeue(process.cwd(), args._[1], new Date().toISOString());
|
|
93
|
+
console.log(`re-queued '${args._[1]}'`);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (cmd === 'loop') {
|
|
98
|
+
const root = path.resolve(args._[1] || '.');
|
|
99
|
+
if (args.dryRun) {
|
|
100
|
+
const q = loop.loadQueue(root);
|
|
101
|
+
const pending = q.features.filter((f) => f.status === 'pending');
|
|
102
|
+
console.log(`dry-run: ${pending.length} pending feature(s): ${pending.map((f) => f.id).join(', ') || '(none)'}`);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
const summary = await loop.processQueue(root, {
|
|
106
|
+
once: args.once, autoApprove: args.autoApprove, log: (m) => console.log(m),
|
|
107
|
+
});
|
|
108
|
+
console.log(`\nDone. committed: ${summary.committed.length}, ` +
|
|
109
|
+
`escalated: ${summary.escalated.length}, needs_approval: ${summary.needs_approval.length}, ` +
|
|
110
|
+
`incomplete: ${summary.incomplete.length}`);
|
|
111
|
+
if (summary.escalated.length || summary.needs_approval.length) {
|
|
112
|
+
console.log('Parked features await a human — see: agent-harness queue list');
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (cmd === 'metrics') {
|
|
118
|
+
console.log(formatMetrics(computeMetrics(path.resolve(args._[1] || '.'))));
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (cmd === 'detect') {
|
|
123
|
+
console.log(formatReport(detect(target, args.python)));
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (cmd === 'init' || cmd === 'install') {
|
|
128
|
+
const report = detect(target, args.python);
|
|
129
|
+
console.log(formatReport(report));
|
|
130
|
+
console.log('');
|
|
131
|
+
if (!report.settings.valid) { console.error('Aborted: fix settings.json first.'); return 1; }
|
|
132
|
+
if (!args.yes) {
|
|
133
|
+
const ok = await confirm('Proceed with install? [y/N] ');
|
|
134
|
+
if (!ok) {
|
|
135
|
+
console.log('Aborted. (Re-run with --yes for non-interactive install.)');
|
|
136
|
+
return process.stdin.isTTY ? 0 : 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const { manifest } = install(target, { python: args.python, now: new Date().toISOString() });
|
|
140
|
+
console.log(`Installed. CLAUDE.md: ${manifest.claudeMd}; ` +
|
|
141
|
+
`${manifest.filesAdded.length} files added` +
|
|
142
|
+
(manifest.filesRenamed.length ? `, ${manifest.filesRenamed.length} collision(s) renamed to *.harness.*` : '') +
|
|
143
|
+
`. Next: /feature <description>`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (cmd === 'uninstall') {
|
|
148
|
+
if (!args.yes) {
|
|
149
|
+
const ok = await confirm(`Uninstall the harness from ${target}? (specs/ is kept) [y/N] `);
|
|
150
|
+
if (!ok) { console.log('Aborted.'); return process.stdin.isTTY ? 0 : 1; }
|
|
151
|
+
}
|
|
152
|
+
const res = uninstall(target);
|
|
153
|
+
console.log(`Uninstalled. Removed ${res.removed.length} files; ` +
|
|
154
|
+
`settings: ${res.settings}; CLAUDE.md: ${res.claudeMd}` +
|
|
155
|
+
(res.usedManifest ? '' : ' (no manifest — heuristic uninstall)') + '. specs/ left in place.');
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.error(`Unknown command: ${cmd}. Try: agent-harness help`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
main().then((code) => process.exit(code || 0)).catch((err) => {
|
|
164
|
+
console.error('Error: ' + err.message);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Langfuse Dashboards (Phase L5)
|
|
2
|
+
|
|
3
|
+
`trace-emitter.py` streams one trace per session (trace id = `session_id`), with
|
|
4
|
+
subagent spans, tool child-spans, and review verdicts as scores. These three
|
|
5
|
+
dashboards turn that stream into the flywheel's instrument panel. Build them in
|
|
6
|
+
the Langfuse UI (Dashboards → New) over your project; the metric each needs is
|
|
7
|
+
noted. The same numbers are available offline via `agent-harness metrics`.
|
|
8
|
+
|
|
9
|
+
## 1. Per-feature burndown
|
|
10
|
+
**Question:** is a feature burning toward or through its approved budget?
|
|
11
|
+
- **Source:** score/observation `tokens_spent` accumulated per feature vs the
|
|
12
|
+
`budget` recorded in `approvals.json` (the driver writes cumulative tokens into
|
|
13
|
+
`divergence.json`; trace metadata carries `feature`).
|
|
14
|
+
- **Chart:** line — cumulative tokens over session time, with a horizontal marker
|
|
15
|
+
at 80% (warn) and 100% (halt) of budget. A feature crossing 100% is exactly
|
|
16
|
+
what trips the circuit breaker's burndown signal.
|
|
17
|
+
|
|
18
|
+
## 2. Tokens-per-passing-task, by specialist
|
|
19
|
+
**Question:** which compression stage is leaking? (Rule 5's leak detector.)
|
|
20
|
+
- **Source:** per subagent span, `output_tokens` ÷ passing tasks for that
|
|
21
|
+
specialist type (`specialist-backend`, `-frontend`, `-database`).
|
|
22
|
+
- **Chart:** time series, one line per specialist type. A steady creep on one
|
|
23
|
+
line localizes the leak to that specialist's retrieval/context slice — the
|
|
24
|
+
trace then shows which span carries the extra tokens.
|
|
25
|
+
|
|
26
|
+
## 3. Estimate-vs-actual, by task type
|
|
27
|
+
**Question:** is the planner's cost estimate trustworthy?
|
|
28
|
+
- **Source:** planner-emitted per-task token estimate (in the approval package)
|
|
29
|
+
vs actual tokens for that task's spans.
|
|
30
|
+
- **Chart:** scatter — estimate (x) vs actual (y), colored by failure class. Points
|
|
31
|
+
far above the diagonal are underestimated task types; feed that back into the
|
|
32
|
+
planner's estimates and the config `budgets.per_task_default`.
|
|
33
|
+
|
|
34
|
+
## Regression dataset (the flywheel closes here)
|
|
35
|
+
Every `"status": "fail"` review and every escalation lands as a dataset item
|
|
36
|
+
labeled with `failure_class`, attempts, and resolution. Before merging any change
|
|
37
|
+
to a skill, agent prompt, or hook, replay the dataset and compare scores — the
|
|
38
|
+
reviewer agent is the native evaluation producer, so "evaluate" never waits on
|
|
39
|
+
human labeling.
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "loop-engineering-harness",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Loop Engineering — a reversible, observable multi-agent Claude Code harness for brownfield codebases. Policy lives in exit codes and artifacts on disk, not in prompts.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"loop-engineering-harness",
|
|
8
|
+
"context-engineering",
|
|
9
|
+
"graphify",
|
|
10
|
+
"langfuse",
|
|
11
|
+
"brownfield"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "commonjs",
|
|
15
|
+
"bin": {
|
|
16
|
+
"agent-harness": "bin/cli.js",
|
|
17
|
+
"loop-engineering": "bin/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin/",
|
|
21
|
+
"src/",
|
|
22
|
+
"template/",
|
|
23
|
+
"docs/",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node tests/run.js",
|
|
28
|
+
"install-here": "node bin/cli.js init .",
|
|
29
|
+
"uninstall-here": "node bin/cli.js uninstall ."
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=16"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Builds the pre-flight detection report: what exists, what will be added,
|
|
3
|
+
// merged, or renamed. Pure inspection — writes nothing.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const m = require('./merge');
|
|
8
|
+
|
|
9
|
+
function templateDir() { return path.join(__dirname, '..', 'template'); }
|
|
10
|
+
|
|
11
|
+
// The set of files the harness ships into the adopter's .claude/ tree.
|
|
12
|
+
function planClaudeFiles(target) {
|
|
13
|
+
const src = path.join(templateDir(), '.claude');
|
|
14
|
+
return m.walk(src).map((abs) => {
|
|
15
|
+
const rel = path.relative(templateDir(), abs); // e.g. .claude/hooks/x.py
|
|
16
|
+
return { src: abs, rel, dest: path.join(target, rel) };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function classify(dest, src) {
|
|
21
|
+
if (!fs.existsSync(dest)) return { status: 'add' };
|
|
22
|
+
const a = fs.readFileSync(dest), b = fs.readFileSync(src);
|
|
23
|
+
if (a.equals(b)) return { status: 'identical' };
|
|
24
|
+
const ext = path.extname(dest);
|
|
25
|
+
return { status: 'collision', renamedTo: dest.slice(0, -ext.length || undefined) + '.harness' + ext };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function detect(target, python) {
|
|
29
|
+
const report = { target, python: python || m.detectPython(), files: [] };
|
|
30
|
+
|
|
31
|
+
for (const f of planClaudeFiles(target)) {
|
|
32
|
+
report.files.push(Object.assign({ rel: f.rel.replace(/\\/g, '/') }, classify(f.dest, f.src)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cfgDest = path.join(target, 'harness.config.json');
|
|
36
|
+
report.config = { exists: fs.existsSync(cfgDest), action: fs.existsSync(cfgDest) ? 'keep' : 'add' };
|
|
37
|
+
|
|
38
|
+
const claudeDest = path.join(target, 'CLAUDE.md');
|
|
39
|
+
const claudeRaw = m.read(claudeDest);
|
|
40
|
+
report.claudeMd = {
|
|
41
|
+
exists: claudeRaw !== null,
|
|
42
|
+
action: claudeRaw === null ? 'create'
|
|
43
|
+
: claudeRaw.includes(m.BEGIN) ? 'upgrade-block' : 'append-block',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const setDest = path.join(target, '.claude', 'settings.json');
|
|
47
|
+
const setRaw = m.read(setDest);
|
|
48
|
+
let valid = true;
|
|
49
|
+
if (setRaw !== null && setRaw.trim() !== '') { try { JSON.parse(setRaw); } catch (_) { valid = false; } }
|
|
50
|
+
report.settings = { exists: setRaw !== null, valid, action: 'merge-hooks' };
|
|
51
|
+
|
|
52
|
+
return report;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatReport(r) {
|
|
56
|
+
const L = [];
|
|
57
|
+
L.push('Loop Engineering — installation plan');
|
|
58
|
+
L.push(' target: ' + r.target);
|
|
59
|
+
L.push(' python: ' + r.python);
|
|
60
|
+
L.push('');
|
|
61
|
+
const adds = r.files.filter((f) => f.status === 'add');
|
|
62
|
+
const same = r.files.filter((f) => f.status === 'identical');
|
|
63
|
+
const cols = r.files.filter((f) => f.status === 'collision');
|
|
64
|
+
L.push(` files to add: ${adds.length}`);
|
|
65
|
+
if (same.length) L.push(` already present: ${same.length} (identical, skipped)`);
|
|
66
|
+
for (const c of cols) L.push(` ! COLLISION: ${c.rel} -> ${path.basename(c.renamedTo)} (yours kept)`);
|
|
67
|
+
L.push('');
|
|
68
|
+
L.push(` harness.config.json: ${r.config.action}`);
|
|
69
|
+
L.push(` CLAUDE.md: ${r.claudeMd.action}`);
|
|
70
|
+
if (!r.settings.valid) {
|
|
71
|
+
L.push(' .claude/settings.json: INVALID JSON — install will abort. Fix it first.');
|
|
72
|
+
} else {
|
|
73
|
+
L.push(` .claude/settings.json: ${r.settings.action} (existing hooks preserved, ` +
|
|
74
|
+
`harness hooks appended with _harness markers)`);
|
|
75
|
+
}
|
|
76
|
+
return L.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { detect, formatReport, planClaudeFiles, templateDir };
|
package/src/install.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Applies the plan from detect.js. Writes a manifest so uninstall is exact.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const m = require('./merge');
|
|
7
|
+
const { detect, planClaudeFiles, templateDir } = require('./detect');
|
|
8
|
+
|
|
9
|
+
const MANIFEST_REL = path.join('.claude', '.harness', 'manifest.json');
|
|
10
|
+
|
|
11
|
+
function install(target, opts) {
|
|
12
|
+
opts = opts || {};
|
|
13
|
+
target = path.resolve(target);
|
|
14
|
+
const python = opts.python || m.detectPython();
|
|
15
|
+
const report = detect(target, python);
|
|
16
|
+
|
|
17
|
+
if (!report.settings.valid) {
|
|
18
|
+
throw new Error('.claude/settings.json is not valid JSON — aborting (no files written).');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const manifest = {
|
|
22
|
+
version: '1.0', python, installedAt: opts.now || null,
|
|
23
|
+
filesAdded: [], filesRenamed: [], configWritten: false,
|
|
24
|
+
claudeMd: null, settingsEvents: [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// 1) .claude tree — collision-safe copy
|
|
28
|
+
for (const f of planClaudeFiles(target)) {
|
|
29
|
+
const res = m.copyFileSafe(f.src, f.dest);
|
|
30
|
+
const rel = path.relative(target, res.dest).replace(/\\/g, '/');
|
|
31
|
+
if (res.status === 'added') manifest.filesAdded.push(rel);
|
|
32
|
+
else if (res.status === 'renamed') {
|
|
33
|
+
manifest.filesRenamed.push(path.relative(target, res.renamedTo).replace(/\\/g, '/'));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2) harness.config.json — add only if absent (never clobber adopter's tuning)
|
|
38
|
+
const cfgDest = path.join(target, 'harness.config.json');
|
|
39
|
+
if (!fs.existsSync(cfgDest)) {
|
|
40
|
+
fs.copyFileSync(path.join(templateDir(), 'harness.config.json'), cfgDest);
|
|
41
|
+
manifest.configWritten = true;
|
|
42
|
+
manifest.filesAdded.push('harness.config.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3) CLAUDE.md — delimited block
|
|
46
|
+
const block = m.read(path.join(templateDir(), 'CLAUDE.harness.md'));
|
|
47
|
+
manifest.claudeMd = m.mergeClaudeMd(path.join(target, 'CLAUDE.md'), block).action;
|
|
48
|
+
|
|
49
|
+
// 4) settings.json — deep-merge hooks
|
|
50
|
+
const payload = JSON.parse(m.read(path.join(templateDir(), 'settings.harness.json')));
|
|
51
|
+
const res = m.mergeSettings(path.join(target, '.claude', 'settings.json'), payload.hooks, python);
|
|
52
|
+
manifest.settingsEvents = res.appended;
|
|
53
|
+
|
|
54
|
+
m.writeJSON(path.join(target, MANIFEST_REL), manifest);
|
|
55
|
+
return { report, manifest };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { install, MANIFEST_REL };
|