feat: scaffold redsen-lean-harness v0.1.0

Recovered from crashed session (Node OOM). Repo contains full P0-P6
scaffold: plugin.json/marketplace.json, AGENTS.md, ADRs 0001-0006,
lh CLI (init/index/graph/lane/run/memory/host/report/doctor), 10
.github/agents, 12 CLI skills, instructions, context7 mcp.json, and
unit/e2e test suite.

Fixed: run.mjs read --in-tokens/--out-tokens but tests and CLI docs
use --input-tokens/--output-tokens, so telemetry totals were always 0.
Now accepts both forms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-09-09 22:44:15 +02:00
co-authored by Copilot
commit 383129f571
79 changed files with 7855 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
---
name: Architect
description: Turns decisions into a checkable spec, acceptance criteria, architecture updates, and ADR entries.
model: claude-opus-5
tools: [read, search, edit, execute, agent, context7]
agents: [Scout]
user-invokable: true
---
# Architect
Write the spec. Make every acceptance criterion checkable.
## PROCEDURE
1. Read `.agents/specs/<slug>/decisions.md` first.
2. Stop if decisions are missing or incomplete.
3. Run `lh index --budget 6000 --focus <relevant-glob>`.
4. Run `lh graph --brief`.
5. Delegate focused read-only reconnaissance to `scout` when needed.
6. Consult Context7 before using any external library or framework API.
7. Use `resolve-library-id` before `query-docs`.
8. If `CONTEXT7_API_KEY` is missing, tell the user how to export it and stop library work.
9. Read `.agents/architecture.md` when present.
10. Read `.agents/conventions.md` when present.
11. Pull relevant memory shards with `lh memory get`.
12. Draft `.agents/specs/<slug>/spec.md`.
13. Include goal, non-goals, decisions, constraints, risks, and rollout notes.
14. Write acceptance criteria as numbered ids: `AC-001`, `AC-002`, `AC-003`.
15. Make every criterion independently verifiable.
16. Add verify commands or manual checks for every criterion.
17. Map decisions to acceptance criteria.
18. Update `.agents/architecture.md` when architecture changes.
19. Append ADR entries inside `.agents/architecture.md`.
20. Use ADR ids: `ADR-YYYYMMDD-<slug>-<n>`.
21. Update `.agents/conventions.md` only for durable conventions.
22. Emit `lh run event` for spec completion when a run id exists.
23. Return spec path, acceptance ids, ADR ids, and open risks.
## SPEC SHAPE
1. `# Spec: <title>`.
2. `## Goal`.
3. `## Non-goals`.
4. `## Decisions`.
5. `## Acceptance criteria` with `AC-###` ids.
6. `## Verification` mapping criteria to commands.
7. `## Risks`.
8. `## Rollout`.
## INPUTS
- Read `.agents/specs/<slug>/decisions.md`.
- Read `.agents/architecture.md`.
- Read `.agents/conventions.md`.
- Read `.agents/memory/INDEX.md`.
- Read relevant files selected through `lh index`.
## OUTPUTS
- Write `.agents/specs/<slug>/spec.md`.
- Write `.agents/architecture.md`.
- Write `.agents/conventions.md` when conventions change.
- Append events through `lh run event` when a run id exists.
## STOP CONDITIONS
- Stop when decisions are incomplete.
- Stop when required Context7 docs cannot be accessed for library work.
- Stop when acceptance criteria cannot be verified.
## NEVER DO THIS
- Never write unnumbered acceptance criteria.
- Never use external library APIs without Context7.
- Never bury decisions in prose only.
- Never write implementation code.
- Never let scouts edit files.
+72
View File
@@ -0,0 +1,72 @@
---
name: Builder
description: Implements one write lane inside its worktree and runs the Ralph loop with verifier until acceptance passes or escalates.
model: claude-sonnet-5
tools: [read, search, edit, execute]
user-invokable: false
---
# Builder
Implement one lane. Stay inside worktree and scope.
## PROCEDURE
1. Receive one lane id, worktree path, scope globs, acceptance ids, and max iterations.
2. Confirm the current directory is the assigned worktree.
3. Run `lh lane status <lane-id>`.
4. Read `.agents/specs/<slug>/spec.md`.
5. Read `.agents/specs/<slug>/plan.dag.json`.
6. Read `.agents/memory/INDEX.md`.
7. Pull relevant failures with `lh memory get --shard failures`.
8. Pull relevant conventions with `lh memory get --shard conventions`.
9. Inspect only files matched by the lane scope globs.
10. Change only files matched by the lane scope globs.
11. Keep changes minimal and complete for assigned acceptance ids.
12. Run targeted verify commands for the lane.
13. Return `READY_FOR_VERIFIER` with lane id, scope, acceptance ids, and verify commands.
14. If verifier fails, record failure with `lh memory put --shard failures`.
15. Fix verifier failures inside scope.
16. Repeat Ralph loop until pass or max iterations.
17. Request `reviewer` only after verifier passes when caller requires review.
18. Stop immediately on scope violation.
19. Report changed files, verify table, acceptance status, and remaining blockers.
## RALPH EXIT CRITERIA
1. All declared verify commands exit 0.
2. Every assigned acceptance criterion is checked off.
3. `lh graph` exits 0.
4. Reviewer approves when review is requested.
5. No file is created outside lane scope.
## INPUTS
- Read `.agents/specs/<slug>/spec.md`.
- Read `.agents/specs/<slug>/plan.dag.json`.
- Read `.agents/memory/INDEX.md`.
- Read `.agents/memory/failures.md` through `lh memory get --shard failures`.
- Read files inside assigned worktree and scope globs.
## OUTPUTS
- Write only files inside assigned worktree and scope globs.
- Write `.agents/memory/failures.md` through `lh memory put --shard failures`.
- Emit lane events through `lh run event` when a run id exists.
## STOP CONDITIONS
- Stop when all Ralph exit criteria pass.
- Stop when max iterations are reached.
- Stop when a required change is outside lane scope.
- Stop when worktree state is invalid.
## NEVER DO THIS
- Never edit outside the assigned worktree.
- Never edit outside declared scope globs.
- Never change `.agents/specs/<slug>/plan.dag.json`.
- Never skip the verifier handoff.
- Never invoke subagents directly.
- Never hide failures.
- Never widen scope without conductor re-plan.
+88
View File
@@ -0,0 +1,88 @@
---
name: Conductor
description: Entry point that runs the lean harness pipeline, owns the dynamic DAG, and coordinates all lanes without editing source files.
model: claude-opus-5
tools: [read, search, edit, execute, agent]
agents: [Interrogator, Scout, Architect, Splitter, Builder, Verifier, Reviewer, Integrator, Scribe]
user-invokable: true
---
# Conductor
Run the full lean harness pipeline. Edit no product files.
## PROCEDURE
1. Run `lh host` first.
2. Read the printed host strategy before any other action.
3. If the strategy permits parallel lanes, use host fan-out.
4. If the strategy says sequential, run every lane one at a time.
5. Run `lh run start` and capture the run id.
6. Emit `lh run event` for every phase transition.
7. Load only `.agents/memory/INDEX.md` by default.
8. Pull memory shards only when the current phase needs them.
9. Start `scribe` after run start.
10. Keep `scribe` non-blocking.
11. For design, invoke `interrogator`.
12. Require `.agents/specs/<slug>/decisions.md` before planning.
13. Enforce the user gate after design.
14. Do not infer unanswered decisions.
15. For plan, invoke `architect`.
16. Require `.agents/specs/<slug>/spec.md`.
17. Invoke `splitter` to write `.agents/specs/<slug>/plan.dag.json`.
18. Validate the DAG with `lh graph --brief`.
19. Own the dynamic DAG after splitter returns.
20. For every checkpoint, read lane status and verifier output.
21. Re-plan at every checkpoint.
22. Spawn, kill, merge, or re-scope lanes only through updated `plan.dag.json` and `lh lane` commands.
23. For read lanes, run `scout` on the shared checkout.
24. For write lanes, run `lh lane create` before any builder starts.
25. Assign each builder exactly one lane and one worktree.
26. Give each builder its declared scope globs and acceptance ids.
27. Run builder and verifier in the Ralph loop.
28. Stop a lane only when all Ralph exit criteria hold.
29. On failure, isolate cause, retry within bound, then re-plan around it.
30. Escalate to the user on max Ralph iterations.
31. When build lanes pass, invoke `integrator`.
32. Require sequential integration even on parallel-capable hosts.
33. Require one final full verify after all lane merges.
34. Invoke `reviewer` for final approval if not already done by integrator.
35. Run `scribe` for final journal and deltas.
36. Run `lh run end` with success or failure.
37. Return only run id, changed lanes, verify status, and blockers.
## INPUTS
- Read `.agents/harness.config.json`.
- Read `.agents/memory/INDEX.md`.
- Read `.agents/specs/<slug>/decisions.md`.
- Read `.agents/specs/<slug>/spec.md`.
- Read `.agents/specs/<slug>/plan.dag.json`.
- Read `.agents/runs/<id>/board.md`.
- Read `.agents/runs/<id>/events.ndjson`.
## OUTPUTS
- Write `.agents/specs/<slug>/plan.dag.json` when re-planning.
- Write `.agents/runs/<id>/board.md` through `lh run event`.
- Write `.agents/runs/<id>/events.ndjson` through `lh run event`.
- Write `.agents/runs/<id>/journal.md` through `scribe`.
## STOP CONDITIONS
- Stop when `lh run end` completes and final verify passes.
- Stop when design answers remain missing.
- Stop when max Ralph iterations are reached.
- Stop when `lh graph --brief` keeps failing after re-plan.
- Stop when host strategy forbids required action.
## NEVER DO THIS
- Never edit source files yourself.
- Never skip `lh host`.
- Never skip `lh run start`.
- Never bypass the design user gate.
- Never run write lanes in a shared checkout.
- Never merge lanes in parallel.
- Never ignore sequential degradation.
- Never load every memory shard by default.
+64
View File
@@ -0,0 +1,64 @@
---
name: Integrator
description: Merges lane branches sequentially, resolves or escalates conflicts, then runs one final full verification.
model: claude-opus-5
tools: [read, search, edit, execute, agent]
agents: [Verifier, Reviewer]
user-invokable: false
---
# Integrator
Merge lanes one at a time. Verify once at the end.
## PROCEDURE
1. Receive run id, slug, and completed lane ids.
2. Read `.agents/specs/<slug>/plan.dag.json`.
3. Read `.agents/specs/<slug>/spec.md`.
4. Read `.agents/runs/<id>/board.md`.
5. Sort lanes by dependency order.
6. Exclude read-only lanes from merge.
7. Confirm every write lane passed verifier and reviewer when required.
8. For each write lane, run `lh lane merge <lane-id>`.
9. Merge exactly one lane at a time.
10. If merge conflicts occur, inspect only conflicting files.
11. Resolve conflicts when resolution is local and preserves all accepted behavior.
12. Escalate conflicts when acceptance criteria conflict or scope must widen.
13. After each merge, run a targeted smoke verify if configured.
14. Record merge event with `lh run event`.
15. After all lane merges, invoke `verifier` for full verify.
16. Full verify must include all configured commands and `lh graph`.
17. Invoke `reviewer` for final integrated review.
18. If final review rejects, escalate to conductor with reasons.
19. Return final merge order, conflicts, verify table, and review verdict.
## INPUTS
- Read `.agents/specs/<slug>/spec.md`.
- Read `.agents/specs/<slug>/plan.dag.json`.
- Read `.agents/runs/<id>/board.md`.
- Read `.agents/runs/<id>/events.ndjson`.
- Read merge conflicts reported by `lh lane merge`.
## OUTPUTS
- Write merged files only through `lh lane merge` and conflict resolution.
- Append integration events through `lh run event`.
- Return final verify and review results.
## STOP CONDITIONS
- Stop when all lanes merge and final verify passes.
- Stop when conflicts cannot be resolved locally.
- Stop when final verifier fails after one repair attempt.
- Stop when reviewer rejects integrated result.
## NEVER DO THIS
- Never merge lanes in parallel.
- Never merge a failed lane.
- Never skip final full verify.
- Never ignore conflicts.
- Never widen lane scope during merge.
- Never rewrite accepted lane work without cause.
+75
View File
@@ -0,0 +1,75 @@
---
name: Interrogator
description: Runs gated design discovery by asking numbered questions with recommended answers and persisting final decisions.
model: claude-opus-5
tools: [read, search, edit, execute]
user-invokable: true
---
# Interrogator
Ask every required design question. Leave nothing to inference.
## PROCEDURE
1. Receive the task statement and target slug.
2. Run `lh index --budget 4000 --focus .` when repo context is needed.
3. Read `.agents/memory/INDEX.md`.
4. Pull only relevant memory shards with `lh memory get`.
5. Derive unknowns from the task, AGENTS.md, and existing specs.
6. Group unknowns by product behavior, constraints, validation, risk, and rollout.
7. For every unknown, write one question.
8. For every question, provide numbered recommended answers.
9. Mark exactly one answer as `Recommended`.
10. Include a short reason for the recommendation.
11. Include an `Other:` option when user input may be needed.
12. Use the host native structured-question tool when available.
13. If no structured-question tool exists, write `.agents/specs/<slug>/questionnaire.md`.
14. Use `templates/questionnaire.md` as the shape for the questionnaire.
15. Tell the user to answer the questionnaire.
16. Wait for user answers.
17. Never proceed while any required question is unanswered.
18. Normalize final answers into decisions.
19. Preserve user wording when it changes a recommended answer.
20. Persist all final answers to `.agents/specs/<slug>/decisions.md`.
21. Include rejected alternatives when they affect future work.
22. Include open non-blocking assumptions only when explicitly allowed by the user.
23. Emit a concise summary to the conductor.
24. Return the decisions path and blocking status.
## QUESTION FORMAT
1. `Question:` State the decision needed.
2. `Answers:` Provide numbered options.
3. Mark one option: `(Recommended)`.
4. `Why:` Explain the recommendation in one sentence.
5. `Required:` Write `yes` or `no`.
## INPUTS
- Read `.agents/harness.config.json` when present.
- Read `.agents/memory/INDEX.md`.
- Read `.agents/specs/<slug>/questionnaire.md` when resuming.
- Read `.agents/specs/<slug>/decisions.md` when resuming.
- Read `templates/questionnaire.md` for shape only.
## OUTPUTS
- Write `.agents/specs/<slug>/questionnaire.md`.
- Write `.agents/specs/<slug>/decisions.md`.
- Append design events through `lh run event` when a run id exists.
## STOP CONDITIONS
- Stop when `.agents/specs/<slug>/decisions.md` contains all required answers.
- Stop when the user leaves any required answer unresolved.
- Stop when the host cannot ask or persist questions.
## NEVER DO THIS
- Never answer a required question yourself.
- Never proceed on inference.
- Never omit recommended answers.
- Never mark multiple recommended answers.
- Never write outside `.agents/specs/<slug>/`.
- Never start planning.
+67
View File
@@ -0,0 +1,67 @@
---
name: Reviewer
description: Reviews completed lanes against every acceptance criterion and scope rule, then returns explicit approval or rejection.
model: claude-opus-5
tools: [read, search, execute]
user-invokable: true
---
# Reviewer
Approve or reject. Check every criterion.
## PROCEDURE
1. Receive lane id or final integration target.
2. Read `.agents/specs/<slug>/spec.md`.
3. Read `.agents/specs/<slug>/plan.dag.json`.
4. Read verifier output.
5. Run `lh graph --brief` unless fresh passing output exists.
6. List assigned acceptance criteria by id.
7. Check each criterion individually.
8. Check changed files against declared scope globs.
9. Check that no file was created outside scope.
10. Check that verify commands passed.
11. Check behavior against decisions.
12. Identify only actionable correctness, safety, or contract issues.
13. Ignore style-only issues unless they break conventions.
14. Return `APPROVE` only when every gate passes.
15. Return `REJECT` with reasons when any gate fails.
16. Include exact paths for every rejection reason.
17. Include required fix in one sentence per reason.
## REVIEW GATES
1. All declared verify commands pass.
2. Every assigned `AC-###` passes.
3. `lh graph --brief` passes.
4. Changed files stay inside declared scope.
5. Decisions from `.agents/specs/<slug>/decisions.md` are honored.
## INPUTS
- Read `.agents/specs/<slug>/decisions.md`.
- Read `.agents/specs/<slug>/spec.md`.
- Read `.agents/specs/<slug>/plan.dag.json`.
- Read `.agents/runs/<id>/events.ndjson` when available.
- Read changed files needed for review.
## OUTPUTS
- Return `APPROVE` or `REJECT`.
- Return reasons and required fixes.
- Emit review events through `lh run event` when a run id exists.
## STOP CONDITIONS
- Stop after explicit `APPROVE` or `REJECT`.
- Stop when required inputs are missing.
- Stop when files outside scope must be inspected to continue.
## NEVER DO THIS
- Never approve unchecked criteria.
- Never ignore scope violations.
- Never request cosmetic churn.
- Never edit files.
- Never replace verifier.
+67
View File
@@ -0,0 +1,67 @@
---
name: Scout
description: Read-only reconnaissance agent that summarizes repository facts under a token ceiling without editing files.
model: claude-haiku-4.5
tools: [read, search, execute]
user-invokable: false
---
# Scout
Recon the repo. Return facts, not dumps.
## PROCEDURE
1. Treat the assignment as read-only.
2. Run `lh index --budget <N> --focus <glob>` before manual reads.
3. Run `lh graph --brief` before manual reads.
4. Read `.agents/memory/INDEX.md` only if the assignment needs history.
5. Pull memory shards only by explicit relevance.
6. Search by symbol, path, or glob before opening files.
7. Open only files inside the assigned read scope.
8. Prefer `lh index` summaries over raw file reads.
9. Note architecture boundaries and conventions.
10. Note tests, verify commands, and risk hotspots.
11. Note dependencies and external libraries without using them.
12. Summarize findings under the assigned token ceiling.
13. Include citations as file paths plus line ranges when available.
14. Report unknowns separately from facts.
15. Return no raw file dump.
16. Return no patch.
17. Return no implementation plan unless asked.
## OUTPUT FORMAT
1. `SUMMARY` under the stated token ceiling.
2. `FACTS` as bullets with paths.
3. `RISKS` as bullets with paths.
4. `VERIFY` with commands discovered.
5. `UNKNOWNS` as bullets.
## INPUTS
- Read `.agents/harness.config.json` when present.
- Read `.agents/memory/INDEX.md` when assigned.
- Read files matched by the assigned scope globs.
- Read `.agents/specs/<slug>/spec.md` when assigned.
- Read `.agents/specs/<slug>/plan.dag.json` when assigned.
## OUTPUTS
- Return summary text to caller.
- Emit `lh run event` only if caller provided run id.
## STOP CONDITIONS
- Stop when the token ceiling is reached.
- Stop when requested files are outside assigned scope.
- Stop when write access is required.
## NEVER DO THIS
- Never edit files.
- Never create files.
- Never run formatters.
- Never run destructive commands.
- Never dump full files.
- Never exceed token ceiling.
+63
View File
@@ -0,0 +1,63 @@
---
name: Scribe
description: Continuously writes non-blocking journal, ADR, living spec, and convention updates from pipeline events.
model: claude-haiku-4.5
tools: [read, search, edit, execute]
user-invokable: false
---
# Scribe
Document continuously. Never block the pipeline.
## PROCEDURE
1. Receive run id and slug.
2. Read `.agents/runs/<id>/events.ndjson`.
3. Read `.agents/runs/<id>/board.md` when present.
4. Read `.agents/specs/<slug>/decisions.md` when present.
5. Read `.agents/specs/<slug>/spec.md` when present.
6. Read `.agents/architecture.md` when present.
7. Read `.agents/conventions.md` when present.
8. Append concise entries to `.agents/runs/<id>/journal.md`.
9. Record phase transitions, decisions, lane changes, failures, retries, and merges.
10. Append ADR notes inside `.agents/architecture.md` only when durable architectural decisions appear.
11. Update `.agents/specs/<slug>/spec.md` only for accepted living-spec deltas.
12. Update `.agents/conventions.md` only for durable project conventions.
13. Write memory shards through `lh memory put` when events reveal reusable facts.
14. Use shards: failures, corrections, insights, conventions, quirks.
15. Run `lh memory scan` before writing memory if secrets may appear.
16. Keep every entry short and dated.
17. If write conflicts occur, emit a note and continue later.
18. Return latest journal path and any skipped updates.
## INPUTS
- Read `.agents/runs/<id>/events.ndjson`.
- Read `.agents/runs/<id>/board.md`.
- Read `.agents/specs/<slug>/decisions.md`.
- Read `.agents/specs/<slug>/spec.md`.
- Read `.agents/architecture.md`.
- Read `.agents/conventions.md`.
## OUTPUTS
- Write `.agents/runs/<id>/journal.md`.
- Write `.agents/architecture.md` when ADRs change.
- Write `.agents/specs/<slug>/spec.md` when living spec changes.
- Write `.agents/conventions.md` when conventions change.
- Write memory shards through `lh memory put`.
## STOP CONDITIONS
- Stop when the run ends and final journal is written.
- Pause when required files are locked or missing.
- Resume on next event batch.
## NEVER DO THIS
- Never block conductor, builder, verifier, reviewer, or integrator.
- Never invent decisions.
- Never write secrets.
- Never expand logs into prose dumps.
- Never change code.
+76
View File
@@ -0,0 +1,76 @@
---
name: Splitter
description: Converts a checkable spec into a DAG of read and write lanes with non-overlapping write scopes.
model: claude-sonnet-5
tools: [read, search, edit, execute]
user-invokable: false
---
# Splitter
Emit the lane DAG. Keep write scopes disjoint.
## PROCEDURE
1. Read `.agents/specs/<slug>/spec.md`.
2. Read `.agents/architecture.md` when present.
3. Run `lh index --budget 6000 --focus <relevant-glob>`.
4. Run `lh graph --brief`.
5. Extract every acceptance criterion id.
6. Group work by independently verifiable outcomes.
7. Create read lanes for discovery-only work.
8. Create write lanes for implementation work.
9. Declare exact file-scope globs for every lane.
10. Use narrow globs over broad globs.
11. Assign every write lane a unique branch-worthy scope.
12. Forbid overlapping write scopes.
13. Model dependencies with lane ids only.
14. Map each lane to the acceptance criteria it satisfies.
15. Add verify commands when known.
16. Add checkpoint hints for risky lanes.
17. Write `.agents/specs/<slug>/plan.dag.json`.
18. Run `lh graph --brief` after writing.
19. If graph fails, revise the DAG until it passes or report blocker.
20. Return lane count, dependency shape, and risk lanes.
## DAG SCHEMA
```json
{
"lanes": [{
"id": "lane-id",
"title": "Short title",
"kind": "read",
"scope": ["path/glob/**"],
"dependsOn": [],
"acceptance": ["AC-001"]
}]
}
```
## INPUTS
- Read `.agents/specs/<slug>/spec.md`.
- Read `.agents/architecture.md`.
- Read `.agents/conventions.md` when present.
- Read `.agents/memory/INDEX.md` when relevant.
## OUTPUTS
- Write `.agents/specs/<slug>/plan.dag.json`.
- Emit graph status through `lh run event` when a run id exists.
## STOP CONDITIONS
- Stop when spec is missing.
- Stop when acceptance criteria are unnumbered.
- Stop when write scopes overlap and cannot be separated.
- Stop when `lh graph --brief` blocks the DAG.
## NEVER DO THIS
- Never emit overlapping write scopes.
- Never omit `id`, `title`, `kind`, `scope`, `dependsOn`, or `acceptance`.
- Never assign implementation to read lanes.
- Never create lanes without acceptance ids.
- Never edit product files.
+63
View File
@@ -0,0 +1,63 @@
---
name: Verifier
description: Runs configured verification commands plus lh graph and returns only a terse pass or fail table.
model: claude-haiku-4.5
tools: [read, search, execute]
user-invokable: false
---
# Verifier
Verify. Return table only.
## PROCEDURE
1. Receive lane id, worktree path, scope globs, acceptance ids, and verify commands.
2. Confirm current directory is the assigned checkout or worktree.
3. Read `.agents/specs/<slug>/spec.md`.
4. Read `.agents/specs/<slug>/plan.dag.json`.
5. Run every configured verify command exactly as declared.
6. Use the smallest targeted command when the lane declares one.
7. Run `lh graph` after configured commands.
8. Check that changed files stay inside scope globs.
9. Check each assigned acceptance criterion by id.
10. Mark criteria `PASS`, `FAIL`, or `NOT CHECKED`.
11. Capture command exit codes.
12. Capture the shortest useful failure reason.
13. Do not propose broad refactors.
14. Do not edit files.
15. Return only the pass/fail table and blocker bullets.
## OUTPUT FORMAT
| Check | Command or criterion | Result | Evidence |
| --- | --- | --- | --- |
| verify | `<command>` | PASS/FAIL | exit code |
| graph | `lh graph` | PASS/FAIL | exit code |
| acceptance | `AC-###` | PASS/FAIL | path or reason |
| scope | declared globs | PASS/FAIL | path or reason |
## INPUTS
- Read `.agents/specs/<slug>/spec.md`.
- Read `.agents/specs/<slug>/plan.dag.json`.
- Read files needed to confirm acceptance criteria.
## OUTPUTS
- Return a terse pass/fail table.
- Emit verify events through `lh run event` when a run id exists.
## STOP CONDITIONS
- Stop after every declared command and `lh graph` have run.
- Stop immediately on a command that corrupts state or requests secrets.
- Stop when scope cannot be checked.
## NEVER DO THIS
- Never edit files.
- Never skip `lh graph`.
- Never return verbose logs.
- Never mark an unchecked criterion as pass.
- Never change verify commands.
+19
View File
@@ -0,0 +1,19 @@
---
applyTo: '**'
description: Project instructions for contributors working on redsen-lean-harness itself.
---
# redsen-lean-harness Contributor Rules
1. Work in ESM `.mjs` modules.
2. Require Node >= 20.
3. Add zero native dependencies.
4. Keep CLI output terse; LLMs consume it.
5. Put shared contracts in `src/lib/`.
6. Export command modules with the project command signature.
7. Keep command implementations imperative and deterministic.
8. Prefer small pure helpers over hidden global state.
9. Keep `.github/` behavior aligned with AGENTS.md.
10. Do not duplicate long behavior in prompts or skills.
11. Validate with existing scripts only.
12. Never write secrets to config, memory, tests, or fixtures.
@@ -0,0 +1,25 @@
---
applyTo: '**'
description: Mandatory Context7 usage for external library, framework, SDK, API, CLI, and cloud-service work.
---
# Context7 Rules
1. Use Context7 before using any external library, framework, SDK, API, CLI, or cloud service.
2. Call `resolve-library-id` first.
3. Call `query-docs` with the resolved id second.
4. Base implementation on the returned docs.
5. Cite the library id or doc source in notes when it affects decisions.
6. Read the API key only from the `CONTEXT7_API_KEY` environment variable.
7. Never write the API key to any file.
8. Never put the API key in `.github/mcp.json`.
9. Never paste the API key into prompts, logs, memory, or telemetry.
10. If `CONTEXT7_API_KEY` is missing, stop library work.
11. Tell the user exactly:
```bash
export CONTEXT7_API_KEY='<your-context7-api-key>'
```
12. Resume library work only after the environment variable exists.
13. Continue non-library work when it does not depend on external docs.
@@ -0,0 +1,51 @@
---
applyTo: '**'
description: Always-on lean harness behavior, token discipline, style, self-documenting rules, and scope guardrails.
---
# Lean Harness Rules
## Token discipline
1. Prefer `lh index --budget <N>` over raw tree reads.
2. Prefer `lh graph --brief` over verbose diagnostics.
3. Load only `.agents/memory/INDEX.md` by default.
4. Pull memory shards only on demand with `lh memory get`.
5. Return summaries, tables, and paths instead of file dumps.
6. Keep skill bodies short. Use agents and `lh` commands for detail.
## Imperative style
1. Write commands as actions.
2. Use short lines.
3. Avoid motivational prose.
4. Avoid generic LLM advice.
5. State inputs, outputs, gates, and stop conditions.
## Self-documenting work
1. Write decisions to `.agents/specs/<slug>/decisions.md`.
2. Write specs to `.agents/specs/<slug>/spec.md`.
3. Write DAGs to `.agents/specs/<slug>/plan.dag.json`.
4. Write run events to `.agents/runs/<id>/events.ndjson` through `lh run event`.
5. Write journals to `.agents/runs/<id>/journal.md`.
6. Keep ADRs inside `.agents/architecture.md`.
7. Keep durable conventions in `.agents/conventions.md`.
## Scope rule
1. Respect lane scope globs exactly.
2. Create write lanes with `lh lane create` before editing.
3. Work inside the assigned worktree for write lanes.
4. Never create files outside the lane scope.
5. Stop and escalate when required scope is missing.
6. Run `lh graph` before claiming done.
## Pipeline rule
1. Design before plan.
2. Require user gate after design.
3. Plan before build unless fast track applies.
4. Run Ralph loop for every write lane.
5. Merge lanes sequentially.
6. Run one full verify after integration.
@@ -0,0 +1,37 @@
---
applyTo: '**'
description: Rules for reading, writing, compacting, and protecting lean harness memory shards.
---
# Memory Rules
## Read memory
1. Load `.agents/memory/INDEX.md` by default.
2. Do not load every shard at startup.
3. Use `lh memory get --shard <name>` only when needed.
4. Prefer compact shards over raw history.
## Write memory
1. Use `lh memory put --shard failures` for recurring failures and fixes.
2. Use `lh memory put --shard corrections` for user corrections and changed assumptions.
3. Use `lh memory put --shard insights` for reusable design or codebase facts.
4. Use `lh memory put --shard conventions` for durable project rules.
5. Use `lh memory put --shard quirks` for environment or tool behavior.
6. Keep entries short, dated, and source-linked.
7. Record what happened, why it matters, and where it applies.
## Protect secrets
1. Never write secrets to memory.
2. Never write tokens, keys, passwords, cookies, private keys, or credentials.
3. Run `lh memory scan` before saving risky content.
4. Redact sensitive values at source.
5. Stop and ask for remediation if a secret is already present.
## Compact memory
1. Use `lh memory compact` when shards grow noisy.
2. Preserve decisions, fixes, conventions, and source paths.
3. Drop duplicate logs and stale speculation.
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "${env:CONTEXT7_API_KEY}"
},
"tools": ["*"]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
---
description: Run the lean harness build phase through the Conductor agent and Ralph lane loop.
---
# Build Prompt
Invoke the `build` skill or `Conductor` agent.
Run `lh host` first. Obey its strategy.
Use parallel lanes only when host supports them.
In VS Code, degrade to sequential lane execution.
Keep Ralph loop and journal intact.
+11
View File
@@ -0,0 +1,11 @@
---
description: Run the lean harness design gate through the Interrogator agent or design skill.
---
# Design Prompt
Invoke the `design` skill or `Interrogator` agent.
Use `.agents/specs/<slug>/questionnaire.md` and `.agents/specs/<slug>/decisions.md`.
If VS Code cannot run parallel subagents, proceed sequentially.
Stop until every required question is answered.
+12
View File
@@ -0,0 +1,12 @@
---
description: Run a small brownfield change through one lean harness lane without losing Ralph discipline.
---
# Fast Track Prompt
Invoke the `fast-track` skill or `Conductor` agent.
Use one lane.
Skip full spec and DAG only when the change is small and bounded.
Keep builder ⇄ verifier Ralph loop, `lh graph`, reviewer approval, and journal.
In VS Code, run sequentially.
+11
View File
@@ -0,0 +1,11 @@
---
description: Produce a concise lean harness onboarding summary from doctor, index, graph, and memory index.
---
# Onboard Prompt
Invoke the `onboard` skill.
Run `lh doctor`, `lh index --stats --budget 4000`, and `lh graph --brief`.
Read only `.agents/memory/INDEX.md` by default.
If VS Code cannot fan out scouts, inspect sequentially.
+15
View File
@@ -0,0 +1,15 @@
---
name: build
description: Use when executing planned read and write lanes through lh host and the Ralph loop.
---
# build skill
1. Invoke `.github/agents/conductor.agent.md`.
2. Run `lh host` first through the conductor.
3. Obey parallel or sequential strategy printed by `lh host`.
4. Use `lh lane create` for write lanes.
5. Run builder ⇄ verifier Ralph loops.
6. Record failures with `lh memory put --shard failures`.
Stop on max Ralph iterations and escalate.
+14
View File
@@ -0,0 +1,14 @@
---
name: design
description: Use when starting gated design discovery before planning, especially when requirements are incomplete or ambiguous.
---
# design skill
1. Invoke `.github/agents/interrogator.agent.md`.
2. Require numbered questions with numbered recommended answers.
3. Mark one answer as recommended.
4. Persist final answers to `.agents/specs/<slug>/decisions.md`.
5. Stop until every required question is answered.
Do not infer decisions. Do not start planning before the user gate.
+14
View File
@@ -0,0 +1,14 @@
---
name: doctor
description: Use when checking harness environment, configuration, host capabilities, or broken setup.
---
# doctor skill
1. Run `lh doctor`.
2. Run `lh host` when orchestration capability matters.
3. Run `lh graph --brief` when repository structure matters.
4. Report failures with exact commands and exit status.
5. Suggest the smallest next fix.
Do not mutate project state unless the user asked for repair.
+15
View File
@@ -0,0 +1,15 @@
---
name: fast-track
description: Use for a small brownfield change that can skip spec and DAG but still needs Ralph and journal discipline.
---
# fast-track skill
1. Confirm the change is small and brownfield.
2. Use a single write lane.
3. Keep the same Ralph loop: builder ⇄ verifier.
4. Keep the same journal in `.agents/runs/<id>/journal.md`.
5. Run `lh graph`.
6. Request reviewer approval before completion.
Do not fast-track ambiguous or cross-cutting work.
+14
View File
@@ -0,0 +1,14 @@
---
name: index
description: Use when needing repository understanding under a token budget before reading files by hand.
---
# index skill
1. Run `lh index --budget <N> --focus <glob>`.
2. Add `--stats` when onboarding or sizing work.
3. Prefer index output before raw reads.
4. Follow with `lh graph --brief` when structure matters.
5. Read files by hand only after narrowing scope.
Return paths and facts, not dumps.
+14
View File
@@ -0,0 +1,14 @@
---
name: init
description: Use when initializing lean harness state in a repository or repairing missing .agents baseline files.
---
# init skill
1. Invoke `lh init`.
2. Confirm `.agents/harness.config.json` exists.
3. Confirm `.agents/memory/INDEX.md` exists.
4. Run `lh doctor` after initialization.
5. Report created paths and blockers only.
Delegate orchestration to `.github/agents/conductor.agent.md` when initialization is part of a full run.
+15
View File
@@ -0,0 +1,15 @@
---
name: integrate
description: Use when planned lane branches are complete and must be merged sequentially with final verification.
---
# integrate skill
1. Invoke `.github/agents/integrator.agent.md`.
2. Merge lanes one at a time with `lh lane merge`.
3. Resolve local conflicts only when safe.
4. Escalate conflicting acceptance criteria.
5. Run one final full verify.
6. Request final reviewer approval.
Never merge lanes in parallel.
+14
View File
@@ -0,0 +1,14 @@
---
name: memory
description: Use when retrieving, writing, compacting, or scanning lean harness memory shards.
---
# memory skill
1. Use `lh memory list` to inspect shards.
2. Use `lh memory get --shard <name>` to read one shard.
3. Use `lh memory put --shard <name>` to write durable facts.
4. Use `lh memory scan` before risky writes.
5. Use `lh memory compact` when shards grow noisy.
Never write secrets to memory.
+14
View File
@@ -0,0 +1,14 @@
---
name: onboard
description: Use when a new contributor or agent needs a concise map of harness state, commands, and memory.
---
# onboard skill
1. Run `lh doctor`.
2. Run `lh index --stats --budget 4000`.
3. Read `.agents/memory/INDEX.md` only.
4. Run `lh graph --brief`.
5. Summarize commands, state paths, conventions, and blockers.
Pull shards only when the user asks for deeper history.
+14
View File
@@ -0,0 +1,14 @@
---
name: plan
description: Use when decisions are complete and the harness must produce a spec plus lane DAG before build.
---
# plan skill
1. Read `.agents/specs/<slug>/decisions.md`.
2. Invoke `.github/agents/architect.agent.md` for `.agents/specs/<slug>/spec.md`.
3. Invoke `.github/agents/splitter.agent.md` for `.agents/specs/<slug>/plan.dag.json`.
4. Run `lh graph --brief`.
5. Report spec path, DAG path, acceptance ids, and blockers.
Do not build before the DAG passes structural gates.
+14
View File
@@ -0,0 +1,14 @@
---
name: telemetry
description: Use when starting, recording, ending, or reporting a harness run and its phase events.
---
# telemetry skill
1. Start with `lh run start`.
2. Record phase changes with `lh run event`.
3. End with `lh run end`.
4. Read `.agents/runs/<id>/board.md` for current state.
5. Run `lh report <runId>` for a markdown summary.
Keep event messages short and machine-readable.
+15
View File
@@ -0,0 +1,15 @@
---
name: verify
description: Use when checking configured commands, lh graph, acceptance criteria, and lane scope compliance.
---
# verify skill
1. Invoke `.github/agents/verifier.agent.md` for command checks.
2. Run configured verify commands.
3. Run `lh graph`.
4. Check acceptance ids individually.
5. Check scope globs.
6. Return a terse pass/fail table only.
Do not edit files during verification.
+11
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
*.log
.DS_Store
report.*.json
# Harness runtime artifacts (per-consumer-repo; here only for fixtures)
.agents/.cache/
.agents/runs/*/events.ndjson
.agents/runs/*/board.md
tests/.tmp/
+89
View File
@@ -0,0 +1,89 @@
# redsen-lean-harness
An imperative, token-lean, self-documenting agent harness. It runs unchanged in
**GitHub Copilot CLI** and **VS Code Copilot**.
## Division of labour
| Layer | Owns | Where |
| --- | --- | --- |
| Markdown | Behaviour — agents, skills, instructions | `.github/` |
| Node CLI (`lh`) | Determinism — index, gates, worktree lanes, telemetry, memory | `src/` |
| Host | Execution — subagents, fleet, tools | Copilot CLI / VS Code |
We never reimplement an agent runtime. If the host can do it, the host does it.
## Pipeline
```
design → gated Q&A, nothing inferred → .agents/specs/<slug>/decisions.md
plan → spec + acceptance criteria + DAG → spec.md, plan.dag.json
build → worktree lanes, Ralph loop → builder ⇄ verifier
integrate → sequential merge + full verify → integrator
document → runs throughout → scribe
```
The **conductor re-plans at every checkpoint**: lanes may be spawned, killed, or
re-scoped based on results. Failures are isolated, retried within bounds, then
planned around.
## Agents
| Agent | Tier | Invokable | Responsibility |
| --- | --- | --- | --- |
| `conductor` | strong | yes | Entry point. Owns the pipeline and the dynamic DAG. |
| `interrogator` | strong | yes | Design-phase Q&A with recommended answers. |
| `scout` | cheap | no | Read-only recon, fanned out N-wide. |
| `architect` | strong | yes | Spec, acceptance criteria, architecture doc, ADRs. |
| `splitter` | mid | no | Decomposes spec into lanes with file-scope globs. |
| `builder` | mid | no | Implements one lane inside its worktree. |
| `verifier` | cheap | no | Runs verify commands + structural gate. |
| `reviewer` | strong | yes | Acceptance-criteria and scope gate. |
| `integrator` | strong | no | Sequential merge, conflict resolution, full verify. |
| `scribe` | cheap | no | Journal, ADRs, living spec, conventions. |
## Skills
`init` `design` `plan` `build` `verify` `integrate` `onboard` `memory` `index`
`telemetry` `fast-track` `doctor`
## `lh` commands
| Command | Purpose |
| --- | --- |
| `lh init` | First-run wizard, writes `.agents/harness.config.json`. |
| `lh index` | Token-budgeted tree-sitter repo map. |
| `lh graph` | Structural gate: duplicates, orphans, unresolved calls. Exit 1 on violation. |
| `lh lane` | git worktree lane lifecycle + file-scope leases. |
| `lh run` | NDJSON telemetry + live board. |
| `lh memory` | Memory shard CRUD, compaction, secret scanning. |
| `lh host` | Detect host capabilities, print orchestration strategy. |
| `lh report` | Markdown telemetry report. |
| `lh doctor` | Environment checks. |
## Per-repo footprint
```
.agents/
harness.config.json
architecture.md # living, ADR log inside [committed]
conventions.md # living [committed]
memory/INDEX.md # the ONLY always-loaded file [committed]
memory/{seed,failures,corrections,insights,conventions,quirks}.md
specs/<slug>/{questionnaire,decisions,spec}.md, plan.dag.json
runs/<id>/{board.md,events.ndjson,journal.md}
.cache/ # repomap, symbols, wasm [gitignored]
```
## Token-leanness
1. Only `memory/INDEX.md` is always loaded; shards pull on demand.
2. `lh index --budget` caps repo-map size.
3. Cheap-tier subagents absorb exploration cost; only summaries return.
4. `lh graph` and `lh report` emit briefs, never dumps.
5. Skill bodies stay short; procedures live in `templates/`.
## Context7
Mandatory for external library work. Key is read from `CONTEXT7_API_KEY`
**environment variable only** — the harness never writes secrets to disk.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Redsen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+250
View File
@@ -0,0 +1,250 @@
# redsen-lean-harness
An **imperative**, **token-lean**, **self-documenting** agent harness for
**GitHub Copilot CLI** and **VS Code Copilot**.
It imposes a spec-driven pipeline, runs the implement→verify stage as a Ralph loop, drives
**parallel dynamic workflows on its own** using git worktree lanes, and writes down everything
it did. Mostly markdown. One runtime dependency.
---
## Why it exists
Coding agents fail in predictable ways: they guess at requirements, load far too much context,
lose what they learned between sessions, work sequentially when the work is parallel, and leave
no trace of *why* anything was done.
This harness fixes each of those with a specific mechanism, not with prompt-engineering hope.
| Failure | Mechanism |
| --- | --- |
| Guessing at requirements | `interrogator` asks questions **with recommended answers**; the pipeline blocks until answered |
| Context bloat | Only `memory/INDEX.md` is ever auto-loaded; everything else is pulled on demand |
| Amnesia between runs | Categorised memory shards, committed to the repo |
| Sequential work | Dynamic DAG + git worktree lanes with file-scope leases |
| No audit trail | ADRs, living specs, and a per-run journal — written by a cheap agent, continuously |
| Unverifiable "done" | Five explicit Ralph exit criteria, all machine-checkable |
## Design
Three layers, one hard boundary — **we never reimplement an agent runtime**
([ADR 0001](docs/adr/0001-markdown-behaviour-node-determinism.md)).
| Layer | Owns | Where |
| --- | --- | --- |
| Markdown | Behaviour — agents, skills, instructions | `.github/` |
| Node CLI (`lh`) | Determinism — index, gates, lanes, telemetry, memory | `src/` |
| Host | Execution — subagents, fleet, tools, models | Copilot CLI / VS Code |
`lh` never calls a model. Agents never do git plumbing, parsing, or token arithmetic by hand.
## Install
### GitHub Copilot CLI
```bash
# direct from the repo
copilot plugin install redsentech/lean-harness
# or via the marketplace
copilot plugin marketplace add redsentech/lean-harness
copilot plugin install redsen-lean-harness@redsen
```
### VS Code Copilot
Clone the repo and point VS Code's Copilot customization settings at it, or copy the `.github/`
tree into your project. `.github/agents/`, `.github/instructions/` and `.github/mcp.json` are
read by both hosts.
> `.github/skills/` is **Copilot CLI only**. VS Code gets equivalent behaviour through
> `.github/agents/` and `.github/prompts/`.
### The `lh` CLI
```bash
npm install -g @redsen/lean-harness # or: npx @redsen/lean-harness <command>
lh doctor # verify the environment
```
### Context7
Mandatory before any external-library work. The key is read from the environment **only** — the
harness never writes secrets to disk.
```bash
export CONTEXT7_API_KEY="<your key>" # add to ~/.bashrc or ~/.zshrc
```
## Quick start
```bash
cd your-repo
lh init # first-run wizard; writes .agents/harness.config.json
lh doctor # confirm everything is wired
```
Then, in Copilot CLI or VS Code Copilot:
```
@conductor build a rate limiter for the public API
```
For a small brownfield change, skip the ceremony:
```
/fast-track fix the off-by-one in pagination
```
To learn an unfamiliar codebase first:
```
/onboard
```
## Pipeline
```
design → interrogator asks, user answers → decisions.md [USER GATE]
plan → architect writes spec + acceptance criteria
splitter emits plan.dag.json (lanes + file scopes)
build → lh host picks the strategy
read lanes → shared checkout, N-wide scout fan-out
write lanes → lh lane create → git worktree + branch
builder ⇄ verifier (RALPH loop)
checkpoint → conductor RE-PLANS, may spawn/kill/re-scope lanes
failure → isolate → bounded retry → re-plan around it
integrate → integrator merges lanes SEQUENTIALLY, then one full verify
document → scribe writes journal, ADRs, spec, conventions (throughout)
```
### Ralph exit criteria
A lane is done only when **all** of these hold. Otherwise it iterates; at max iterations it
escalates to you.
1. Every declared verify command exits 0
2. Every acceptance criterion is individually checked off
3. `lh graph` structural gate passes
4. `reviewer` approves
5. No files were changed outside the lane's declared scope
## Agents
| Agent | Tier | Invokable | Responsibility |
| --- | --- | --- | --- |
| `conductor` | strong | yes | Entry point. Owns the pipeline and the dynamic DAG. |
| `interrogator` | strong | yes | Design-phase Q&A with recommended answers. |
| `scout` | cheap | – | Read-only recon, fanned out N-wide. |
| `architect` | strong | yes | Spec, acceptance criteria, architecture doc, ADRs. |
| `splitter` | mid | – | Decomposes the spec into lanes with file-scope globs. |
| `builder` | mid | – | Implements one lane inside its worktree. |
| `verifier` | cheap | – | Runs verify commands + the structural gate. |
| `reviewer` | strong | yes | Acceptance-criteria and scope gate. |
| `integrator` | strong | – | Sequential merge, conflict escalation, full verify. |
| `scribe` | cheap | – | Journal, ADRs, living spec, conventions. |
Tiers map to models in `harness.config.json` and are fully overridable. The default map is
`cheap → claude-haiku-4.5`, `mid → claude-sonnet-5`, `strong → claude-opus-5`. Expensive
exploration is deliberately pushed onto the cheap tier; only summaries return to the main context.
## `lh` commands
| Command | Purpose |
| --- | --- |
| `lh init` | First-run wizard → `.agents/harness.config.json` |
| `lh index [--budget N] [--focus g] [--fetch]` | Token-budgeted tree-sitter repo map |
| `lh graph [--brief]` | Structural gate. **Exit 1 on violations** |
| `lh lane create\|list\|status\|merge\|drop` | Worktree lanes + file-scope leases |
| `lh run start\|event\|end` | NDJSON telemetry + live board |
| `lh memory get\|put\|compact\|scan\|list` | Memory shards, compaction, secret scanning |
| `lh host` | Detect host capabilities, print the orchestration strategy |
| `lh report [runId] [--journal]` | Markdown telemetry report / run journal |
| `lh doctor` | Environment checks |
## Repository footprint
The harness writes into one configurable directory:
```
.agents/
harness.config.json
architecture.md # living, ADR log inside [committed]
conventions.md # living [committed]
memory/INDEX.md # the ONLY always-loaded file [committed]
memory/{seed,failures,corrections,insights,conventions,quirks}.md
specs/<slug>/{questionnaire,decisions,spec}.md · plan.dag.json
runs/<id>/journal.md [committed]
runs/<id>/{board.md,events.ndjson} [gitignored]
.cache/ # repomap, symbols, wasm, lanes [gitignored]
```
The committed/gitignored split is chosen during `lh init`.
## Observability
`lh run event` appends OTEL-GenAI-shaped NDJSON. No collector, no server, works offline.
- **`board.md`** regenerates on every event: lanes in flight, ralph iterations, elapsed, tokens,
and **live burn rate**.
- **`lh report`** gives the post-run breakdown by phase, agent, model and lane.
- **`journal.md`** is the committed, human-readable record of what happened and why.
> There is **no hard budget cap** — a deliberate choice. The control is visibility: live burn
> rate while running, full cost breakdown after. See
> [ADR 0006](docs/adr/0006-local-ndjson-telemetry.md).
## Token discipline
1. Only `memory/INDEX.md` is always loaded; shards are pulled on demand.
2. `lh index --budget` caps the repo map and degrades signatures → names → counts.
3. Cheap-tier subagents absorb exploration; only their summaries re-enter the main context.
4. `lh graph` and `lh report` emit briefs, never dumps.
5. Skill bodies stay short; procedures live in `templates/`, loaded only when used.
6. `lh memory compact` merges the oldest entries when the budget is approached.
## Architecture decisions
| ADR | Decision |
| --- | --- |
| [0001](docs/adr/0001-markdown-behaviour-node-determinism.md) | Markdown owns behaviour, Node owns determinism |
| [0002](docs/adr/0002-web-tree-sitter-index.md) | web-tree-sitter WASM index with an offline regex fallback |
| [0003](docs/adr/0003-worktree-lane-isolation.md) | git worktree per write-lane; pluggable isolation backend |
| [0004](docs/adr/0004-no-vendoring-elastic-licensed-code.md) | No vendoring of Elastic-licensed code |
| [0005](docs/adr/0005-file-only-memory.md) | File-only memory with a single always-loaded index |
| [0006](docs/adr/0006-local-ndjson-telemetry.md) | Local NDJSON telemetry, no server, no budget cap |
## Development
```bash
npm install
npm run validate # manifests + every markdown frontmatter block
npm test
node src/cli.mjs --help
```
`npm run validate` is the distribution gate: it checks `plugin.json`, `marketplace.json`,
agent/skill/instruction frontmatter, delegation targets, and scans `.github/mcp.json` for
committed secrets.
## Prior art
Ideas taken (no code, no dependencies) from `pi-hermes-memory` (categorised memory shards,
secret scanning, consolidation) and `pi-context-mode` (token budgeting, compaction, checkpoint
anchors — **Elastic Licensed, deliberately not vendored**, see ADR 0004). The structural gate is
modelled on `codegraph`; the Ralph loop and run-state telemetry on `rapid-prototyping-agent`;
the packaging on `redsen-copilot-agents`.
## Roadmap
**v1** — spec pipeline, Ralph loop, memory, index + structural gate, worktree lanes, integrator,
telemetry.
**v2** — devcontainer isolation backend with a generator/manager/updater ecosystem
(`lh lane` is already backend-pluggable), semantic memory retrieval behind the existing
`getMemory()` interface, and OTEL export.
## License
MIT © Redsen
@@ -0,0 +1,42 @@
# ADR 0001 — Markdown owns behaviour, Node owns determinism
- **Status**: Accepted
- **Date**: 2026-09-09
## Context
The harness must be *simple*, *lean on context*, *distributable as a plugin*, and run on two
hosts (GitHub Copilot CLI, VS Code Copilot) that both already contain a capable agent runtime.
There is a strong temptation to build an orchestrator process that drives the model directly.
That path produces a second agent runtime we would have to maintain, and it cannot be shipped
as a plugin because plugins run *inside* the host.
## Decision
Split the system in three, with a hard boundary:
| Layer | Owns | Artifact |
| --- | --- | --- |
| Markdown | Behaviour — agents, skills, instructions | `.github/` |
| Node CLI (`lh`) | Determinism — index, gates, lanes, telemetry, memory | `src/` |
| Host | Execution — subagents, fleet, tools, models | Copilot CLI / VS Code |
`lh` never calls a model. Agents never do arithmetic, parsing, git plumbing, or bookkeeping
by hand. **We do not reimplement an agent runtime.**
## Consequences
- The same `.github/` tree works in both hosts; only the scheduler differs (see ADR 0003).
- `lh` is trivially testable — it is pure I/O with no model in the loop.
- Anything the host cannot do, we cannot do. Accepted: host capability detection (`lh host`)
makes the limitation explicit and degrades rather than failing.
- Behaviour changes are markdown diffs, reviewable in a PR without running anything.
## Alternatives considered
- **Standalone orchestrator binary** — rejected: not plugin-distributable, duplicates the host.
- **Everything in markdown, no CLI** — rejected: indexing, worktrees, structural analysis and
token accounting are not things an LLM should do by hand; they are slow, expensive and unreliable.
- **Everything in Node, markdown as prompts only** — rejected: opaque, unreviewable, and it
breaks the self-documenting requirement.
+45
View File
@@ -0,0 +1,45 @@
# ADR 0002 — web-tree-sitter (WASM) for the code index, with a regex fallback
- **Status**: Accepted
- **Date**: 2026-09-09
## Context
The harness must work on brownfield repos in *any* language, be OS-agnostic, and install with
no build step. It needs a token-budgeted repo map so agents stop reading whole files.
Candidates evaluated:
| Option | Verdict |
| --- | --- |
| `tree-sitter` (native bindings) | Requires a native compile toolchain — fails "easy install" |
| **`web-tree-sitter` (WASM)** | Pure npm, no compiler, runs anywhere Node runs |
| `universal-ctags` | External binary the user must install; 200+ languages but not npm-installable |
| `ast-grep` | Prebuilt binary via npm, good, but heavier and rule-oriented rather than map-oriented |
| SCIP / LSIF | Per-language indexers — far too much install surface |
| Zoekt | Requires Docker |
| Host `/lsp` | Excellent fidelity but CLI-only and not available in every host/language |
## Decision
Use **`web-tree-sitter`**, the sole runtime dependency.
Grammars are **not vendored** and **not fetched at import time**. They are cached lazily under
`.agents/.cache/wasm/` and only downloaded when the user passes `--fetch`. When a grammar is
absent, the indexer **silently degrades to a per-language regex extractor** and reports the
degradation in `lh index --stats`.
The repo map is ranked with a hand-rolled PageRank over the reference graph and truncated to
`config.index.budget` tokens, degrading full signatures → names → file-level counts.
## Consequences
- `lh index` works **fully offline** on first run, at lower fidelity. This is a hard requirement.
- No compiler, no Docker, no external binary, no service. `npm install` is the whole setup.
- Fidelity varies by language and by whether a grammar has been fetched. `--stats` makes this visible.
- We own a small glob matcher and a small PageRank implementation rather than taking dependencies.
## Alternatives considered
Rejected `--fetch`-by-default: it would make the first run fail on an air-gapped machine and
would surprise users with network traffic. Opt-in is the safer default.
+49
View File
@@ -0,0 +1,49 @@
# ADR 0003 — git worktree per write-lane; pluggable isolation backend
- **Status**: Accepted
- **Date**: 2026-09-09
## Context
The harness drives **parallel dynamic workflows on its own**: the conductor re-plans and
re-fans-out at every checkpoint. Multiple builder agents therefore write code concurrently.
Concurrent writes to a single checkout corrupt work — two agents editing the same file, or one
agent's partial state being read by another, produces failures that are extremely hard to
diagnose and that waste far more tokens than they save.
## Decision
1. **Read-only lanes share the main checkout.** Recon and review never write, so they are safe
to fan out N-wide with no isolation.
2. **Every write lane gets its own `git worktree` + branch** (`lh/<runId>/<laneId>`).
3. **File-scope leases.** Every lane declares `scope` globs up front. `lh lane create` performs a
glob-intersection check against all live write lanes and **rejects overlapping scopes**. The
check is deliberately conservative: when intersection is ambiguous, it rejects.
4. **Sequential integration.** A dedicated integrator agent merges lane branches one at a time and
runs one full verify. On conflict, `lh lane merge` does **not** auto-resolve — it marks the lane
blocked and returns the conflicted paths for escalation.
5. **The isolation backend is pluggable** — `worktree` (default), `inplace`, and a stubbed
`devcontainer`, selected by `config.isolation.backend`.
Scope compliance is also a Ralph exit criterion: `lh lane status` reports files changed outside
the declared scope, and a lane with out-of-scope changes cannot pass.
## Consequences
- Parallel writes are safe by construction rather than by convention.
- Lanes are cheap (a branch and a worktree), so the fully dynamic re-planning model can spawn and
drop them freely.
- Merge conflicts surface as an explicit, escalatable state instead of silent corruption.
- Worktrees require git ≥ 2.5 and a real repository. `lh doctor` and `lh host` check for this and
degrade to `inplace` + sequential execution when unavailable.
- Moving to per-lane devcontainers in v2 is a backend swap, not a rewrite.
## Alternatives considered
- **Shared checkout with file locks** — rejected: locks are advisory, agents forget them, and a
crashed agent leaves stale locks.
- **Devcontainer per lane now** — deferred to v2. Strongest isolation but a heavy dependency and a
slow inner loop; the pluggable backend keeps the door open.
- **Read-only parallelism only** — rejected: it caps the speedup at exactly the phase that is
already cheap.
@@ -0,0 +1,45 @@
# ADR 0004 — Do not vendor `pi-context-mode`; adopt techniques only
- **Status**: Accepted
- **Date**: 2026-09-09
## Context
Two prior-art projects were investigated for the memory and context layer:
| Project | Repo | License | Storage |
| --- | --- | --- | --- |
| `pi-hermes-memory` | `chandra447/pi-hermes-memory` | unconfirmed | Markdown + SQLite FTS5 |
| `pi-context-mode` | `FluidLogicLabs/pi-context-mode` | **Elastic License 2.0 (ELv2)** | SQLite FTS5 + event log |
`pi-context-mode` is genuinely good at what we need — auto-compaction near token limits,
anchors and checkpoints, tree-structured session pruning, pre-compaction hooks.
But **ELv2 is not an open-source licence.** It forbids providing the software to third parties
as a managed service and forbids circumventing licence-key functionality. Bundling ELv2 code
into an MIT-licensed plugin that we publish to a marketplace creates a licence conflict and
would misrepresent the terms downstream consumers are bound by.
## Decision
1. **Do not vendor, bundle, fork, or take a dependency on `pi-context-mode`.**
2. **Adopt its techniques**, which are ideas and not protected by the licence: token budgeting
with graceful degradation, compaction of the oldest entries when approaching a budget,
checkpoint anchors, and progressive disclosure instead of bulk loading.
3. Optionally **document it as a user-installed MCP server** for users who want it. The user
installs it themselves under their own licence terms; we ship no ELv2 code.
4. From `pi-hermes-memory`, adopt the *ideas* — categorised memory shards (failures, corrections,
insights, conventions, quirks), secret scanning on write, auto-consolidation, and
memory-*policy* prompting rather than loading everything — but **take no dependency** (see ADR 0005).
## Consequences
- The plugin stays cleanly MIT with one runtime dependency (`web-tree-sitter`).
- We implement compaction ourselves in `src/lib/memory.mjs`. It is less sophisticated than
`pi-context-mode`'s, which is an accepted trade for licence cleanliness and zero dependencies.
- Attribution: both projects are credited as inspiration in the README and here.
## Follow-up
Both projects were identified via an automated research pass. **Re-verify the repositories and
their licences directly before citing them in any shipped documentation.**
+49
View File
@@ -0,0 +1,49 @@
# ADR 0005 — File-only memory with a single always-loaded index
- **Status**: Accepted
- **Date**: 2026-09-09
## Context
The harness must be *lean on token and context size* and use a *lean, OS-agnostic, easily
configurable memory system*. It must also be **self-documenting** and work on greenfield and
brownfield repos.
The dominant failure mode of agent memory is loading all of it every turn. A 30 KB memory file
costs ~8 000 tokens on every single request, which is exactly the cost the harness exists to avoid.
## Decision
**Plain markdown files. No database, no vector store, no embedding model, no server.**
```
.agents/memory/
INDEX.md # one line per shard + counts + token estimate — the ONLY always-loaded file
seed.md failures.md corrections.md insights.md conventions.md quirks.md
```
1. **Progressive disclosure.** Only `INDEX.md` enters context automatically. Shards are pulled on
demand via `lh memory get <shard>` or scored retrieval with `lh memory get --query`.
2. **Categorised shards**, so retrieval is targeted rather than semantic-guessy.
3. **Budgeted compaction.** When the estimated total exceeds
`memory.tokenBudget × memory.compactAtPercent`, `lh memory compact` deterministically merges the
oldest entries of the largest shards. The newest entries are never touched.
4. **Secret scanning is mandatory on write.** `putEntry` refuses to persist an entry containing
anything matching a credential pattern. Previews are redacted.
5. **Committed by default.** Durable memory, the architecture doc, conventions, specs and run
journals are committed so they are reviewable in PRs and shared with the team. Volatile
artifacts (`.cache/`, `events.ndjson`, `board.md`) are gitignored. The split is configurable.
Deliberately rejected: SQLite (a native dependency), embeddings (a model dependency and
non-determinism), and any MCP memory server that needs a running service.
## Consequences
- Zero install cost, zero runtime cost, works offline, identical on Linux/macOS/Windows.
- Memory is human-readable and diffable — this *is* the self-documenting requirement, not a
separate feature.
- Retrieval is lexical, not semantic. Accepted: shards are small and categorised, and the agent
knows which category it wants. Semantic search can be added later behind the same
`getMemory()` interface without changing the file format.
- Compaction is lossy by design. Mitigated by never compacting recent entries and by keeping the
full history in git.
+50
View File
@@ -0,0 +1,50 @@
# ADR 0006 — Local NDJSON telemetry, no server, no budget cap
- **Status**: Accepted
- **Date**: 2026-09-09
## Context
Requirement: the harness must be *fast, observable and benchmarkable*. Scope was explicitly set to
**telemetry only** — per-run token counts, latency, tool-call traces, pass/fail — not an external
benchmark suite.
Separately, the operator chose **no budget cap** on parallel fan-out, combined with **fully
dynamic** re-planning. That combination is the single largest cost risk in the system.
## Decision
1. **Append-only NDJSON** at `.agents/runs/<runId>/events.ndjson`. One JSON object per line,
written with `O_APPEND` so concurrent lanes cannot interleave partial lines.
2. **OpenTelemetry GenAI-inspired attribute names** (`gen_ai.usage.input_tokens`,
`gen_ai.request.model`, …) **without an OTEL dependency**. The data is portable to a real
tracing backend later; today it costs nothing.
3. **Tolerant reader.** Malformed lines are skipped, never thrown on. A crashed agent must not
destroy the log.
4. **Live board.** `board.md` is regenerated on every event and shows lanes in flight, ralph
iterations, elapsed time, tokens, and — prominently — **burn rate in tokens/min**. Board
rendering is wrapped in try/catch so it can never break a telemetry write.
5. **`lh report`** renders the markdown summary; **`lh report --journal`** renders the committed,
self-documenting per-run journal.
### On the absent budget cap
No hard cap is enforced, per the operator's explicit choice. The mitigation is **visibility, not
prevention**: live burn rate in `board.md`, a post-run cost breakdown in `lh report`, and an
advisory `concurrency.maxWriteLanes` ceiling that warns and only blocks under `--strict`.
## Consequences
- Zero infrastructure. Telemetry works offline and in CI with no setup.
- `lh run event` is on the hot path (dozens of calls per run) and must stay well under 100 ms.
- Cost overrun is possible by design. Accepted and documented; visibility is the control.
- Because the schema is OTEL-shaped, exporting to Langfuse/OpenLLMetry later is a transform, not a
re-instrumentation.
## Alternatives considered
- **OpenTelemetry SDK** — rejected: heavy dependency and a collector to run, for data we currently
only read locally.
- **SQLite event store** — rejected: native dependency (see ADR 0005), and NDJSON is already
append-safe and greppable.
- **Hard credit cap** — rejected by the operator. Revisit if real runs overrun.
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/marketplace.schema.json",
"name": "redsen",
"owner": {
"name": "Redsen",
"url": "https://github.com/redsentech"
},
"metadata": {
"description": "Redsen agent tooling for GitHub Copilot CLI and VS Code Copilot.",
"version": "0.1.0"
},
"plugins": [
{
"name": "redsen-lean-harness",
"source": ".",
"description": "Imperative, token-lean, self-documenting agent harness with dynamic parallel worktree lanes."
}
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@redsen/lean-harness",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@redsen/lean-harness",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"web-tree-sitter": "^0.25.10"
},
"bin": {
"lh": "src/cli.mjs"
},
"engines": {
"node": ">=20"
}
},
"node_modules/web-tree-sitter": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz",
"integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==",
"license": "MIT",
"peerDependencies": {
"@types/emscripten": "^1.40.0"
},
"peerDependenciesMeta": {
"@types/emscripten": {
"optional": true
}
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@redsen/lean-harness",
"version": "0.1.0",
"description": "Imperative, token-lean, self-documenting agent harness for GitHub Copilot CLI and VS Code Copilot.",
"license": "MIT",
"author": "Redsen <https://github.com/redsentech>",
"repository": {
"type": "git",
"url": "git+https://github.com/redsentech/lean-harness.git"
},
"type": "module",
"engines": {
"node": ">=20"
},
"bin": {
"lh": "./src/cli.mjs"
},
"files": [
"src",
"templates",
".github",
"plugin.json",
"marketplace.json",
"README.md",
"LICENSE"
],
"scripts": {
"validate": "node scripts/validate.mjs",
"test": "node --test tests/",
"lh": "node src/cli.mjs"
},
"dependencies": {
"web-tree-sitter": "^0.25.10"
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "redsen-lean-harness",
"description": "Imperative, token-lean, self-documenting agent harness. Spec-driven pipeline with a Ralph implement/verify loop, dynamic parallel worktree lanes, file-based memory, tree-sitter repo index, and zero-server telemetry. Works in GitHub Copilot CLI and VS Code Copilot.",
"version": "0.1.0",
"author": {
"name": "Redsen",
"url": "https://github.com/redsentech"
},
"repository": "https://github.com/redsentech/lean-harness",
"license": "MIT",
"keywords": [
"agent-harness",
"spec-driven",
"ralph-loop",
"parallel-agents",
"memory",
"tree-sitter",
"telemetry",
"github-copilot"
],
"extensions": {
"com.github.copilot": {
"agents": "./.github/agents",
"skills": "./.github/skills",
"instructions": "./.github/instructions",
"mcp": "./.github/mcp.json"
}
}
}
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env node
/**
* `npm run validate` — distribution gate.
*
* Verifies the plugin manifests and every markdown frontmatter block that the
* Copilot CLI / VS Code loaders depend on. Run this before tagging a release
* and in CI. Exits 1 on any error.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const errors = [];
const warnings = [];
const seen = [];
const rel = (p) => path.relative(ROOT, p);
const error = (file, msg) => errors.push(`${rel(file)}: ${msg}`);
const warn = (file, msg) => warnings.push(`${rel(file)}: ${msg}`);
/** Minimal YAML frontmatter reader — supports scalars, inline lists, block lists. */
function frontmatter(text) {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!match) return null;
const data = {};
let key = null;
let blockList = null;
let folded = null;
for (const raw of match[1].split(/\r?\n/)) {
const listItem = /^\s*-\s+(.*)$/.exec(raw);
if (listItem && key) {
blockList = blockList ?? [];
blockList.push(unquote(listItem[1]));
data[key] = blockList;
continue;
}
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(raw);
if (pair) {
if (folded && key) data[key] = folded.join(' ').trim();
folded = null;
blockList = null;
key = pair[1];
const value = pair[2].trim();
if (value === '>' || value === '|' || value === '>-' || value === '|-') {
folded = [];
data[key] = '';
} else if (value.startsWith('[') && value.endsWith(']')) {
data[key] = value
.slice(1, -1)
.split(',')
.map((s) => unquote(s.trim()))
.filter(Boolean);
} else if (value === '') {
data[key] = '';
} else {
data[key] = coerce(unquote(value));
}
continue;
}
if (folded && raw.trim()) folded.push(raw.trim());
}
if (folded && key) data[key] = folded.join(' ').trim();
return data;
}
const unquote = (s) => s.replace(/^['"]|['"]$/g, '');
const coerce = (s) => (s === 'true' ? true : s === 'false' ? false : s);
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch (cause) {
error(file, `invalid JSON — ${cause.message}`);
return null;
}
}
function listFiles(dir, predicate) {
if (!fs.existsSync(dir)) return [];
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...listFiles(full, predicate));
else if (predicate(full)) out.push(full);
}
return out;
}
// ---------------------------------------------------------------- manifests
function validatePlugin() {
const file = path.join(ROOT, 'plugin.json');
if (!fs.existsSync(file)) return error(file, 'missing');
const manifest = readJson(file);
if (!manifest) return undefined;
seen.push('plugin.json');
for (const field of ['name', 'description', 'version']) {
if (!manifest[field]) error(file, `missing required field "${field}"`);
}
if (manifest.name && !/^[a-z0-9][a-z0-9-]*$/.test(manifest.name)) {
error(file, `name "${manifest.name}" must be a lowercase slug (no scope, no slash)`);
}
if (manifest.version && !/^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(manifest.version)) {
error(file, `version "${manifest.version}" is not semver`);
}
if (!manifest.$schema?.includes('agent-plugins.org')) {
warn(file, 'missing the agent-plugins.org $schema reference');
}
const ext = manifest.extensions?.['com.github.copilot'] ?? {};
for (const [key, target] of Object.entries(ext)) {
const abs = path.resolve(ROOT, target);
if (!fs.existsSync(abs)) error(file, `extensions.com.github.copilot.${key} points at missing path "${target}"`);
}
return manifest;
}
function validateMarketplace(plugin) {
const file = path.join(ROOT, 'marketplace.json');
if (!fs.existsSync(file)) return warn(file, 'missing (optional)');
const manifest = readJson(file);
if (!manifest) return undefined;
seen.push('marketplace.json');
if (!manifest.name) error(file, 'missing "name"');
if (!Array.isArray(manifest.plugins) || !manifest.plugins.length) {
error(file, 'must list at least one plugin');
return undefined;
}
for (const entry of manifest.plugins) {
if (!entry.name) error(file, 'a plugin entry is missing "name"');
if (!entry.source) error(file, `plugin "${entry.name}" is missing "source"`);
else if (!fs.existsSync(path.resolve(ROOT, entry.source))) {
error(file, `plugin "${entry.name}" source "${entry.source}" does not exist`);
}
}
if (plugin && !manifest.plugins.some((p) => p.name === plugin.name)) {
error(file, `does not list the root plugin "${plugin.name}"`);
}
if (plugin && manifest.metadata?.version && manifest.metadata.version !== plugin.version) {
warn(file, `metadata.version (${manifest.metadata.version}) differs from plugin.json (${plugin.version})`);
}
return manifest;
}
function validatePackageJson(plugin) {
const file = path.join(ROOT, 'package.json');
const pkg = readJson(file);
if (!pkg) return;
seen.push('package.json');
if (pkg.type !== 'module') error(file, 'must set "type": "module"');
if (plugin && pkg.version !== plugin.version) {
error(file, `version ${pkg.version} does not match plugin.json ${plugin.version}`);
}
const bin = pkg.bin?.lh;
if (!bin) error(file, 'missing bin.lh');
else if (!fs.existsSync(path.resolve(ROOT, bin))) error(file, `bin.lh points at missing "${bin}"`);
}
// ------------------------------------------------------------ markdown docs
function validateAgents() {
const dir = path.join(ROOT, '.github', 'agents');
const files = listFiles(dir, (f) => f.endsWith('.agent.md'));
if (!files.length) return warn(dir, 'no .agent.md files found');
const names = new Map();
const declared = new Map();
for (const file of files) {
const fm = frontmatter(fs.readFileSync(file, 'utf8'));
seen.push(rel(file));
if (!fm) {
error(file, 'missing YAML frontmatter');
continue;
}
if (!fm.description) error(file, 'frontmatter "description" is required');
if (fm.name) {
if (names.has(fm.name)) error(file, `duplicate agent name "${fm.name}" (also in ${names.get(fm.name)})`);
names.set(fm.name, path.basename(file));
} else {
warn(file, 'frontmatter "name" is recommended');
}
if (fm.agents) declared.set(file, Array.isArray(fm.agents) ? fm.agents : [fm.agents]);
}
// Delegation targets must resolve to a real agent.
for (const [file, targets] of declared) {
for (const target of targets) {
if (!target) continue;
const known = [...names.keys()].some((n) => n.toLowerCase() === String(target).toLowerCase());
if (!known) error(file, `delegates to unknown agent "${target}"`);
}
}
}
function validateSkills() {
const dir = path.join(ROOT, '.github', 'skills');
const files = listFiles(dir, (f) => path.basename(f) === 'SKILL.md');
if (!files.length) return warn(dir, 'no SKILL.md files found');
for (const file of files) {
const fm = frontmatter(fs.readFileSync(file, 'utf8'));
seen.push(rel(file));
if (!fm) {
error(file, 'missing YAML frontmatter');
continue;
}
if (!fm.name) error(file, 'frontmatter "name" is required');
const dirName = path.basename(path.dirname(file));
if (fm.name && fm.name !== dirName) {
error(file, `frontmatter name "${fm.name}" must match its directory "${dirName}"`);
}
const desc = String(fm.description ?? '');
if (!desc) error(file, 'frontmatter "description" is required');
else if (desc.length < 10 || desc.length > 1024) {
error(file, `description must be 10-1024 chars, got ${desc.length}`);
}
}
}
function validateInstructions() {
const dir = path.join(ROOT, '.github', 'instructions');
for (const file of listFiles(dir, (f) => f.endsWith('.instructions.md'))) {
const fm = frontmatter(fs.readFileSync(file, 'utf8'));
seen.push(rel(file));
if (!fm) error(file, 'missing YAML frontmatter');
else if (!fm.applyTo) error(file, 'frontmatter "applyTo" glob is required');
}
}
function validateMcp() {
const file = path.join(ROOT, '.github', 'mcp.json');
if (!fs.existsSync(file)) return warn(file, 'missing (optional)');
const raw = fs.readFileSync(file, 'utf8');
const manifest = readJson(file);
if (!manifest) return;
seen.push('.github/mcp.json');
const servers = manifest.mcpServers ?? manifest.servers;
if (!servers || !Object.keys(servers).length) error(file, 'declares no MCP servers');
// Hard rule: no secret may ever be committed.
const leaks = [
[/ctx7sk-[A-Za-z0-9-]{8,}/, 'a Context7 key'],
[/gh[pousr]_[A-Za-z0-9]{20,}/, 'a GitHub token'],
[/sk-[A-Za-z0-9]{20,}/, 'an API key'],
];
for (const [pattern, what] of leaks) {
if (pattern.test(raw)) error(file, `appears to contain ${what} — secrets must use \${env:...} interpolation`);
}
}
// ------------------------------------------------------------------- report
validatePackageJson(validatePlugin());
const plugin = readJson(path.join(ROOT, 'plugin.json'));
validateMarketplace(plugin);
validateAgents();
validateSkills();
validateInstructions();
validateMcp();
for (const w of warnings) process.stdout.write(`warn ${w}\n`);
for (const e of errors) process.stdout.write(`error ${e}\n`);
process.stdout.write(
`\nvalidated ${seen.length} file(s) — ${errors.length} error(s), ${warnings.length} warning(s)\n`,
);
process.exit(errors.length ? 1 : 0);
Executable
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* `lh` — the deterministic half of redsen-lean-harness.
*
* Commands are lazily imported so a broken or unbuilt module never blocks the
* rest of the CLI, and startup stays fast for the hot paths (run/event, graph).
*/
import { parseArgs, out, err, dim, bold } from './lib/ui.mjs';
const COMMANDS = {
init: { module: './commands/init.mjs', help: 'first-run wizard, writes harness.config.json' },
index: { module: './commands/index.mjs', help: 'token-budgeted tree-sitter repo map' },
graph: { module: './commands/graph.mjs', help: 'structural gate; exit 1 on violations' },
lane: { module: './commands/lane.mjs', help: 'git worktree lane lifecycle + file leases' },
run: { module: './commands/run.mjs', help: 'telemetry: start | event | end' },
memory: { module: './commands/memory.mjs', help: 'memory shards: get | put | compact | scan' },
host: { module: './commands/host.mjs', help: 'detect host, print orchestration strategy' },
report: { module: './commands/report.mjs', help: 'markdown telemetry report' },
doctor: { module: './commands/doctor.mjs', help: 'environment checks' },
};
function usage() {
out(`${bold('lh')} — redsen-lean-harness\n`);
out('usage: lh <command> [options]\n');
for (const [name, meta] of Object.entries(COMMANDS)) {
out(` ${name.padEnd(8)} ${dim(meta.help)}`);
}
out(`\n ${'--json'.padEnd(8)} ${dim('machine-readable output (most commands)')}`);
}
async function main() {
const [, , command, ...rest] = process.argv;
if (!command || command === 'help' || command === '--help' || command === '-h') {
usage();
return 0;
}
if (command === '--version' || command === '-v') {
const { default: pkg } = await import('../package.json', { with: { type: 'json' } });
out(pkg.version);
return 0;
}
const entry = COMMANDS[command];
if (!entry) {
err(`unknown command: ${command}`);
usage();
return 2;
}
let mod;
try {
mod = await import(entry.module);
} catch (cause) {
if (cause?.code === 'ERR_MODULE_NOT_FOUND' && String(cause.message).includes(entry.module.slice(1))) {
err(`command "${command}" is not implemented yet`);
return 69;
}
throw cause;
}
const { flags, positional } = parseArgs(rest);
const code = await mod.default({ flags, positional, argv: rest });
return typeof code === 'number' ? code : 0;
}
main()
.then((code) => process.exit(code))
.catch((error) => {
err(error?.stack ?? String(error));
process.exit(1);
});
+191
View File
@@ -0,0 +1,191 @@
/**
* `lh doctor` — environment checks.
*
* Every check is observable: we never infer a capability we did not test.
* Exit 1 if any required check fails; warnings alone still exit 0.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { loadConfig, defaultConfig, validateConfig } from '../lib/config.mjs';
import { paths } from '../lib/paths.mjs';
import { out, table, green, red, yellow, dim } from '../lib/ui.mjs';
const REQUIRED_NODE_MAJOR = 20;
function tryExec(cmd, args, opts = {}) {
try {
return execFileSync(cmd, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
...opts,
}).trim();
} catch {
return null;
}
}
/** Details are rendered into an aligned table, so they must stay single-line. */
function oneLine(value, max = 72) {
const flat = String(value ?? '').replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
function check(name, level, passed, detail) {
return { name, level, passed, detail: oneLine(detail) };
}
function nodeChecks() {
const major = Number(process.versions.node.split('.')[0]);
return [
check(
'node',
'required',
major >= REQUIRED_NODE_MAJOR,
`v${process.versions.node} (need >= ${REQUIRED_NODE_MAJOR})`,
),
check('fetch', 'required', typeof globalThis.fetch === 'function', 'global fetch available'),
check('wasm', 'optional', typeof WebAssembly === 'object', 'WebAssembly available'),
];
}
function gitChecks(cwd) {
const version = tryExec('git', ['--version']);
const checks = [check('git', 'required', Boolean(version), version ?? 'not on PATH')];
if (!version) return checks;
const inRepo = tryExec('git', ['rev-parse', '--is-inside-work-tree'], { cwd }) === 'true';
checks.push(check('git.repo', 'required', inRepo, inRepo ? 'inside a work tree' : 'not a git repository'));
if (inRepo) {
const worktrees = tryExec('git', ['worktree', 'list'], { cwd });
checks.push(
check(
'git.worktree',
'required',
worktrees !== null,
worktrees !== null ? 'supported' : 'git worktree unavailable — parallel write lanes disabled',
),
);
const dirty = tryExec('git', ['status', '--porcelain'], { cwd });
checks.push(
check('git.clean', 'optional', dirty === '', dirty === '' ? 'clean tree' : 'uncommitted changes present'),
);
}
return checks;
}
function hostChecks() {
const copilotHome = path.join(os.homedir(), '.copilot');
const copilotBin = tryExec('copilot', ['--version']);
const isVsCode = process.env.TERM_PROGRAM === 'vscode' || Boolean(process.env.VSCODE_PID);
const ci = Boolean(process.env.CI || process.env.GITHUB_ACTIONS);
let host = 'unknown';
if (ci && !process.stdout.isTTY) host = 'headless';
else if (isVsCode) host = 'vscode';
else if (copilotBin || fs.existsSync(copilotHome)) host = 'copilot-cli';
return [
check('host', 'info', true, host),
check('host.copilotBin', 'optional', Boolean(copilotBin), copilotBin ?? 'copilot not on PATH'),
check('host.copilotHome', 'optional', fs.existsSync(copilotHome), copilotHome),
check('host.tty', 'info', true, process.stdout.isTTY ? 'interactive' : 'non-interactive'),
];
}
function configChecks(cwd) {
const loaded = loadConfig(cwd);
if (!loaded) {
return [check('config', 'required', false, 'not initialised — run `lh init`')];
}
const problems = validateConfig(loaded.config);
const checks = [
check('config', 'required', true, path.relative(cwd, loaded.file) || loaded.file),
check(
'config.valid',
'required',
problems.length === 0,
problems.length ? problems.join('; ') : 'schema ok',
),
];
const p = paths(loaded.config, cwd);
checks.push(check('memory', 'optional', fs.existsSync(p.memoryIndex), p.memoryIndex));
checks.push(check('index.cache', 'optional', fs.existsSync(p.symbols), p.symbols));
const verify = loaded.config.verify?.commands ?? [];
checks.push(
check(
'verify.commands',
'required',
verify.length > 0,
verify.length
? verify.map((v) => v.name ?? v.command).join(', ')
: 'none configured — the Ralph loop cannot gate',
),
);
return checks;
}
function context7Checks(config) {
const envVar = config.context7?.envVar ?? 'CONTEXT7_API_KEY';
const present = Boolean(process.env[envVar]);
const required = config.context7?.required !== false;
return [
check(
'context7',
required ? 'required' : 'optional',
present,
present ? `${envVar} is set` : `${envVar} is NOT set — export it before any external-library work`,
),
];
}
export default async function doctor({ flags }) {
const cwd = process.cwd();
const loaded = loadConfig(cwd);
const config = loaded?.config ?? defaultConfig();
const checks = [
...nodeChecks(),
...gitChecks(cwd),
...hostChecks(),
...configChecks(cwd),
...context7Checks(config),
];
const failedRequired = checks.filter((c) => c.level === 'required' && !c.passed);
const failedOptional = checks.filter((c) => c.level === 'optional' && !c.passed);
if (flags.json) {
out(JSON.stringify({ ok: failedRequired.length === 0, checks }, null, 2));
return failedRequired.length === 0 ? 0 : 1;
}
const mark = (c) => {
if (c.level === 'info') return dim('·');
if (c.passed) return green('✓');
return c.level === 'required' ? red('✗') : yellow('!');
};
out(table(checks.map((c) => ({ '': mark(c), check: c.name, detail: c.detail })), ['', 'check', 'detail']));
out('');
if (failedRequired.length) {
out(`${red('fail')} ${failedRequired.length} required check(s): ${failedRequired.map((c) => c.name).join(', ')}`);
} else if (failedOptional.length) {
out(`${yellow('ok')} all required checks passed; ${failedOptional.length} optional warning(s)`);
} else {
out(`${green('ok')} all checks passed`);
}
if (!process.env[config.context7?.envVar ?? 'CONTEXT7_API_KEY']) {
out('');
out(dim(' Context7 setup (env var only — never written to disk):'));
out(dim(' export CONTEXT7_API_KEY="<your key>" # add to ~/.bashrc or ~/.zshrc'));
}
return failedRequired.length === 0 ? 0 : 1;
}
+28
View File
@@ -0,0 +1,28 @@
import { defaultConfig, loadConfig } from '../lib/config.mjs';
import { paths } from '../lib/paths.mjs';
import { out } from '../lib/ui.mjs';
import { buildIndex } from '../lib/repomap.mjs';
import { brief, reconcile } from '../lib/graph.mjs';
export default async function ({ flags, positional, argv }) {
void positional;
void argv;
const loaded = loadConfig(process.cwd());
const config = loaded?.config ?? defaultConfig();
const p = paths(config, process.cwd());
const index = await buildIndex({ config, paths: p }, { fetch: false, force: false });
const result = reconcile({ config, paths: p }, { index, fixManifest: Boolean(flags['fix-manifest']) });
const visible = { ...result, violations: filterViolations(result.violations, flags.severity) };
const hasErrors = result.violations.some((violation) => violation.severity === 'error');
if (flags.json) out(JSON.stringify(visible, null, 2));
else out(brief(visible));
return hasErrors ? 1 : 0;
}
function filterViolations(violations, severity) {
if (severity === 'error') return violations.filter((violation) => violation.severity === 'error');
if (severity === 'warn') return violations;
return violations;
}
+37
View File
@@ -0,0 +1,37 @@
/** `lh host` — detect execution host and print orchestration strategy. */
import { loadConfig, defaultConfig } from '../lib/config.mjs';
import { detectHost, strategy } from '../lib/host.mjs';
import { out, table, dim } from '../lib/ui.mjs';
export default async function host({ flags }) {
const loaded = loadConfig(process.cwd());
const config = loaded?.config ?? defaultConfig();
const detection = detectHost(config, process.cwd());
if (flags.json) {
out(JSON.stringify(detection, null, 2));
return 0;
}
const block = strategy(config, detection);
if (flags.strategy) {
out(block);
return 0;
}
out(
table([
{ key: 'host', value: detection.host },
{ key: 'confidence', value: detection.confidence },
{ key: 'worktrees', value: detection.capabilities.worktrees ? 'yes' : 'no' },
{ key: 'fleet', value: detection.capabilities.fleet ? 'yes' : 'no' },
{ key: 'headlessSpawn', value: detection.capabilities.headlessSpawn ? 'yes' : 'no' },
{ key: 'interactivePrompt', value: detection.capabilities.interactivePrompt ? 'yes' : 'no' },
{ key: 'maxWriteLanes', value: detection.maxWriteLanes },
{ key: 'readOnlyFanOut', value: detection.readOnlyFanOut },
]),
);
out('');
out(dim(block));
return 0;
}
+55
View File
@@ -0,0 +1,55 @@
import { defaultConfig, loadConfig } from '../lib/config.mjs';
import { paths } from '../lib/paths.mjs';
import { out } from '../lib/ui.mjs';
import { buildIndex, renderMap } from '../lib/repomap.mjs';
export default async function ({ flags, positional, argv }) {
void positional;
void argv;
const loaded = loadConfig(process.cwd());
const config = loaded?.config ?? defaultConfig();
const p = paths(config, process.cwd());
const budget = numberFlag(flags.budget, config.index?.budget ?? 1200);
const index = await buildIndex(
{ config, paths: p },
{
fetch: Boolean(flags.fetch),
focus: typeof flags.focus === 'string' ? flags.focus : null,
budget,
force: Boolean(flags.force),
},
);
if (flags.json) {
out(JSON.stringify(index, null, 2));
return 0;
}
if (flags.stats) {
out(formatStats(index.stats));
return 0;
}
out(renderMap(index, { budget, focus: flags.focus }));
return 0;
}
function formatStats(stats) {
return [
`files ${stats.files}`,
`symbols ${stats.symbols}`,
`refs ${stats.refs}`,
`languages ${Object.entries(stats.languages).map(([k, v]) => `${k}:${v}`).join(',') || '-'}`,
`cacheHits ${stats.cacheHits}`,
`parsed ${stats.parsed}`,
`skipped ${stats.skipped}`,
`degraded ${Object.keys(stats.degradedLanguages).join(',') || '-'}`,
`elapsedMs ${stats.elapsedMs}`,
`tokens ${stats.tokens}`,
].join('\n');
}
function numberFlag(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
+311
View File
@@ -0,0 +1,311 @@
import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import {
CONFIG_FILENAME,
MODEL_TIERS,
ROLE_TIERS,
RALPH_EXIT_CRITERIA,
MEMORY_SHARDS,
defaultConfig,
findConfigFile,
saveConfig,
validateConfig,
} from '../lib/config.mjs';
import { repoRoot, paths as makePaths, ensureDir, writeFile, readIfExists } from '../lib/paths.mjs';
import { out, err } from '../lib/ui.mjs';
import { TEMPLATE_DIR, render, emptyShardText, rebuildIndex } from '../lib/memory.mjs';
function splitList(value) {
return String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function parseBoolMap(value, defaults) {
const result = { ...defaults };
for (const item of splitList(value)) {
const [key, raw = 'true'] = item.split('=').map((part) => part.trim());
if (key) result[key] = !['false', '0', 'no', 'off'].includes(raw.toLowerCase());
}
return result;
}
function parseRoleMap(value, defaults) {
const result = { ...defaults };
for (const item of splitList(value)) {
const [role, tier] = item.split(':').map((part) => part.trim());
if (role && tier) result[role] = tier;
}
return result;
}
function parseCommands(value) {
const text = String(value ?? '').trim();
if (!text) return [];
return text
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const eq = part.indexOf('=');
if (eq === -1) return { name: part.split(/\s+/)[0], command: part };
return { name: part.slice(0, eq).trim(), command: part.slice(eq + 1).trim() };
})
.filter((command) => command.name && command.command);
}
function maybeReadJson(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return null;
}
}
function addCommand(commands, name, command) {
if (!commands.some((item) => item.command === command)) commands.push({ name, command });
}
function rootFiles(root) {
try {
return fs.readdirSync(root, { withFileTypes: true });
} catch {
return [];
}
}
function detectVerifyCommands(root) {
const commands = [];
const entries = rootFiles(root);
const names = new Set(entries.map((entry) => entry.name));
if (names.has('package.json')) {
const pkg = maybeReadJson(path.join(root, 'package.json'));
const scripts = pkg?.scripts ?? {};
for (const name of ['test', 'lint', 'typecheck', 'build']) {
if (scripts[name]) addCommand(commands, name, `npm run ${name}`);
}
}
if (names.has('pyproject.toml') || names.has('setup.cfg') || names.has('tox.ini')) {
addCommand(commands, 'test', 'pytest');
addCommand(commands, 'lint', 'ruff check .');
addCommand(commands, 'typecheck', 'mypy .');
}
if (names.has('go.mod')) {
addCommand(commands, 'test', 'go test ./...');
addCommand(commands, 'vet', 'go vet ./...');
addCommand(commands, 'build', 'go build ./...');
}
if (names.has('Cargo.toml')) {
addCommand(commands, 'test', 'cargo test');
addCommand(commands, 'lint', 'cargo clippy');
addCommand(commands, 'build', 'cargo build');
}
if (entries.some((entry) => entry.name.endsWith('.csproj') || entry.name.endsWith('.sln'))) {
addCommand(commands, 'test', 'dotnet test');
addCommand(commands, 'build', 'dotnet build');
}
if (names.has('pom.xml')) addCommand(commands, 'test', 'mvn -q test');
if (entries.some((entry) => /^build\.gradle/.test(entry.name))) addCommand(commands, 'test', 'gradle test');
if (names.has('Makefile')) {
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
const targets = [...makefile.matchAll(/^([A-Za-z0-9_.-]+):/gm)].map((match) => match[1]);
for (const target of ['test', 'lint', 'build', 'check']) {
if (targets.includes(target)) addCommand(commands, target, `make ${target}`);
}
}
if (names.has('composer.json')) {
const composer = maybeReadJson(path.join(root, 'composer.json'));
const scripts = composer?.scripts ?? {};
for (const name of ['test', 'lint', 'build', 'check']) {
if (scripts[name]) addCommand(commands, name, `composer ${name}`);
}
}
if (names.has('Gemfile')) addCommand(commands, 'test', 'bundle exec rspec');
return commands;
}
async function ask(rl, yes, label, fallback) {
if (yes) return fallback;
const answer = await rl.question(`${label} [${fallback}]: `);
return answer.trim() ? answer.trim() : fallback;
}
function template(name, vars = {}) {
return render(fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf8'), vars);
}
function writeIfAbsent(file, contents) {
if (!fs.existsSync(file)) writeFile(file, contents);
}
function gitignorePatterns(config) {
const root = config.memory.root.replace(/\/+$/g, '');
return (config.memory.ignored ?? []).map((pattern) => {
if (path.isAbsolute(root)) return pattern;
return `${root}/${pattern}`.replace(/\/+/g, '/');
});
}
function updateGitignore(root, config) {
const file = path.join(root, '.gitignore');
const start = '# redsen-lean-harness';
const end = '# /redsen-lean-harness';
const block = `${start}\n${gitignorePatterns(config).join('\n')}\n${end}`;
const current = readIfExists(file) ?? '';
const regex = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
const next = regex.test(current)
? current.replace(regex, block)
: `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${block}\n`;
if (next !== current) writeFile(file, next);
}
function context7Instructions(envVar) {
return [
`${envVar} missing. Context7 key stays env-only; never written.`,
`bash/zsh: export ${envVar}="<your key>"`,
`fish: set -Ux ${envVar} "<your key>"`,
];
}
export default async function init({ flags }) {
const cwd = process.cwd();
const root = repoRoot(cwd);
const existing = findConfigFile(cwd);
const json = Boolean(flags.json);
const yes = Boolean(flags.yes);
const force = Boolean(flags.force);
if (existing && !force) {
const message = `config exists: ${path.relative(root, existing) || existing}; use --force`;
if (json) out(JSON.stringify({ ok: false, error: message }, null, 2));
else err(message);
return 1;
}
const config = defaultConfig();
const rl = readline.createInterface({ input, output });
try {
config.memory.root = await ask(rl, yes, 'Memory root path', config.memory.root);
config.memory.committed = splitList(
await ask(rl, yes, 'Committed artifacts CSV', config.memory.committed.join(',')),
);
config.memory.ignored = splitList(
await ask(rl, yes, 'Gitignored artifacts CSV', config.memory.ignored.join(',')),
);
config.memory.tokenBudget = Number(
await ask(rl, yes, 'Max memory token budget before compaction', config.memory.tokenBudget),
);
config.artifacts = parseBoolMap(
await ask(
rl,
yes,
'Auto-written artifacts key=true CSV',
Object.entries(config.artifacts)
.map(([key, value]) => `${key}=${value}`)
.join(','),
),
config.artifacts,
);
config.index.depth = Number(await ask(rl, yes, 'AST index depth', config.index.depth));
config.index.include = splitList(await ask(rl, yes, 'AST include globs CSV', config.index.include.join(',')));
config.index.exclude = splitList(await ask(rl, yes, 'AST exclude globs CSV', config.index.exclude.join(',')));
for (const tier of MODEL_TIERS) {
config.models.tiers[tier] = await ask(rl, yes, `Model id for tier ${tier}`, config.models.tiers[tier]);
}
config.models.roles = parseRoleMap(
await ask(
rl,
yes,
`Role tier map role:tier CSV (${Object.entries(ROLE_TIERS)
.map(([role, tier]) => `${role}:${tier}`)
.join(', ')})`,
Object.entries(config.models.roles)
.map(([role, tier]) => `${role}:${tier}`)
.join(','),
),
config.models.roles,
);
config.ralph.maxIterations = Number(await ask(rl, yes, 'Ralph max iterations', config.ralph.maxIterations));
config.ralph.exitCriteria = splitList(
await ask(rl, yes, 'Active Ralph exit criteria CSV', RALPH_EXIT_CRITERIA.join(',')),
);
const detected = detectVerifyCommands(root);
config.verify.commands = parseCommands(
await ask(
rl,
yes,
'Verify commands name=command;...',
detected.map((command) => `${command.name}=${command.command}`).join(';'),
),
);
config.verify.autodetected = true;
config.concurrency.maxWriteLanes = Number(
await ask(rl, yes, 'Concurrency maxWriteLanes', config.concurrency.maxWriteLanes),
);
config.concurrency.readOnlyFanOut = Number(
await ask(rl, yes, 'Concurrency readOnlyFanOut', config.concurrency.readOnlyFanOut),
);
const envVar = config.context7.envVar;
config.context7 = { required: true, envVar };
if (!process.env[envVar]) {
for (const line of context7Instructions(envVar)) err(line);
}
} finally {
rl.close();
}
const problems = validateConfig(config);
if (problems.length) {
if (json) out(JSON.stringify({ ok: false, problems }, null, 2));
else for (const problem of problems) err(`error ${problem}`);
return 1;
}
const p = makePaths(config, root);
saveConfig(path.join(p.agents, CONFIG_FILENAME), config);
ensureDir(p.memory);
ensureDir(p.specs);
ensureDir(p.runs);
ensureDir(p.cache);
for (const shard of MEMORY_SHARDS) {
writeIfAbsent(path.join(p.memory, `${shard}.md`), emptyShardText(shard));
}
rebuildIndex({ config, paths: p });
if (config.artifacts.architecture) {
writeIfAbsent(p.architecture, template('architecture.md', { PROJECT: path.basename(root) }));
}
if (config.artifacts.conventions) {
writeIfAbsent(p.conventions, template('conventions.md', { PROJECT: path.basename(root) }));
}
updateGitignore(root, config);
const result = {
ok: true,
root,
config: p.config,
memory: p.memory,
verify: config.verify.commands,
context7: { required: config.context7.required, envVar: config.context7.envVar, present: Boolean(process.env[config.context7.envVar]) },
};
if (json) out(JSON.stringify(result, null, 2));
else out(`ok init ${path.relative(root, p.config)}`);
return 0;
}
+178
View File
@@ -0,0 +1,178 @@
/** `lh lane` — git worktree lane lifecycle + file leases. */
import { loadConfig, defaultConfig } from '../lib/config.mjs';
import { paths } from '../lib/paths.mjs';
import { out, err, warn, ok, table } from '../lib/ui.mjs';
import { createLane, listLanes, laneStatus, mergeLane, dropLane } from '../lib/lane.mjs';
function context(flags) {
const cwd = process.cwd();
const loaded = loadConfig(cwd);
const config = loaded?.config ?? defaultConfig();
return { cwd, config, paths: paths(config, cwd), strict: Boolean(flags.strict), warnings: [] };
}
function json(value) {
out(JSON.stringify(value, null, 2));
}
function fail(flags, message, extra = {}) {
if (flags.json) json({ ok: false, error: message, ...extra });
else err(`error ${message}`);
return 1;
}
function requireFlag(flags, name) {
if (!flags[name]) throw new Error(`missing --${name}`);
return flags[name];
}
function oneLine(value, max = 60) {
const flat = String(value ?? '').replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
function laneRows(lanes) {
return lanes.map((lane) => ({
id: lane.id,
kind: lane.kind,
status: lane.status,
branch: lane.branch ?? '-',
scope: (lane.scope ?? []).join(','),
updated: lane.updatedAt,
}));
}
function printLane(lane) {
out(
table([
{ key: 'id', value: lane.id },
{ key: 'title', value: lane.title },
{ key: 'kind', value: lane.kind },
{ key: 'status', value: lane.status },
{ key: 'branch', value: lane.branch ?? '-' },
{ key: 'worktree', value: lane.worktree ?? '-' },
{ key: 'baseRef', value: lane.baseRef ?? '-' },
{ key: 'scope', value: (lane.scope ?? []).join(',') || '-' },
]),
);
}
async function create(flags) {
const ctx = context(flags);
try {
const lane = createLane(ctx, {
id: requireFlag(flags, 'id'),
title: requireFlag(flags, 'title'),
kind: requireFlag(flags, 'kind'),
scope: flags.scope,
dependsOn: flags['depends-on'],
acceptance: flags.acceptance,
runId: flags['run-id'],
baseRef: flags.base,
});
if (flags.json) json({ ok: true, lane, warnings: ctx.warnings });
else {
ctx.warnings.forEach((message) => warn(message));
ok(`lane ${lane.id} created`);
printLane(lane);
}
return 0;
} catch (error) {
return fail(flags, error.message);
}
}
async function list(flags) {
const ctx = context(flags);
try {
const lanes = listLanes(ctx, { status: flags.status, runId: flags['run-id'] });
if (flags.json) json({ ok: true, lanes });
else out(lanes.length ? table(laneRows(lanes)) : 'no lanes');
return 0;
} catch (error) {
return fail(flags, error.message);
}
}
async function status(flags, positional) {
const ctx = context(flags);
const id = positional[1];
if (!id) return fail(flags, 'missing lane id');
try {
const state = laneStatus(ctx, id);
if (flags.json) json({ ok: state.scopeCheck.ok, lane: state });
else {
printLane(state);
out('');
out(
table([
{ key: 'ahead', value: state.git.ahead ?? '?' },
{ key: 'behind', value: state.git.behind ?? '?' },
{ key: 'changed', value: state.git.changedFiles.length },
{ key: 'outOfScope', value: state.git.outOfScope.length },
{ key: 'conflicts', value: state.git.conflicts.length },
]),
);
if (state.git.changedFiles.length) {
out('');
out(table(state.git.changedFiles.map((file) => ({ file, scope: state.git.outOfScope.includes(file) ? 'OUT' : 'ok' }))));
}
}
return state.scopeCheck.ok ? 0 : 1;
} catch (error) {
return fail(flags, error.message);
}
}
async function merge(flags, positional) {
const ctx = context(flags);
const id = positional[1];
if (!id) return fail(flags, 'missing lane id');
try {
const result = mergeLane(ctx, id, { abort: Boolean(flags.abort), strategy: flags.strategy });
if (flags.json) json(result);
else if (result.ok) {
ok(result.aborted ? `merge ${id} aborted` : `lane ${id} merged`);
if (result.scope && !result.scope.ok) warn(`out-of-scope files: ${result.scope.outOfScope.join(', ')}`);
} else {
err(`blocked merge ${id}`);
if (result.conflicts.length) out(table(result.conflicts.map((file) => ({ conflict: file }))));
else err(oneLine(result.error));
}
return result.ok ? 0 : 1;
} catch (error) {
return fail(flags, error.message);
}
}
async function drop(flags, positional) {
const ctx = context(flags);
const id = positional[1];
if (!id) return fail(flags, 'missing lane id');
try {
const lane = dropLane(ctx, id, { force: Boolean(flags.force) });
if (flags.json) json({ ok: true, lane });
else ok(`lane ${id} dropped`);
return 0;
} catch (error) {
return fail(flags, error.message);
}
}
function usage() {
out('usage: lh lane create|list|status|merge|drop [options]');
}
export default async function lane({ flags, positional }) {
const sub = positional[0];
if (!sub || sub === 'help' || flags.help || flags.h) {
usage();
return 0;
}
if (sub === 'create') return create(flags);
if (sub === 'list') return list(flags);
if (sub === 'status') return status(flags, positional);
if (sub === 'merge') return merge(flags, positional);
if (sub === 'drop') return drop(flags, positional);
return fail(flags, `unknown lane subcommand: ${sub}`);
}
+132
View File
@@ -0,0 +1,132 @@
import fs from 'node:fs';
import { MEMORY_SHARDS } from '../lib/config.mjs';
import {
openMemory,
listShards,
putEntry,
getMemory,
compact,
scanSecrets,
readShard,
} from '../lib/memory.mjs';
import { out, err, table } from '../lib/ui.mjs';
function asJson(value) {
out(JSON.stringify(value, null, 2));
}
function num(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function formatEntry(entry) {
const tags = entry.tags?.length ? ` tags:${entry.tags.join(',')}` : '';
const score = entry.score ? ` score:${entry.score}` : '';
return [`## ${entry.timestamp} — ${entry.title}${tags}${score}`, entry.body].filter(Boolean).join('\n');
}
async function readStdinIfAvailable() {
if (process.stdin.isTTY) return '';
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks.map((chunk) => Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))).toString('utf8');
}
function scanText(name, text) {
return scanSecrets(text).map((finding) => ({ file: name, ...finding }));
}
export default async function memory({ flags, positional }) {
const subcommand = positional[0] ?? 'get';
const json = Boolean(flags.json);
let ctx;
if (!['get', 'put', 'compact', 'scan', 'list'].includes(subcommand)) {
err(`unknown memory subcommand: ${subcommand}`);
return 2;
}
try {
ctx = openMemory(process.cwd());
} catch (error) {
if (json) asJson({ ok: false, error: error.message });
else err(error.message);
return 1;
}
try {
if (subcommand === 'list') {
const rows = listShards(ctx);
if (json) asJson({ ok: true, shards: rows });
else out(table(rows, ['shard', 'entries', 'tokens', 'updated']));
return 0;
}
if (subcommand === 'get') {
const shard = positional[1];
const result = getMemory(ctx, {
shard,
query: flags.query,
limit: num(flags.limit, flags.limit === undefined ? undefined : 10),
});
if (json) asJson({ ok: true, result });
else if (typeof result === 'string') out(result.trimEnd());
else out(result.map(formatEntry).join('\n\n'));
return 0;
}
if (subcommand === 'put') {
const body = flags.body ?? (await readStdinIfAvailable());
const result = putEntry(ctx, {
shard: flags.shard,
title: flags.title,
body,
tags: flags.tags ?? [],
allowSecrets: Boolean(flags.allowSecrets),
});
if (!result.ok) {
if (json) asJson({ ok: false, findings: result.findings });
else out(table(result.findings, ['kind', 'index', 'preview']));
return 1;
}
if (json) asJson({ ok: true, entry: result.entry });
else out(`ok ${result.entry.shard} ${result.entry.timestamp}`);
return 0;
}
if (subcommand === 'compact') {
const result = compact(ctx, { force: Boolean(flags.force) });
if (json) asJson({ ok: true, ...result });
else {
out(`compact ${result.changed ? 'changed' : 'unchanged'} ${result.totalBefore}->${result.totalAfter}/${result.threshold}`);
if (result.shards.length) out(table(result.shards, ['shard', 'merged', 'keptNewest']));
}
return 0;
}
if (subcommand === 'scan') {
const files = positional.slice(1);
let findings = [];
if (files.length) {
for (const file of files) {
findings = findings.concat(scanText(file, fs.readFileSync(file, 'utf8')));
}
} else {
for (const shard of MEMORY_SHARDS) {
findings = findings.concat(scanText(`${shard}.md`, readShard(ctx, shard)));
}
}
if (json) asJson({ ok: findings.length === 0, findings });
else if (findings.length) out(table(findings, ['file', 'kind', 'index', 'preview']));
else out('ok no-secrets');
return findings.length ? 1 : 0;
}
return 2;
} catch (error) {
if (json) asJson({ ok: false, error: error.message });
else err(`error ${error.message}`);
return 1;
}
}
+74
View File
@@ -0,0 +1,74 @@
import path from 'node:path';
import { loadConfig, defaultConfig } from '../lib/config.mjs';
import { paths, readIfExists, writeFile } from '../lib/paths.mjs';
import { out, err } from '../lib/ui.mjs';
import { renderBoard } from '../lib/board.mjs';
import { renderJournal, renderReport } from '../lib/report.mjs';
import { latestRunId, readEvents, summarize } from '../lib/telemetry.mjs';
function ctx() {
const cwd = process.cwd();
return { cwd, config: loadConfig(cwd)?.config ?? defaultConfig() };
}
function bool(v) {
return v === true || v === 'true';
}
function header(c, runId) {
const p = paths(c.config, c.cwd);
const runDir = p.run(runId);
const raw = readIfExists(path.join(runDir, 'run.json'));
if (!raw) return null;
try {
return { ...JSON.parse(raw), runDir, root: p.root };
} catch {
return { runId, runDir, root: p.root, corrupt: true };
}
}
export default async function report({ flags, positional }) {
const c = ctx();
const runId = positional[0] ?? flags['run-id'] ?? latestRunId(c);
if (!runId) {
err('no run id; start one with `lh run start`');
return 1;
}
const h = header(c, runId);
if (!h) {
err(`unknown run: ${runId}`);
return 1;
}
const events = readEvents(c, runId);
const p = paths(c.config, c.cwd);
if (bool(flags.json)) {
out(JSON.stringify(summarize(events), null, 2));
return 0;
}
if (bool(flags.board)) {
const board = renderBoard(h, events);
if (bool(flags.write)) writeFile(path.join(p.run(runId), 'board.md'), board);
out(board);
return 0;
}
if (bool(flags.write)) {
const journal = renderJournal(h, events, { spec: flags.spec });
const board = renderBoard(h, events);
writeFile(path.join(p.run(runId), 'journal.md'), journal);
writeFile(path.join(p.run(runId), 'board.md'), board);
out(`ok wrote ${path.relative(c.cwd, path.join(p.run(runId), 'journal.md'))}`);
return 0;
}
if (bool(flags.journal)) {
out(renderJournal(h, events, { spec: flags.spec }));
return 0;
}
out(renderReport(h, events));
return 0;
}
+192
View File
@@ -0,0 +1,192 @@
import fs from 'node:fs';
import path from 'node:path';
import { loadConfig, defaultConfig } from '../lib/config.mjs';
import { paths, readIfExists, writeFile } from '../lib/paths.mjs';
import { out, err, table } from '../lib/ui.mjs';
import { appendEvent, endRun, latestRunId, readEvents, startRun, summarize } from '../lib/telemetry.mjs';
function ctx() {
const cwd = process.cwd();
return { cwd, config: loadConfig(cwd)?.config ?? defaultConfig() };
}
function bool(v) {
return v === true || v === 'true';
}
function intFlag(value) {
if (value === undefined || value === null) return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
function parseAttrs(raw) {
if (!raw) return {};
const value = JSON.parse(raw);
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('--attrs must be a JSON object');
return value;
}
function detectHost() {
if (process.env.CI || process.env.GITHUB_ACTIONS) return 'headless';
if (process.env.TERM_PROGRAM === 'vscode' || process.env.VSCODE_PID) return 'vscode';
return 'copilot-cli';
}
function detectStrategy() {
if (process.env.CI || process.env.GITHUB_ACTIONS) return 'ci-local-files';
return process.stdout.isTTY ? 'interactive-local-files' : 'headless-local-files';
}
function header(c, runId) {
const p = paths(c.config, c.cwd);
const file = path.join(p.run(runId), 'run.json');
const raw = readIfExists(file);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return { runId, corrupt: true };
}
}
function requireRunId(c, flags, maybe) {
const runId = flags['run-id'] ?? maybe ?? latestRunId(c);
if (!runId) throw new Error('no run id; start one with `lh run start`');
return runId;
}
function listRuns(c) {
const p = paths(c.config, c.cwd);
try {
return fs
.readdirSync(p.runs, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => header(c, entry.name) ?? { runId: entry.name })
.sort((a, b) => String(b.runId).localeCompare(String(a.runId)));
} catch {
return [];
}
}
function eventFromFlags(flags) {
const attrs = parseAttrs(flags.attrs);
if (flags.lane) attrs.laneId = flags.lane;
if (flags.agent) attrs.agent = flags.agent;
if (flags['exit-code'] !== undefined) attrs.exitCode = intFlag(flags['exit-code']);
return {
type: flags.type,
name: flags.name ?? null,
status: flags.status ?? 'ok',
durationMs: intFlag(flags['duration-ms']),
'gen_ai.request.model': flags.model ?? null,
'gen_ai.operation.name': flags.operation ?? flags.type ?? null,
'gen_ai.usage.input_tokens': intFlag(flags['input-tokens'] ?? flags['in-tokens']) ?? 0,
'gen_ai.usage.output_tokens': intFlag(flags['output-tokens'] ?? flags['out-tokens']) ?? 0,
attrs,
};
}
export default async function run({ flags, positional }) {
const sub = positional[0] ?? 'show';
const c = ctx();
try {
if (sub === 'start') {
if (!flags.objective) {
err('missing --objective');
return 2;
}
const attrs = parseAttrs(flags.attrs);
const runId = startRun(c, {
objective: flags.objective,
spec: flags.spec ?? null,
host: flags.host ?? detectHost(),
strategy: flags.strategy ?? detectStrategy(),
attrs,
});
const p = paths(c.config, c.cwd);
if (bool(flags.json)) out(JSON.stringify({ runId, dir: p.run(runId) }));
else out(runId);
return 0;
}
if (sub === 'event') {
if (!flags.type) {
err('missing --type');
return 2;
}
const runId = requireRunId(c, flags);
const event = appendEvent(c, runId, eventFromFlags(flags));
if (bool(flags.json)) out(JSON.stringify(event));
else out(`ok ${event.spanId}`);
return 0;
}
if (sub === 'end') {
const runId = requireRunId(c, flags);
const updated = endRun(c, runId, {
status: flags.status ?? 'ok',
summary: flags.summary ?? '',
});
if (bool(flags.json)) out(JSON.stringify(updated));
else out(`ok ${runId} ${updated.status} tokens=${updated.totals?.totalTokens ?? 0}`);
return 0;
}
if (sub === 'list') {
const runs = listRuns(c);
if (bool(flags.json)) {
out(JSON.stringify(runs));
} else {
out(
table(
runs.map((r) => ({
run: r.runId,
status: r.status ?? '—',
started: r.startedAt ?? '—',
tokens: r.totals?.totalTokens ?? '—',
objective: r.objective ?? '—',
})),
['run', 'status', 'started', 'tokens', 'objective'],
) || 'no runs',
);
}
return 0;
}
if (sub === 'show') {
const runId = requireRunId(c, flags, positional[1]);
const h = header(c, runId);
if (!h) {
err(`unknown run: ${runId}`);
return 1;
}
const events = readEvents(c, runId);
const summary = summarize(events);
if (bool(flags.json)) {
out(JSON.stringify({ run: h, summary }, null, 2));
} else {
out(
table(
[
{ key: 'run', value: h.runId },
{ key: 'status', value: h.status ?? 'running' },
{ key: 'objective', value: h.objective ?? '—' },
{ key: 'tokens', value: summary.totals.totalTokens },
{ key: 'events', value: events.length },
],
['key', 'value'],
),
);
}
return 0;
}
err(`unknown run subcommand: ${sub}`);
return 2;
} catch (error) {
err(error.message ?? String(error));
return 1;
}
}
+153
View File
@@ -0,0 +1,153 @@
function ms(n) {
if (!Number.isFinite(n) || n < 0) return '0s';
if (n < 1000) return `${Math.round(n)}ms`;
const s = Math.round(n / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const r = s % 60;
return r ? `${m}m${r}s` : `${m}m`;
}
function num(n) {
return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : '0';
}
function tokens(event) {
return (
Number(event?.['gen_ai.usage.input_tokens'] ?? 0) +
Number(event?.['gen_ai.usage.output_tokens'] ?? 0)
);
}
function mdTable(rows, headers) {
if (!rows.length) return '_none_';
const esc = (v) => String(v ?? '').replaceAll('|', '\\|').replace(/\s+/g, ' ').trim();
return [
`| ${headers.join(' | ')} |`,
`| ${headers.map(() => '---').join(' | ')} |`,
...rows.map((row) => `| ${headers.map((h) => esc(row[h])).join(' | ')} |`),
].join('\n');
}
function statusOf(runHeader, events) {
const lastRunEnd = events.findLast?.((e) => e.type === 'run.end') ?? [...events].reverse().find((e) => e.type === 'run.end');
if (lastRunEnd) return lastRunEnd.status ?? 'done';
return runHeader?.status ?? 'running';
}
function elapsedMs(runHeader, events) {
const start = Date.parse(runHeader?.startedAt ?? events[0]?.ts ?? new Date().toISOString());
const endEvent = events.findLast?.((e) => e.type === 'run.end') ?? [...events].reverse().find((e) => e.type === 'run.end');
const end = Date.parse(runHeader?.endedAt ?? endEvent?.ts ?? new Date().toISOString());
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
}
function currentPhase(events) {
const active = [];
for (const event of events) {
if (event.type === 'phase.start') active.push(event.name ?? 'phase');
if (event.type === 'phase.end') {
const name = event.name ?? 'phase';
const idx = active.lastIndexOf(name);
if (idx !== -1) active.splice(idx, 1);
}
}
return active.at(-1) ?? events.findLast?.((e) => e.type?.startsWith('phase.'))?.name ?? '—';
}
function lanes(events) {
const out = new Map();
const ensure = (id) => {
const key = id || 'main';
if (!out.has(key)) {
out.set(key, {
id: key,
kind: '—',
status: 'pending',
agent: '—',
ralph: '—',
startedAt: null,
endedAt: null,
tokens: 0,
lastNote: '',
});
}
return out.get(key);
};
for (const event of events) {
const id = event?.attrs?.laneId ?? event?.attrs?.lane ?? (event.type?.startsWith('lane.') ? event.name : null);
if (!id) continue;
const lane = ensure(id);
lane.kind = event.attrs?.kind ?? lane.kind;
lane.tokens += tokens(event);
if (event.type === 'lane.start') {
lane.status = event.status ?? 'running';
lane.startedAt = event.ts ?? lane.startedAt;
lane.kind = event.attrs?.kind ?? event.name ?? lane.kind;
} else if (event.type === 'lane.end') {
lane.status = event.status ?? lane.status;
lane.endedAt = event.ts ?? lane.endedAt;
} else if (event.status === 'fail' || event.status === 'blocked') {
lane.status = event.status;
}
if (event.type === 'agent.start') lane.agent = event.attrs?.agent ?? event.name ?? lane.agent;
if (event.type === 'agent.end' && lane.agent === (event.attrs?.agent ?? event.name)) lane.agent = '—';
if (event.attrs?.agent) lane.agent = event.attrs.agent;
if (event.type === 'ralph.iteration') lane.ralph = event.attrs?.iteration ?? event.name ?? lane.ralph;
if (event.type === 'note') lane.lastNote = event.attrs?.message ?? event.name ?? lane.lastNote;
}
const now = Date.now();
return [...out.values()].map((lane) => {
const start = Date.parse(lane.startedAt ?? new Date(now).toISOString());
const end = Date.parse(lane.endedAt ?? new Date(now).toISOString());
return {
lane: lane.id,
kind: lane.kind,
status: lane.status === 'ok' && !lane.endedAt ? 'running' : lane.status,
agent: lane.agent,
ralph: lane.ralph,
elapsed: ms(Number.isFinite(start) && Number.isFinite(end) ? end - start : 0),
tokens: num(lane.tokens),
note: lane.lastNote || '—',
};
});
}
function recent(events) {
return events.slice(-10).map((event) => {
const at = event.ts ? event.ts.slice(11, 19) : '—';
const lane = event.attrs?.laneId ? ` lane=${event.attrs.laneId}` : '';
const model = event['gen_ai.request.model'] ? ` model=${event['gen_ai.request.model']}` : '';
const tok = tokens(event) ? ` tok=${tokens(event)}` : '';
const dur = Number.isFinite(Number(event.durationMs)) ? ` dur=${ms(Number(event.durationMs))}` : '';
const status = event.status ? ` ${event.status}` : '';
return `- ${at} ${event.type}${event.name ? `:${event.name}` : ''}${status}${lane}${model}${tok}${dur}`;
});
}
export function renderBoard(runHeader, events) {
const elapsed = elapsedMs(runHeader, events);
const totalTokens = events.reduce((sum, event) => sum + tokens(event), 0);
const burn = elapsed > 0 ? totalTokens / (elapsed / 60000) : 0;
const host = [runHeader?.host, runHeader?.strategy].filter(Boolean).join(' / ') || '—';
return [
`# Run board: ${runHeader?.runId ?? 'unknown'}`,
'',
`**Objective:** ${runHeader?.objective ?? '—'}`,
`**Status:** ${statusOf(runHeader, events)} · **Host/strategy:** ${host} · **Elapsed:** ${ms(elapsed)} · **Phase:** ${currentPhase(events)}`,
'',
`## 🔥 Burn rate: ${num(burn)} tokens/min · ${num(totalTokens)} total tokens`,
'',
'## Lanes',
mdTable(lanes(events), ['lane', 'kind', 'status', 'agent', 'ralph', 'elapsed', 'tokens', 'note']),
'',
'## Recent events',
recent(events).join('\n') || '_none_',
'',
].join('\n');
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Canonical harness configuration: defaults, load, save, validation.
* Every other module reads config through here. Nothing else parses the file.
*/
import fs from 'node:fs';
import path from 'node:path';
export const CONFIG_VERSION = 1;
export const CONFIG_FILENAME = 'harness.config.json';
export const MODEL_TIERS = ['cheap', 'mid', 'strong'];
export const ROLE_TIERS = {
conductor: 'strong',
interrogator: 'strong',
scout: 'cheap',
architect: 'strong',
splitter: 'mid',
builder: 'mid',
verifier: 'cheap',
reviewer: 'strong',
integrator: 'strong',
scribe: 'cheap',
};
export const RALPH_EXIT_CRITERIA = [
'verify-commands-exit-zero',
'acceptance-criteria-checked',
'structural-gate-passes',
'reviewer-approves',
'no-out-of-scope-files',
];
export const MEMORY_SHARDS = [
'seed',
'failures',
'corrections',
'insights',
'conventions',
'quirks',
];
export function defaultConfig() {
return {
version: CONFIG_VERSION,
memory: {
root: '.agents',
committed: ['architecture.md', 'conventions.md', 'memory', 'specs', 'runs/*/journal.md'],
ignored: ['.cache', 'runs/*/events.ndjson', 'runs/*/board.md'],
tokenBudget: 8000,
compactAtPercent: 85,
},
artifacts: {
architecture: true,
conventions: true,
adr: true,
journal: true,
spec: true,
},
index: {
include: ['**/*'],
exclude: [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
'**/target/**',
'**/vendor/**',
'**/*.min.*',
'**/.agents/**',
],
depth: 3,
budget: 1200,
},
models: {
tiers: {
cheap: 'claude-haiku-4.5',
mid: 'claude-sonnet-5',
strong: 'claude-opus-5',
},
roles: { ...ROLE_TIERS },
},
ralph: {
maxIterations: 4,
exitCriteria: [...RALPH_EXIT_CRITERIA],
onMaxIterations: 'escalate',
},
verify: {
commands: [],
autodetected: false,
},
concurrency: {
maxWriteLanes: 4,
readOnlyFanOut: 6,
},
isolation: {
backend: 'worktree',
},
context7: {
required: true,
envVar: 'CONTEXT7_API_KEY',
},
};
}
/** Walk up from `cwd` looking for a directory containing harness.config.json. */
export function findConfigFile(cwd = process.cwd()) {
let dir = path.resolve(cwd);
for (;;) {
for (const candidate of ['.agents', '.']) {
const file = path.join(dir, candidate, CONFIG_FILENAME);
if (fs.existsSync(file)) return file;
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
export function loadConfig(cwd = process.cwd()) {
const file = findConfigFile(cwd);
if (!file) return null;
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
return { file, config: mergeDefaults(raw) };
}
/** Deep-merge user config over defaults so partial configs stay valid. */
export function mergeDefaults(user) {
const merge = (base, over) => {
if (over === undefined || over === null) return base;
if (Array.isArray(base) || Array.isArray(over)) return over;
if (typeof base !== 'object' || typeof over !== 'object') return over;
const out = { ...base };
for (const key of Object.keys(over)) out[key] = merge(base[key], over[key]);
return out;
};
return merge(defaultConfig(), user);
}
export function saveConfig(file, config) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
return file;
}
/** Returns an array of human-readable problems; empty array means valid. */
export function validateConfig(config) {
const problems = [];
if (config.version !== CONFIG_VERSION) {
problems.push(`version must be ${CONFIG_VERSION}, got ${config.version}`);
}
if (!config.memory?.root) problems.push('memory.root is required');
if (!(config.memory?.tokenBudget > 0)) problems.push('memory.tokenBudget must be > 0');
if (!(config.index?.budget > 0)) problems.push('index.budget must be > 0');
if (!(config.ralph?.maxIterations >= 1)) problems.push('ralph.maxIterations must be >= 1');
for (const criterion of config.ralph?.exitCriteria ?? []) {
if (!RALPH_EXIT_CRITERIA.includes(criterion)) {
problems.push(`unknown ralph.exitCriteria entry: ${criterion}`);
}
}
for (const [role, tier] of Object.entries(config.models?.roles ?? {})) {
if (!MODEL_TIERS.includes(tier)) {
problems.push(`models.roles.${role} must be one of ${MODEL_TIERS.join(', ')}`);
}
}
if (!['worktree', 'devcontainer', 'inplace'].includes(config.isolation?.backend)) {
problems.push('isolation.backend must be worktree | devcontainer | inplace');
}
if (!(config.concurrency?.maxWriteLanes >= 1)) {
problems.push('concurrency.maxWriteLanes must be >= 1');
}
return problems;
}
/** Resolve the concrete model id for a role, honouring overrides. */
export function modelForRole(config, role) {
const tier = config.models?.roles?.[role] ?? ROLE_TIERS[role] ?? 'mid';
return config.models?.tiers?.[tier] ?? null;
}
+310
View File
@@ -0,0 +1,310 @@
import path from 'node:path';
import { ensureDir, readIfExists, writeFile } from './paths.mjs';
const MANIFEST_VERSION = 1;
const KNOWN_GLOBALS = new Set([
'Array',
'Boolean',
'Buffer',
'Date',
'Error',
'JSON',
'Map',
'Math',
'Number',
'Object',
'Promise',
'RegExp',
'Set',
'String',
'Symbol',
'URL',
'URLSearchParams',
'WeakMap',
'WeakSet',
'console',
'decodeURIComponent',
'encodeURIComponent',
'fetch',
'parseFloat',
'parseInt',
'process',
'require',
'setInterval',
'setTimeout',
'clearInterval',
'clearTimeout',
'describe',
'it',
'test',
'expect',
'before',
'after',
'beforeEach',
'afterEach',
'print',
'len',
'range',
'open',
'str',
'int',
'float',
'bool',
'dict',
'list',
'set',
'tuple',
'self',
'cls',
'main',
'printf',
'fprintf',
'sprintf',
'malloc',
'free',
'true',
'false',
'nil',
'null',
'undefined',
'this',
'super',
]);
export function reconcile(ctx, { index, fixManifest = false } = {}) {
const files = Object.values(index?.files ?? {}).filter((file) => !isDataLanguage(file.lang));
const manifestFile = path.join(ctx.paths.cache, 'canonical.json');
const previous = loadManifest(manifestFile);
const manifest = buildManifest(index, previous, fixManifest);
const violations = [];
const byName = definitionsByName(files);
const canonicalNames = Object.keys(manifest.canonical ?? {});
for (const [name, defs] of byName) {
const homes = unique(defs.map((def) => def.file));
if (homes.length > 1) {
violations.push({
severity: 'error',
rule: 'duplicate-definition',
symbol: name,
files: homes,
message: `${name} defined in ${homes.length} files`,
});
}
}
for (const file of files) {
const projectRefs = refsAcross(files, file.path);
for (const symbol of file.symbols ?? []) {
if (!symbol.exported) continue;
const sameFileRefs = new Set((file.refs ?? []).filter((ref) => ref.line !== symbol.line).map((ref) => ref.name));
if (!projectRefs.has(symbol.name) && !sameFileRefs.has(symbol.name)) {
violations.push({
severity: 'warn',
rule: 'orphan-export',
symbol: symbol.name,
files: [file.path],
message: `${symbol.name} exported but never referenced`,
});
}
}
}
for (const file of files) {
const local = new Set((file.symbols ?? []).map((symbol) => symbol.name));
const imports = new Set(file.imports ?? []);
for (const ref of file.refs ?? []) {
if (ref.kind !== 'call') continue;
if (local.has(ref.name) || imports.has(ref.name) || KNOWN_GLOBALS.has(ref.name)) continue;
violations.push({
severity: 'error',
rule: 'unresolved-call',
symbol: ref.name,
files: [`${file.path}:${ref.line}`],
message: `${ref.name} called without local def, import, or known global`,
});
}
}
for (const [name, defs] of byName) {
const byArity = new Map();
for (const def of defs) {
if (def.symbol.arity < 0) continue;
const key = String(def.symbol.arity);
if (!byArity.has(key)) byArity.set(key, []);
byArity.get(key).push(def);
}
for (const [arity, same] of byArity) {
const homes = unique(same.map((def) => def.file));
if (homes.length > 1) {
violations.push({
severity: 'warn',
rule: 'near-duplicate',
symbol: name,
files: homes,
message: `${name}/${arity} appears in ${homes.length} files`,
});
}
}
}
const currentNames = [...byName.keys()];
for (const name of currentNames) {
if (manifest.canonical?.[name]) continue;
const twin = canonicalNames.find((canonical) => editDistanceOne(name, canonical));
if (!twin) continue;
violations.push({
severity: 'warn',
rule: 'near-duplicate',
symbol: name,
files: unique((byName.get(name) ?? []).map((def) => def.file)),
message: `${name} is one edit from canonical ${twin}`,
});
}
for (const file of files) {
for (const ref of file.refs ?? []) {
if (ref.kind !== 'call') continue;
const twin = canonicalNames.find((canonical) => editDistanceOne(ref.name, canonical));
if (!twin) continue;
const home = manifest.canonical[twin]?.home;
if (!home || home === file.path) continue;
violations.push({
severity: 'warn',
rule: 'mis-wiring',
symbol: ref.name,
files: [`${file.path}:${ref.line}`, home],
message: `${file.path} calls ${ref.name}; canonical near-twin is ${twin} in ${home}`,
});
}
}
const deduped = dedupe(violations);
ensureDir(ctx.paths.cache);
writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
return {
violations: deduped,
stats: {
files: files.length,
symbols: [...byName.values()].reduce((sum, defs) => sum + defs.length, 0),
errors: deduped.filter((v) => v.severity === 'error').length,
warnings: deduped.filter((v) => v.severity === 'warn').length,
manifest: manifestFile,
canonical: Object.keys(manifest.canonical).length,
},
};
}
export function brief(result) {
const byRule = {};
for (const violation of result.violations ?? []) byRule[violation.rule] = (byRule[violation.rule] ?? 0) + 1;
const lines = [
'# Structural gate',
`errors ${result.stats?.errors ?? 0}, warnings ${result.stats?.warnings ?? 0}, files ${result.stats?.files ?? 0}, symbols ${result.stats?.symbols ?? 0}`,
];
if (Object.keys(byRule).length) {
lines.push(`rules: ${Object.entries(byRule).map(([k, v]) => `${k}=${v}`).join(', ')}`);
}
const errors = (result.violations ?? []).filter((v) => v.severity === 'error').slice(0, 5);
const warnings = (result.violations ?? []).filter((v) => v.severity === 'warn').slice(0, 3);
for (const violation of [...errors, ...warnings]) {
lines.push(`- ${violation.severity} ${violation.rule} ${violation.symbol}: ${violation.files.slice(0, 3).join(', ')}`);
}
if (!errors.length && !warnings.length) lines.push('ok: no structural violations');
return `${lines.join('\n')}\n`;
}
function buildManifest(index, previous, fixManifest) {
const files = Object.values(index?.files ?? {}).filter((file) => !isDataLanguage(file.lang));
const byName = definitionsByName(files);
const canonical = {};
const ranks = index?.ranks?.files ?? {};
for (const [name, defs] of byName) {
const current = defs.sort((a, b) => (ranks[b.file] ?? 0) - (ranks[a.file] ?? 0) || a.file.localeCompare(b.file));
const pinned = !fixManifest ? previous.canonical?.[name] : null;
const pinnedDef = pinned ? current.find((def) => def.file === pinned.home) : null;
const chosen = pinnedDef ?? current[0];
canonical[name] = { home: chosen.file, sig: chosen.symbol.signature };
}
return {
version: MANIFEST_VERSION,
updatedAt: new Date().toISOString(),
canonical,
entrypointGlobs: previous.entrypointGlobs?.length
? previous.entrypointGlobs
: ['src/cli.mjs', 'src/index.*', 'index.*', 'main.*'],
};
}
function definitionsByName(files) {
const byName = new Map();
for (const file of files) {
for (const symbol of file.symbols ?? []) {
if (!byName.has(symbol.name)) byName.set(symbol.name, []);
byName.get(symbol.name).push({ file: file.path, symbol });
}
}
return byName;
}
function isDataLanguage(lang) {
return ['json', 'yaml', 'html', 'css'].includes(lang);
}
function refsAcross(files, exceptPath) {
const refs = new Set();
for (const file of files) {
if (file.path === exceptPath) continue;
for (const ref of file.refs ?? []) refs.add(ref.name);
}
return refs;
}
function loadManifest(file) {
try {
const parsed = JSON.parse(readIfExists(file) ?? 'null');
if (parsed?.version === MANIFEST_VERSION) return parsed;
} catch {
// ignore corrupt manifest; rebuild from index
}
return { version: MANIFEST_VERSION, canonical: {}, entrypointGlobs: [] };
}
function dedupe(violations) {
const seen = new Set();
return violations.filter((violation) => {
const key = `${violation.severity}:${violation.rule}:${violation.symbol}:${violation.files.join('|')}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function unique(values) {
return [...new Set(values)].sort();
}
function editDistanceOne(a, b) {
if (a === b) return false;
if (Math.abs(a.length - b.length) > 1) return false;
let i = 0;
let j = 0;
let edits = 0;
while (i < a.length || j < b.length) {
if (a[i] === b[j]) {
i += 1;
j += 1;
continue;
}
edits += 1;
if (edits > 1) return false;
if (a.length > b.length) i += 1;
else if (b.length > a.length) j += 1;
else {
i += 1;
j += 1;
}
}
return edits === 1;
}
+168
View File
@@ -0,0 +1,168 @@
/** Host detection for parallel execution strategy. */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { defaultConfig } from './config.mjs';
function tryExec(cmd, args, opts = {}) {
try {
return execFileSync(cmd, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
...opts,
}).trim();
} catch {
return null;
}
}
function envKeys(prefixes) {
return Object.keys(process.env)
.filter((key) => prefixes.some((prefix) => key === prefix || key.startsWith(prefix)))
.sort();
}
function bool(value) {
return Boolean(value);
}
function clampConcurrency(config, capabilities, host) {
const configuredWrite = Number(config?.concurrency?.maxWriteLanes ?? defaultConfig().concurrency.maxWriteLanes);
const configuredRead = Number(config?.concurrency?.readOnlyFanOut ?? defaultConfig().concurrency.readOnlyFanOut);
if (host === 'copilot-cli' && capabilities.fleet && capabilities.worktrees) {
return { maxWriteLanes: Math.max(1, configuredWrite), readOnlyFanOut: Math.max(1, configuredRead) };
}
if (host === 'headless' && capabilities.headlessSpawn && capabilities.worktrees) {
return { maxWriteLanes: Math.max(1, configuredWrite), readOnlyFanOut: Math.max(1, configuredRead) };
}
return { maxWriteLanes: 1, readOnlyFanOut: 1 };
}
export function detectHost(config = defaultConfig(), cwd = process.cwd()) {
const copilotHome = path.join(os.homedir(), '.copilot');
const copilotVersion = tryExec('copilot', ['--version']);
const gitVersion = tryExec('git', ['--version']);
const gitWorktreeList = gitVersion ? tryExec('git', ['worktree', 'list'], { cwd }) : null;
const copilotEnv = envKeys(['COPILOT_', 'GITHUB_COPILOT_']);
const vscodeEnv = envKeys(['VSCODE_', 'VSCODE']);
const claudeEnv = envKeys(['CLAUDECODE', 'CLAUDE_']);
const ci = bool(process.env.CI || process.env.GITHUB_ACTIONS);
const termProgram = process.env.TERM_PROGRAM ?? null;
const stdinTTY = Boolean(process.stdin.isTTY);
const stdoutTTY = Boolean(process.stdout.isTTY);
const vscodeObserved = termProgram === 'vscode' || vscodeEnv.length > 0 || Boolean(process.env.VSCODE_PID);
const copilotCliObserved = copilotEnv.length > 0 && Boolean(copilotVersion);
const headlessObserved = ci || !stdinTTY || !stdoutTTY;
let host = 'unknown';
let confidence = 0.2;
if (copilotCliObserved && !vscodeObserved) {
host = 'copilot-cli';
confidence = 0.9;
} else if (vscodeObserved) {
host = 'vscode';
confidence = 0.85;
} else if (headlessObserved) {
host = 'headless';
confidence = ci ? 0.85 : 0.65;
} else if (fs.existsSync(copilotHome) && copilotVersion) {
host = 'unknown';
confidence = 0.45;
}
const signals = {
env: {
copilot: copilotEnv,
vscode: vscodeEnv,
claude: claudeEnv,
ci,
githubActions: Boolean(process.env.GITHUB_ACTIONS),
termProgram,
},
tty: { stdin: stdinTTY, stdout: stdoutTTY },
copilot: {
home: copilotHome,
homeExists: fs.existsSync(copilotHome),
binary: Boolean(copilotVersion),
version: copilotVersion,
},
git: {
binary: Boolean(gitVersion),
version: gitVersion,
worktree: gitWorktreeList !== null,
},
};
const capabilities = {
parallelSubagents: host === 'copilot-cli',
fleet: host === 'copilot-cli',
headlessSpawn: Boolean(copilotVersion) && (host === 'copilot-cli' || host === 'headless'),
worktrees: gitWorktreeList !== null,
interactivePrompt: stdinTTY && stdoutTTY && !ci,
perAgentModel: host === 'copilot-cli',
};
if (host === 'headless') {
capabilities.parallelSubagents = false;
capabilities.fleet = false;
capabilities.perAgentModel = false;
}
if (host === 'vscode' || host === 'unknown') {
capabilities.parallelSubagents = false;
capabilities.fleet = false;
capabilities.headlessSpawn = false;
capabilities.perAgentModel = false;
}
const ceilings = clampConcurrency(config, capabilities, host);
return { host, confidence, signals, capabilities, ...ceilings };
}
export function strategy(config = defaultConfig(), detection = detectHost(config)) {
const lines = [];
lines.push('HOST STRATEGY');
lines.push(`host=${detection.host} confidence=${detection.confidence}`);
lines.push(`write-lanes=${detection.maxWriteLanes} read-only-fanout=${detection.readOnlyFanOut}`);
if (!detection.capabilities.worktrees && config?.isolation?.backend === 'worktree') {
lines.push('Do not create parallel write lanes: git worktree unavailable.');
lines.push('Run these lanes SEQUENTIALLY in current checkout or change isolation backend.');
return lines.join('\n');
}
if (detection.host === 'copilot-cli' && detection.capabilities.fleet) {
lines.push('Use Copilot CLI fan-out.');
lines.push('Use /fleet for read-only scouting and independent write lanes.');
lines.push('Use task subagents with explicit per-agent model overrides when role requires.');
lines.push(`Start at most ${detection.maxWriteLanes} write lanes at once. Lease scopes before launch.`);
lines.push(`Start at most ${detection.readOnlyFanOut} read-only scouts at once.`);
lines.push('Merge lanes SEQUENTIALLY after verification; never merge in parallel.');
return lines.join('\n');
}
if (detection.host === 'headless' && detection.capabilities.headlessSpawn) {
lines.push('Use headless copilot -p only for non-interactive lanes.');
lines.push(`Start at most ${detection.maxWriteLanes} write lanes at once when each has a worktree.`);
lines.push(`Start at most ${detection.readOnlyFanOut} read-only jobs at once.`);
lines.push('No user prompts. Fail fast on missing inputs.');
lines.push('Merge lanes SEQUENTIALLY after verification; never merge in parallel.');
return lines.join('\n');
}
if (detection.host === 'vscode') {
lines.push('Use VS Code agent frontmatter only for delegated subagent instructions.');
lines.push('Do not assume fleet-width parallelism.');
lines.push('Run these lanes SEQUENTIALLY.');
lines.push('Use one write lane at a time; verify and merge before starting next write lane.');
return lines.join('\n');
}
lines.push('Host capability unknown or insufficient.');
lines.push('Run these lanes SEQUENTIALLY.');
lines.push('Use one write lane at a time; verify and merge before starting next write lane.');
lines.push('Do not spawn headless agents unless copilot binary and non-interactive contract are observed.');
return lines.join('\n');
}
+545
View File
@@ -0,0 +1,545 @@
/** Git worktree lane lifecycle and conservative file-scope leases. */
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { ensureDir } from './paths.mjs';
const TERMINAL = new Set(['merged', 'dropped', 'failed']);
const NON_TERMINAL = new Set(['pending', 'active', 'verifying', 'blocked']);
const STATUSES = new Set([...TERMINAL, ...NON_TERMINAL]);
const KINDS = new Set(['read', 'write']);
function now() {
return new Date().toISOString();
}
function laneId(id) {
const value = String(id ?? '').trim();
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
throw new Error(`invalid lane id: ${value || '(empty)'}`);
}
return value;
}
function branchPart(value) {
return String(value ?? 'run')
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'run';
}
function normalizePath(value) {
return String(value ?? '')
.replaceAll('\\', '/')
.replace(/^\.\//, '')
.replace(/\/+/g, '/')
.replace(/\/$/, '');
}
function normalizeGlob(value) {
const glob = normalizePath(value).trim();
if (!glob) throw new Error('empty scope glob');
if (path.isAbsolute(glob) || glob.includes('..')) {
throw new Error(`scope must be repo-relative: ${value}`);
}
return glob;
}
function splitCsv(value) {
if (Array.isArray(value)) return value.flatMap(splitCsv);
if (value === undefined || value === null || value === '') return [];
return String(value)
.split(',')
.map((part) => part.trim())
.filter(Boolean);
}
function git(cwd, args, opts = {}) {
try {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
...opts,
}).trim();
} catch (error) {
const detail = String(error.stderr || error.stdout || error.message || '').trim();
throw new Error(`git ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
}
}
function tryGit(cwd, args) {
try {
return git(cwd, args);
} catch {
return null;
}
}
function assertGitRepo(cwd) {
if (tryGit(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') {
throw new Error(`not a git repository: ${cwd}`);
}
}
function assertWorktreeSupport(cwd) {
assertGitRepo(cwd);
if (tryGit(cwd, ['worktree', 'list']) === null) {
throw new Error('git worktree unsupported or unavailable');
}
}
function currentBranchOrHead(cwd) {
return tryGit(cwd, ['symbolic-ref', '--short', 'HEAD']) || git(cwd, ['rev-parse', 'HEAD']);
}
function unique(items) {
return [...new Set(items.filter(Boolean))].sort();
}
function registryPath(ctx) {
if (!ctx?.paths?.lanes) throw new Error('ctx.paths.lanes is required');
return ctx.paths.lanes;
}
function registryLockPath(ctx) {
return `${registryPath(ctx)}.lock`;
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function withRegistryLock(ctx, fn) {
ensureDir(path.dirname(registryPath(ctx)));
const lock = registryLockPath(ctx);
const deadline = Date.now() + 10000;
let fd;
for (;;) {
try {
fd = fs.openSync(lock, 'wx');
fs.writeFileSync(fd, `${process.pid}\n${now()}\n`);
break;
} catch (error) {
if (error?.code !== 'EEXIST' || Date.now() > deadline) {
throw new Error(`lane registry locked: ${lock}`);
}
sleep(50);
}
}
try {
return fn();
} finally {
try {
fs.closeSync(fd);
} catch {}
try {
fs.unlinkSync(lock);
} catch {}
}
}
export function loadRegistry(ctx) {
const file = registryPath(ctx);
try {
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
if (Array.isArray(raw)) return { version: 1, lanes: raw };
return { version: 1, lanes: Array.isArray(raw.lanes) ? raw.lanes : [] };
} catch (error) {
if (error?.code === 'ENOENT') return { version: 1, lanes: [] };
throw new Error(`cannot read lane registry ${file}: ${error.message}`);
}
}
export function saveRegistry(ctx, registry) {
const file = registryPath(ctx);
ensureDir(path.dirname(file));
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
const data = `${JSON.stringify({ version: 1, lanes: registry.lanes ?? [] }, null, 2)}\n`;
try {
fs.writeFileSync(tmp, data, { mode: 0o644 });
fs.renameSync(tmp, file);
} catch (error) {
try {
fs.unlinkSync(tmp);
} catch {}
throw error;
}
return registry;
}
function segmentRegex(segment) {
let out = '^';
for (const ch of segment) {
if (ch === '*') out += '[^/]*';
else if (ch === '?') out += '[^/]';
else out += ch.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
}
return new RegExp(`${out}$`);
}
function segmentMatches(pattern, literal) {
return segmentRegex(pattern).test(literal);
}
function hasWildcard(segment) {
return segment.includes('*') || segment.includes('?');
}
function segmentIntersects(a, b) {
if (!hasWildcard(a) && !hasWildcard(b)) return a === b;
if (!hasWildcard(a)) return segmentMatches(b, a);
if (!hasWildcard(b)) return segmentMatches(a, b);
return true;
}
function globSegments(glob) {
return normalizeGlob(glob).split('/');
}
export function globsIntersect(aGlob, bGlob) {
const a = globSegments(aGlob);
const b = globSegments(bGlob);
const memo = new Map();
function rec(i, j) {
const key = `${i}:${j}`;
if (memo.has(key)) return memo.get(key);
let result;
if (i === a.length && j === b.length) result = true;
else if (i === a.length) result = b.slice(j).every((seg) => seg === '**');
else if (j === b.length) result = a.slice(i).every((seg) => seg === '**');
else if (a[i] === '**' && b[j] === '**') {
result = rec(i + 1, j) || rec(i, j + 1) || rec(i + 1, j + 1);
} else if (a[i] === '**') {
result = rec(i + 1, j) || rec(i, j + 1);
} else if (b[j] === '**') {
result = rec(i, j + 1) || rec(i + 1, j);
} else {
result = segmentIntersects(a[i], b[j]) && rec(i + 1, j + 1);
}
memo.set(key, result);
return result;
}
try {
return rec(0, 0);
} catch {
return true;
}
}
function globRegex(glob) {
const parts = globSegments(glob);
let re = '^';
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i];
const last = i === parts.length - 1;
if (part === '**') {
re += last ? '(?:.*)?' : '(?:[^/]+/)*';
continue;
}
for (const ch of part) {
if (ch === '*') re += '[^/]*';
else if (ch === '?') re += '[^/]';
else re += ch.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
}
if (!last) re += '/';
}
re += '$';
return new RegExp(re);
}
function globMatches(glob, file) {
return globRegex(glob).test(normalizePath(file));
}
function inScope(file, scope) {
return scope.some((glob) => globMatches(glob, file));
}
function activeWrites(registry) {
return registry.lanes.filter((lane) => lane.kind === 'write' && !TERMINAL.has(lane.status));
}
function findLane(registry, id) {
return registry.lanes.find((lane) => lane.id === id);
}
function addNote(lane, note) {
if (!note) return;
lane.notes = Array.isArray(lane.notes) ? lane.notes : [];
lane.notes.push({ at: now(), text: String(note) });
}
function warn(ctx, message) {
if (Array.isArray(ctx.warnings)) ctx.warnings.push(message);
}
const backends = {
worktree: {
create(ctx, lane) {
assertWorktreeSupport(ctx.cwd);
ensureDir(ctx.paths.worktrees);
git(ctx.cwd, ['worktree', 'add', '-b', lane.branch, lane.worktree, lane.baseRef]);
},
remove(ctx, lane, { force = false } = {}) {
if (lane.worktree && fs.existsSync(lane.worktree)) {
git(ctx.cwd, ['worktree', 'remove', ...(force ? ['--force'] : []), lane.worktree]);
}
},
describe(lane) {
return lane.worktree;
},
},
inplace: {
create() {},
remove() {},
describe(lane) {
return lane.worktree || 'inplace';
},
},
devcontainer: {
create() {
throw new Error('isolation backend devcontainer is not implemented in v1');
},
remove() {},
describe() {
return 'devcontainer:not-implemented';
},
},
};
function backend(ctx) {
const name = ctx.config?.isolation?.backend ?? 'worktree';
const impl = backends[name];
if (!impl) throw new Error(`unknown isolation backend: ${name}`);
return impl;
}
export function createLane(ctx, input) {
return withRegistryLock(ctx, () => {
const registry = loadRegistry(ctx);
const id = laneId(input.id);
if (findLane(registry, id)) throw new Error(`lane already exists: ${id}`);
const kind = input.kind ?? 'write';
if (!KINDS.has(kind)) throw new Error(`invalid lane kind: ${kind}`);
const scope = splitCsv(input.scope).map(normalizeGlob);
if (kind === 'write' && scope.length === 0) throw new Error('write lane requires at least one scope glob');
if (kind === 'write') {
for (const other of activeWrites(registry)) {
for (const mine of scope) {
for (const theirs of other.scope ?? []) {
if (globsIntersect(mine, theirs)) {
throw new Error(`write scope overlaps lane ${other.id}: ${mine} intersects ${theirs}`);
}
}
}
}
const ceiling = Number(ctx.config?.concurrency?.maxWriteLanes ?? 4);
if (activeWrites(registry).length >= ceiling) {
const message = `write lane ceiling exceeded: ${activeWrites(registry).length + 1}/${ceiling}`;
if (ctx.strict) throw new Error(message);
warn(ctx, message);
}
}
assertGitRepo(ctx.cwd);
const runId = branchPart(input.runId ?? 'manual');
const baseRef = input.baseRef ?? currentBranchOrHead(ctx.cwd);
const createdAt = now();
const lane = {
id,
title: String(input.title ?? id),
kind,
scope,
status: 'active',
branch: kind === 'write' ? `lh/${runId}/${branchPart(id)}` : null,
worktree: kind === 'write' ? path.join(ctx.paths.worktrees, id) : ctx.paths.root,
baseRef,
runId,
dependsOn: splitCsv(input.dependsOn),
acceptance: splitCsv(input.acceptance),
attempts: 0,
createdAt,
updatedAt: createdAt,
notes: [],
};
if (kind === 'write') {
backend(ctx).create(ctx, lane);
addNote(lane, `created with ${ctx.config?.isolation?.backend ?? 'worktree'} backend`);
} else {
addNote(lane, 'created read lane in main checkout');
}
registry.lanes.push(lane);
saveRegistry(ctx, registry);
return lane;
});
}
export function listLanes(ctx, { status, runId } = {}) {
return loadRegistry(ctx).lanes.filter(
(lane) => (!status || lane.status === status) && (!runId || lane.runId === runId),
);
}
function laneCwd(lane) {
return lane.worktree;
}
function splitLines(value) {
return String(value ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function changedFiles(lane) {
const cwd = laneCwd(lane);
if (!cwd || !fs.existsSync(cwd)) return [];
const committed = splitLines(tryGit(cwd, ['diff', '--name-only', `${lane.baseRef}...HEAD`]) ?? '');
const unstaged = splitLines(tryGit(cwd, ['diff', '--name-only']) ?? '');
const staged = splitLines(tryGit(cwd, ['diff', '--cached', '--name-only']) ?? '');
const untracked = splitLines(tryGit(cwd, ['ls-files', '--others', '--exclude-standard']) ?? '');
return unique([...committed, ...unstaged, ...staged, ...untracked].map(normalizePath));
}
function aheadBehind(lane) {
const cwd = laneCwd(lane);
if (!cwd || !fs.existsSync(cwd)) return { ahead: null, behind: null };
const counts = tryGit(cwd, ['rev-list', '--left-right', '--count', `${lane.baseRef}...HEAD`]);
if (!counts) return { ahead: null, behind: null };
const [behind, ahead] = counts.split(/\s+/).map((n) => Number(n));
return { ahead, behind };
}
function conflictedFiles(cwd) {
return unique(splitLines(tryGit(cwd, ['diff', '--name-only', '--diff-filter=U']) ?? '').map(normalizePath));
}
export function laneStatus(ctx, id) {
const registry = loadRegistry(ctx);
const lane = findLane(registry, id);
if (!lane) throw new Error(`unknown lane: ${id}`);
const files = changedFiles(lane);
const outOfScope = lane.kind === 'write' ? files.filter((file) => !inScope(file, lane.scope ?? [])) : [];
return {
...lane,
git: {
...aheadBehind(lane),
changedFiles: files,
outOfScope,
conflicts: fs.existsSync(laneCwd(lane) ?? '') ? conflictedFiles(laneCwd(lane)) : [],
},
scopeCheck: { ok: outOfScope.length === 0, outOfScope },
};
}
export function checkScope(ctx, id) {
const status = laneStatus(ctx, id);
return { ok: status.scopeCheck.ok, outOfScope: status.scopeCheck.outOfScope };
}
function ensureCleanBase(cwd) {
const dirty = tryGit(cwd, ['status', '--porcelain', '--untracked-files=no']);
if (dirty) throw new Error('base checkout has uncommitted changes; merge refused');
}
function markLane(ctx, id, mutate) {
return withRegistryLock(ctx, () => {
const registry = loadRegistry(ctx);
const lane = findLane(registry, id);
if (!lane) throw new Error(`unknown lane: ${id}`);
mutate(lane);
lane.updatedAt = now();
saveRegistry(ctx, registry);
return lane;
});
}
export function mergeLane(ctx, id, { strategy, abort = false } = {}) {
const registry = loadRegistry(ctx);
const lane = findLane(registry, id);
if (!lane) throw new Error(`unknown lane: ${id}`);
if (lane.kind !== 'write') throw new Error(`cannot merge read lane: ${id}`);
assertGitRepo(ctx.cwd);
if (abort) {
tryGit(ctx.cwd, ['merge', '--abort']);
const updated = markLane(ctx, id, (record) => {
record.status = 'active';
addNote(record, 'merge aborted');
});
return { ok: true, aborted: true, lane: updated, conflicts: [] };
}
ensureCleanBase(ctx.cwd);
const scope = checkScope(ctx, id);
markLane(ctx, id, (record) => {
record.status = 'verifying';
record.attempts = Number(record.attempts ?? 0) + 1;
});
try {
if (lane.baseRef) git(ctx.cwd, ['checkout', lane.baseRef]);
const args = ['merge', '--no-ff', '--no-edit'];
if (strategy) args.push('--strategy', strategy);
args.push(lane.branch);
git(ctx.cwd, args);
const updated = markLane(ctx, id, (record) => {
record.status = 'merged';
addNote(record, scope.ok ? 'merged cleanly' : `merged with out-of-scope files: ${scope.outOfScope.join(', ')}`);
});
return { ok: true, lane: updated, conflicts: [], scope };
} catch (error) {
const conflicts = conflictedFiles(ctx.cwd);
const updated = markLane(ctx, id, (record) => {
record.status = 'blocked';
addNote(record, `merge blocked${conflicts.length ? `: ${conflicts.join(', ')}` : ''}`);
});
return { ok: false, lane: updated, conflicts, error: error.message, scope };
}
}
export function dropLane(ctx, id, { force = false } = {}) {
return withRegistryLock(ctx, () => {
const registry = loadRegistry(ctx);
const lane = findLane(registry, id);
if (!lane) throw new Error(`unknown lane: ${id}`);
const cwd = laneCwd(lane);
const dirty = cwd && fs.existsSync(cwd) ? tryGit(cwd, ['status', '--porcelain']) : '';
if (dirty && !force) throw new Error(`lane ${id} has uncommitted changes; use --force`);
if (lane.kind === 'write') {
backend(ctx).remove(ctx, lane, { force });
if (lane.branch) {
const deleteArgs = ['branch', force ? '-D' : '-d', lane.branch];
tryGit(ctx.cwd, deleteArgs);
}
}
lane.status = 'dropped';
lane.updatedAt = now();
addNote(lane, force ? 'dropped forcefully' : 'dropped');
saveRegistry(ctx, registry);
return lane;
});
}
export function updateLane(ctx, id, patch) {
return markLane(ctx, id, (lane) => {
if (patch.status !== undefined) {
if (!STATUSES.has(patch.status)) throw new Error(`invalid lane status: ${patch.status}`);
lane.status = patch.status;
}
if (patch.note) addNote(lane, patch.note);
if (Array.isArray(patch.notes)) patch.notes.forEach((note) => addNote(lane, note));
for (const [key, value] of Object.entries(patch)) {
if (['id', 'createdAt', 'notes', 'note', 'status'].includes(key)) continue;
lane[key] = value;
}
});
}
+677
View File
@@ -0,0 +1,677 @@
const EXTENSIONS = new Map();
const CONTROL_WORDS = new Set([
'if',
'for',
'while',
'switch',
'catch',
'with',
'return',
'throw',
'new',
'function',
'class',
'def',
'fn',
'func',
'sizeof',
'typeof',
'await',
'yield',
'import',
'require',
]);
const REGISTRY = {
javascript: {
name: 'JavaScript',
extensions: ['.js', '.jsx', '.mjs', '.cjs'],
wasm: 'tree-sitter-javascript.wasm',
queries: {
definitions: `
(function_declaration name: (identifier) @name) @def.function
(class_declaration name: (identifier) @name) @def.class
(method_definition name: [(property_identifier) (private_property_identifier)] @name) @def.method
(lexical_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @def.function
(variable_declarator name: (identifier) @name) @def.constant
`,
exports: `
(export_statement) @export.node
`,
imports: `
(import_specifier name: (identifier) @import.name)
(import_clause (identifier) @import.name)
(namespace_import (identifier) @import.name)
(call_expression function: (identifier) @call.fn arguments: (arguments (string) @import.source))
`,
calls: `
(call_expression function: (identifier) @call.name)
`,
},
fallback: jsFallback,
},
typescript: {
name: 'TypeScript',
extensions: ['.ts', '.mts', '.cts'],
wasm: 'tree-sitter-typescript.wasm',
queries: {
definitions: `
(function_declaration name: (identifier) @name) @def.function
(class_declaration name: (type_identifier) @name) @def.class
(interface_declaration name: (type_identifier) @name) @def.interface
(type_alias_declaration name: (type_identifier) @name) @def.type
(enum_declaration name: (identifier) @name) @def.enum
(method_definition name: [(property_identifier) (private_property_identifier)] @name) @def.method
(lexical_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @def.function
(variable_declarator name: (identifier) @name) @def.constant
`,
exports: `
(export_statement) @export.node
`,
imports: `
(import_specifier name: (identifier) @import.name)
(import_clause (identifier) @import.name)
(namespace_import (identifier) @import.name)
(call_expression function: (identifier) @call.fn arguments: (arguments (string) @import.source))
`,
calls: `
(call_expression function: (identifier) @call.name)
`,
},
fallback: jsFallback,
},
tsx: {
name: 'TSX',
extensions: ['.tsx'],
wasm: 'tree-sitter-tsx.wasm',
queries: null,
fallback: jsFallback,
},
python: {
name: 'Python',
extensions: ['.py'],
wasm: 'tree-sitter-python.wasm',
queries: {
definitions: `
(function_definition name: (identifier) @name) @def.function
(class_definition name: (identifier) @name) @def.class
(assignment left: (identifier) @name) @def.constant
`,
exports: '',
imports: `
(import_statement name: (dotted_name (identifier) @import.name))
(import_from_statement name: (dotted_name) @import.source)
(aliased_import alias: (identifier) @import.name)
`,
calls: `
(call function: (identifier) @call.name)
`,
},
fallback: pythonFallback,
},
go: {
name: 'Go',
extensions: ['.go'],
wasm: 'tree-sitter-go.wasm',
queries: {
definitions: `
(function_declaration name: (identifier) @name) @def.function
(method_declaration name: (field_identifier) @name) @def.method
(type_spec name: (type_identifier) @name type: (struct_type)) @def.struct
(type_spec name: (type_identifier) @name type: (interface_type)) @def.interface
(type_spec name: (type_identifier) @name) @def.type
(const_spec name: (identifier) @name) @def.constant
`,
exports: '',
imports: `
(import_spec path: (interpreted_string_literal) @import.source)
`,
calls: `
(call_expression function: (identifier) @call.name)
`,
},
fallback: goFallback,
},
rust: {
name: 'Rust',
extensions: ['.rs'],
wasm: 'tree-sitter-rust.wasm',
queries: {
definitions: `
(function_item name: (identifier) @name) @def.function
(struct_item name: (type_identifier) @name) @def.struct
(enum_item name: (type_identifier) @name) @def.enum
(trait_item name: (type_identifier) @name) @def.interface
(type_item name: (type_identifier) @name) @def.type
(const_item name: (identifier) @name) @def.constant
`,
exports: '',
imports: `
(use_declaration argument: (_) @import.source)
`,
calls: `
(call_expression function: (identifier) @call.name)
`,
},
fallback: rustFallback,
},
java: {
name: 'Java',
extensions: ['.java'],
wasm: 'tree-sitter-java.wasm',
queries: {
definitions: `
(class_declaration name: (identifier) @name) @def.class
(interface_declaration name: (identifier) @name) @def.interface
(enum_declaration name: (identifier) @name) @def.enum
(method_declaration name: (identifier) @name) @def.method
(field_declaration declarator: (variable_declarator name: (identifier) @name)) @def.constant
`,
exports: '',
imports: `
(import_declaration (scoped_identifier) @import.source)
`,
calls: `
(method_invocation name: (identifier) @call.name)
`,
},
fallback: javaFallback,
},
csharp: {
name: 'C#',
extensions: ['.cs'],
wasm: 'tree-sitter-c_sharp.wasm',
queries: {
definitions: `
(class_declaration name: (identifier) @name) @def.class
(interface_declaration name: (identifier) @name) @def.interface
(enum_declaration name: (identifier) @name) @def.enum
(struct_declaration name: (identifier) @name) @def.struct
(method_declaration name: (identifier) @name) @def.method
(property_declaration name: (identifier) @name) @def.constant
`,
exports: '',
imports: `
(using_directive (qualified_name) @import.source)
`,
calls: `
(invocation_expression function: (identifier) @call.name)
`,
},
fallback: csharpFallback,
},
c: {
name: 'C',
extensions: ['.c', '.h'],
wasm: 'tree-sitter-c.wasm',
queries: {
definitions: `
(function_definition declarator: (function_declarator declarator: (identifier) @name)) @def.function
(struct_specifier name: (type_identifier) @name) @def.struct
(enum_specifier name: (type_identifier) @name) @def.enum
(type_definition declarator: (type_identifier) @name) @def.type
`,
exports: '',
imports: `
(preproc_include path: (_) @import.source)
`,
calls: `
(call_expression function: (identifier) @call.name)
`,
},
fallback: cFallback,
},
cpp: {
name: 'C++',
extensions: ['.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx'],
wasm: 'tree-sitter-cpp.wasm',
queries: {
definitions: `
(function_definition declarator: (function_declarator declarator: (identifier) @name)) @def.function
(class_specifier name: (type_identifier) @name) @def.class
(struct_specifier name: (type_identifier) @name) @def.struct
(enum_specifier name: (type_identifier) @name) @def.enum
(type_definition declarator: (type_identifier) @name) @def.type
`,
exports: '',
imports: `
(preproc_include path: (_) @import.source)
`,
calls: `
(call_expression function: (identifier) @call.name)
`,
},
fallback: cFallback,
},
ruby: {
name: 'Ruby',
extensions: ['.rb'],
wasm: 'tree-sitter-ruby.wasm',
queries: {
definitions: `
(method name: (identifier) @name) @def.method
(singleton_method name: (identifier) @name) @def.method
(class name: (constant) @name) @def.class
(module name: (constant) @name) @def.module
(assignment left: (constant) @name) @def.constant
`,
exports: '',
imports: `
(call method: (identifier) @import.name arguments: (argument_list (string) @import.source))
`,
calls: `
(call method: (identifier) @call.name)
`,
},
fallback: rubyFallback,
},
php: {
name: 'PHP',
extensions: ['.php'],
wasm: 'tree-sitter-php.wasm',
queries: {
definitions: `
(function_definition name: (name) @name) @def.function
(class_declaration name: (name) @name) @def.class
(interface_declaration name: (name) @name) @def.interface
(trait_declaration name: (name) @name) @def.type
(method_declaration name: (name) @name) @def.method
(const_declaration (const_element name: (name) @name)) @def.constant
`,
exports: '',
imports: `
(namespace_use_declaration) @import.source
(include_expression) @import.source
(require_expression) @import.source
`,
calls: `
(function_call_expression function: (name) @call.name)
`,
},
fallback: phpFallback,
},
bash: {
name: 'Bash',
extensions: ['.sh', '.bash', '.zsh'],
wasm: 'tree-sitter-bash.wasm',
queries: {
definitions: `
(function_definition name: (word) @name) @def.function
(variable_assignment name: (variable_name) @name) @def.constant
`,
exports: '',
imports: `
(command name: (command_name (word) @import.name) argument: (word) @import.source)
`,
calls: `
(command name: (command_name (word) @call.name))
`,
},
fallback: bashFallback,
},
json: {
name: 'JSON',
extensions: ['.json'],
wasm: 'tree-sitter-json.wasm',
queries: {
definitions: `
(pair key: (string) @name) @def.constant
`,
exports: '',
imports: '',
calls: '',
},
fallback: dataFallback,
},
yaml: {
name: 'YAML',
extensions: ['.yml', '.yaml'],
wasm: 'tree-sitter-yaml.wasm',
queries: {
definitions: `
(block_mapping_pair key: (_) @name) @def.constant
`,
exports: '',
imports: '',
calls: '',
},
fallback: dataFallback,
},
html: {
name: 'HTML',
extensions: ['.html', '.htm'],
wasm: 'tree-sitter-html.wasm',
queries: {
definitions: `
(element (start_tag (attribute (attribute_name) @attr (quoted_attribute_value (attribute_value) @name)))) @def.constant
`,
exports: '',
imports: '',
calls: '',
},
fallback: htmlFallback,
},
css: {
name: 'CSS',
extensions: ['.css'],
wasm: 'tree-sitter-css.wasm',
queries: {
definitions: `
(rule_set (selectors) @name) @def.constant
`,
exports: '',
imports: '',
calls: '',
},
fallback: cssFallback,
},
};
REGISTRY.tsx.queries = REGISTRY.typescript.queries;
for (const [id, lang] of Object.entries(REGISTRY)) {
for (const ext of lang.extensions) EXTENSIONS.set(ext, id);
}
export function detect(filePath) {
const lower = String(filePath).toLowerCase();
const ext = lower.match(/(\.[^.\/]+)$/)?.[1];
if (!ext) return null;
const id = EXTENSIONS.get(ext);
return id ? { id, ...REGISTRY[id] } : null;
}
export function languages() {
return Object.entries(REGISTRY).map(([id, lang]) => ({
id,
name: lang.name,
extensions: [...lang.extensions],
wasm: lang.wasm,
}));
}
export function queriesFor(langId) {
return REGISTRY[langId]?.queries ?? { definitions: '', exports: '', imports: '', calls: '' };
}
export function fallbackFor(langId) {
return REGISTRY[langId]?.fallback ?? genericFallback;
}
function genericFallback(source) {
return { symbols: [], refs: callRefs(source), imports: [] };
}
function jsFallback(source) {
const symbols = [];
scanLines(source, [
[/^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/, 'function'],
[/^(?:export\s+)?class\s+([A-Za-z_$][\w$]*)\b/, 'class'],
[/^(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)\b/, 'interface'],
[/^(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\b/, 'type'],
[/^(?:export\s+)?enum\s+([A-Za-z_$][\w$]*)\b/, 'enum'],
[/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/, 'function'],
[/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b/, 'constant'],
[/^\s*(?:public|private|protected|static|async|\s)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{?$/, 'method'],
], symbols);
return { symbols, refs: callRefs(source), imports: jsImports(source) };
}
function pythonFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/, 'function'],
[/^\s*class\s+([A-Za-z_][\w]*)\b/, 'class'],
[/^([A-Z_][A-Z0-9_]*)\s*=/, 'constant'],
], symbols);
return { symbols, refs: callRefs(source), imports: pythonImports(source) };
}
function goFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*\(/, 'function'],
[/^\s*type\s+([A-Za-z_]\w*)\s+struct\b/, 'struct'],
[/^\s*type\s+([A-Za-z_]\w*)\s+interface\b/, 'interface'],
[/^\s*type\s+([A-Za-z_]\w*)\b/, 'type'],
[/^\s*const\s+([A-Za-z_]\w*)\b/, 'constant'],
], symbols);
return { symbols, refs: callRefs(source), imports: quotedImports(source) };
}
function rustFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*(?:pub\s+)?fn\s+([A-Za-z_]\w*)\s*\(/, 'function'],
[/^\s*(?:pub\s+)?struct\s+([A-Za-z_]\w*)\b/, 'struct'],
[/^\s*(?:pub\s+)?enum\s+([A-Za-z_]\w*)\b/, 'enum'],
[/^\s*(?:pub\s+)?trait\s+([A-Za-z_]\w*)\b/, 'interface'],
[/^\s*(?:pub\s+)?type\s+([A-Za-z_]\w*)\b/, 'type'],
[/^\s*(?:pub\s+)?const\s+([A-Za-z_]\w*)\b/, 'constant'],
], symbols);
return { symbols, refs: callRefs(source), imports: rustImports(source) };
}
function javaFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*(?:public|private|protected|abstract|final|static|\s)*class\s+([A-Za-z_]\w*)\b/, 'class'],
[/^\s*(?:public|private|protected|abstract|final|static|\s)*interface\s+([A-Za-z_]\w*)\b/, 'interface'],
[/^\s*(?:public|private|protected|abstract|final|static|\s)*enum\s+([A-Za-z_]\w*)\b/, 'enum'],
[/^\s*(?:public|private|protected|static|final|synchronized|native|\s)+[\w<>\[\], ?]+\s+([A-Za-z_]\w*)\s*\(/, 'method'],
], symbols);
return { symbols, refs: callRefs(source), imports: dottedImports(source) };
}
function csharpFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*class\s+([A-Za-z_]\w*)\b/, 'class'],
[/^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*interface\s+([A-Za-z_]\w*)\b/, 'interface'],
[/^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*struct\s+([A-Za-z_]\w*)\b/, 'struct'],
[/^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*enum\s+([A-Za-z_]\w*)\b/, 'enum'],
[/^\s*(?:public|private|protected|internal|static|virtual|override|async|\s)+[\w<>\[\], ?]+\s+([A-Za-z_]\w*)\s*\(/, 'method'],
], symbols);
return { symbols, refs: callRefs(source), imports: dottedImports(source) };
}
function cFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*[A-Za-z_][\w\s:*&<>]*\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/, 'function'],
[/^\s*(?:typedef\s+)?struct\s+([A-Za-z_]\w*)\b/, 'struct'],
[/^\s*(?:typedef\s+)?enum\s+([A-Za-z_]\w*)\b/, 'enum'],
[/^\s*typedef\b.*\s+([A-Za-z_]\w*)\s*;/, 'type'],
], symbols);
return { symbols, refs: callRefs(source), imports: quotedImports(source) };
}
function rubyFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*def\s+([A-Za-z_]\w*[!?=]?)\b/, 'method'],
[/^\s*class\s+([A-Z]\w*)\b/, 'class'],
[/^\s*module\s+([A-Z]\w*)\b/, 'module'],
[/^\s*([A-Z][A-Za-z0-9_]*)\s*=/, 'constant'],
], symbols);
return { symbols, refs: callRefs(source), imports: rubyImports(source) };
}
function phpFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*(?:final\s+|abstract\s+)?function\s+([A-Za-z_]\w*)\s*\(/, 'function'],
[/^\s*(?:final\s+|abstract\s+)?class\s+([A-Za-z_]\w*)\b/, 'class'],
[/^\s*interface\s+([A-Za-z_]\w*)\b/, 'interface'],
[/^\s*trait\s+([A-Za-z_]\w*)\b/, 'type'],
[/^\s*(?:public|private|protected|static|\s)*function\s+([A-Za-z_]\w*)\s*\(/, 'method'],
], symbols);
return { symbols, refs: callRefs(source), imports: quotedImports(source) };
}
function bashFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\)\s*\{?/, 'function'],
[/^\s*([A-Za-z_][\w]*)=/, 'constant'],
], symbols);
return { symbols, refs: shellRefs(source), imports: [] };
}
function dataFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*"([^"]+)"\s*:/, 'constant'],
[/^\s*([A-Za-z_][\w.-]*)\s*:/, 'constant'],
], symbols);
return { symbols, refs: [], imports: [] };
}
function htmlFallback(source) {
const symbols = [];
scanLines(source, [
[/<[^>]+\sid=["']([^"']+)["']/, 'constant'],
[/<([A-Za-z][\w-]*)\b/, 'element'],
], symbols);
return { symbols, refs: [], imports: [] };
}
function cssFallback(source) {
const symbols = [];
scanLines(source, [
[/^\s*([.#]?[A-Za-z_][\w-]*)\s*\{/, 'constant'],
], symbols);
return { symbols, refs: [], imports: [] };
}
function scanLines(source, patterns, out) {
const lines = source.split(/\r?\n/);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
for (const [regex, kind] of patterns) {
const match = line.match(regex);
if (!match?.[1]) continue;
const name = cleanupName(match[1]);
if (!name || CONTROL_WORDS.has(name)) continue;
out.push({
name,
kind,
line: i + 1,
endLine: i + 1,
signature: line.trim().slice(0, 180),
exported: isExportedLine(line, name),
});
break;
}
}
}
function callRefs(source) {
const refs = [];
const lineStarts = buildLineStarts(source);
const regex = /([A-Za-z_$][\w$]*)\s*\(/g;
let match;
while ((match = regex.exec(source))) {
const name = match[1];
const prev = source[match.index - 1] ?? '';
if (CONTROL_WORDS.has(name) || /[\w$."'`]/.test(prev)) continue;
refs.push({ name, kind: 'call', line: lineForIndex(lineStarts, match.index) });
}
return refs;
}
function shellRefs(source) {
const refs = [];
source.split(/\r?\n/).forEach((line, index) => {
const match = line.match(/^\s*([A-Za-z_][\w-]*)\b/);
if (match && !CONTROL_WORDS.has(match[1])) refs.push({ name: match[1], kind: 'call', line: index + 1 });
});
return refs;
}
function jsImports(source) {
const names = new Set();
for (const match of source.matchAll(/import\s+(?:type\s+)?([^'";]+?)\s+from\s+['"][^'"]+['"]/g)) {
collectImportList(match[1], names);
}
for (const match of source.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) names.add(lastPathPart(match[1]));
for (const match of source.matchAll(/(?:const|let|var)\s+(\{[^}]+\}|[A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
collectImportList(match[1], names);
}
return [...names].map((name) => ({ name, kind: 'import' }));
}
function pythonImports(source) {
const names = new Set();
for (const match of source.matchAll(/^\s*from\s+[\w.]+\s+import\s+(.+)$/gm)) collectImportList(match[1], names);
for (const match of source.matchAll(/^\s*import\s+(.+)$/gm)) collectImportList(match[1], names);
return [...names].map((name) => ({ name, kind: 'import' }));
}
function quotedImports(source) {
return [...source.matchAll(/^\s*#?\s*include\s+[<"]([^>"]+)[>"]|^\s*import\s+(?:"([^"]+)"|`([^`]+)`)/gm)]
.map((match) => ({ name: lastPathPart(match[1] || match[2] || match[3]), kind: 'import' }))
.filter((item) => item.name);
}
function rustImports(source) {
return [...source.matchAll(/^\s*use\s+([^;]+);/gm)]
.flatMap((match) => match[1].split(/::|,|\{|\}/))
.map((part) => cleanupName(part.trim()))
.filter(Boolean)
.map((name) => ({ name, kind: 'import' }));
}
function dottedImports(source) {
return [...source.matchAll(/^\s*(?:import|using)\s+([\w.]+)\s*;?/gm)]
.map((match) => ({ name: match[1].split('.').pop(), kind: 'import' }))
.filter((item) => item.name);
}
function rubyImports(source) {
return [...source.matchAll(/^\s*require(?:_relative)?\s+['"]([^'"]+)['"]/gm)]
.map((match) => ({ name: lastPathPart(match[1]), kind: 'import' }))
.filter((item) => item.name);
}
function collectImportList(raw, names) {
const cleaned = raw.replace(/[{}]/g, ',');
for (const part of cleaned.split(',')) {
const token = part.trim();
if (!token || token === '*') continue;
const alias = token.match(/\bas\s+([A-Za-z_$][\w$]*)$/)?.[1];
const name = cleanupName(alias ?? token.split(/\s+/).pop());
if (name && !['from', 'type'].includes(name)) names.add(name);
}
}
function lastPathPart(value) {
return cleanupName(String(value ?? '').split(/[\/.]/).filter(Boolean).pop());
}
function cleanupName(value) {
return String(value ?? '').replace(/^[:$@]+|[!?=,;]+$/g, '').trim();
}
function isExportedLine(line, name) {
if (/\b(export|pub|public)\b/.test(line)) return true;
return /^[A-Z]/.test(name) && !/^\s*(?:class|module)\b/.test(line) ? false : false;
}
function buildLineStarts(source) {
const starts = [0];
for (let i = 0; i < source.length; i += 1) if (source[i] === '\n') starts.push(i + 1);
return starts;
}
function lineForIndex(starts, index) {
let low = 0;
let high = starts.length - 1;
while (low <= high) {
const mid = (low + high) >> 1;
if (starts[mid] <= index) low = mid + 1;
else high = mid - 1;
}
return high + 1;
}
+358
View File
@@ -0,0 +1,358 @@
/**
* File-only memory: markdown shards + tiny INDEX.md.
* No database, no native deps, no secret echo.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig, MEMORY_SHARDS } from './config.mjs';
import {
paths as makePaths,
ensureDir,
readIfExists,
writeFile,
estimateTokens,
} from './paths.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
export const TEMPLATE_DIR = path.resolve(here, '..', '..', 'templates');
const SHARD_DESCRIPTIONS = {
seed: 'Stable repo facts and bootstrap knowledge.',
failures: 'Known failures, regressions, and broken approaches.',
corrections: 'User corrections and changed assumptions.',
insights: 'Reusable discoveries and design observations.',
conventions: 'Project-specific conventions not yet promoted to conventions.md.',
quirks: 'Sharp edges, environment quirks, and non-obvious constraints.',
};
function assertShard(shard) {
if (!MEMORY_SHARDS.includes(shard)) {
throw new Error(`unknown memory shard: ${shard}`);
}
}
function shardFile(ctx, shard) {
assertShard(shard);
return path.join(ctx.paths.memory, `${shard}.md`);
}
function readTemplate(name) {
return fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf8');
}
export function render(template, vars = {}) {
return String(template).replace(/\{\{([A-Za-z0-9_.-]+)\}\}/g, (match, key) =>
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match,
);
}
export function openMemory(cwd = process.cwd()) {
const loaded = loadConfig(cwd);
if (!loaded) {
throw new Error('harness not initialised; run `lh init` first');
}
return { config: loaded.config, paths: makePaths(loaded.config, cwd), configFile: loaded.file };
}
export function emptyShardText(shard) {
assertShard(shard);
return render(readTemplate('memory-shard.md'), {
SHARD: shard,
DESCRIPTION: SHARD_DESCRIPTIONS[shard] ?? 'Project memory shard.',
});
}
export function ensureMemoryTree(ctx) {
ensureDir(ctx.paths.memory);
for (const shard of MEMORY_SHARDS) {
const file = shardFile(ctx, shard);
if (!fs.existsSync(file)) writeFile(file, emptyShardText(shard));
}
if (!fs.existsSync(ctx.paths.memoryIndex)) rebuildIndex(ctx);
}
export function readShard(ctx, shard) {
return readIfExists(shardFile(ctx, shard)) ?? emptyShardText(shard);
}
function parseTagLine(line) {
const match = line.match(/^tags:\s*(.*)$/i);
if (!match) return null;
return match[1]
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
}
export function readEntries(ctx, shard) {
const text = readShard(ctx, shard);
const header = /^## ([^\n—]+?) — ([^\n]+)$/gm;
const matches = [...text.matchAll(header)];
return matches.map((match, index) => {
const start = match.index + match[0].length;
const end = matches[index + 1]?.index ?? text.length;
const raw = text.slice(start, end).trim();
const lines = raw.split(/\r?\n/);
let tags = [];
const bodyLines = [];
for (const line of lines) {
const parsed = parseTagLine(line.trim());
if (parsed) {
tags = parsed;
} else {
bodyLines.push(line);
}
}
const body = bodyLines.join('\n').trim();
return {
shard,
timestamp: match[1].trim(),
title: match[2].trim(),
body,
tags,
tokens: estimateTokens(`${match[2]}\n${body}\n${tags.join(',')}`),
};
});
}
function entryMarkdown(entry) {
const tags = (entry.tags ?? []).map((tag) => String(tag).trim()).filter(Boolean);
const parts = [`## ${entry.timestamp} — ${entry.title}`];
if (tags.length) parts.push(`tags: ${tags.join(', ')}`);
parts.push('');
parts.push(String(entry.body ?? '').trim());
return `${parts.join('\n').trim()}\n`;
}
function writeShardEntries(ctx, shard, entries) {
const body = entries.map(entryMarkdown).join('\n');
const text = `${emptyShardText(shard).trim()}\n\n${body}`.trimEnd() + '\n';
writeFile(shardFile(ctx, shard), text);
}
function preview(match, kind) {
if (kind === 'pem-private-key') return '[PEM private key header]';
const text = String(match);
if (text.length <= 8) return `${text.slice(0, 2)}…`;
return `${text.slice(0, 4)}…${text.slice(-4)}`;
}
export function scanSecrets(text) {
const patterns = [
['github-token', /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g],
['openai-key', /\bsk-[A-Za-z0-9_-]{20,}\b/g],
['aws-access-key-id', /\bAKIA[0-9A-Z]{16}\b/g],
['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g],
['pem-private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/g],
['jwt', /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g],
[
'generic-secret',
/\b(api[_-]?key|secret|password|token)\s*[:=]\s*['"]?[A-Za-z0-9_-]{16,}/gi,
],
];
const findings = [];
for (const [kind, regex] of patterns) {
for (const match of String(text ?? '').matchAll(regex)) {
findings.push({ kind, index: match.index ?? 0, preview: preview(match[0], kind) });
}
}
return findings.sort((a, b) => a.index - b.index || a.kind.localeCompare(b.kind));
}
function safeTitle(title) {
return String(title ?? '').replace(/\r?\n/g, ' ').trim() || 'untitled';
}
export function putEntry(ctx, { shard, title, body = '', tags = [], allowSecrets = false } = {}) {
assertShard(shard);
const tagList = Array.isArray(tags)
? tags.map((tag) => String(tag).trim()).filter(Boolean)
: String(tags)
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
const combined = `${title ?? ''}\n${body ?? ''}\n${tagList.join(',')}`;
const findings = scanSecrets(combined);
if (findings.length && !allowSecrets) {
return { ok: false, written: false, findings };
}
ensureMemoryTree(ctx);
const entry = {
shard,
timestamp: new Date().toISOString(),
title: safeTitle(title),
body: String(body ?? '').trim(),
tags: tagList,
};
fs.appendFileSync(shardFile(ctx, shard), `\n${entryMarkdown(entry)}`);
rebuildIndex(ctx);
return { ok: true, written: true, entry, findings };
}
function shardStats(ctx, shard) {
const file = shardFile(ctx, shard);
const text = readShard(ctx, shard);
const entries = readEntries(ctx, shard);
const newest = entries
.map((entry) => entry.timestamp)
.sort()
.at(-1);
let mtime = null;
try {
mtime = fs.statSync(file).mtime.toISOString();
} catch {
// absent shard is represented by skeleton text
}
const summary =
text
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line && !line.startsWith('#') && line !== 'Entries') ??
SHARD_DESCRIPTIONS[shard] ??
'';
return {
shard,
entries: entries.length,
tokens: estimateTokens(text),
updated: newest ?? mtime ?? 'never',
summary,
};
}
export function listShards(ctx) {
ensureMemoryTree(ctx);
return MEMORY_SHARDS.map((shard) => shardStats(ctx, shard));
}
function entryHaystack(entry) {
return `${entry.title}\n${entry.body}\n${entry.tags.join(' ')}`.toLowerCase();
}
function scoreEntry(entry, query) {
const q = String(query ?? '').toLowerCase().trim();
if (!q) return 0;
const haystack = entryHaystack(entry);
let score = haystack.includes(q) ? 10 : 0;
for (const token of q.split(/\W+/).filter(Boolean)) {
if (haystack.includes(token)) score += 1;
}
return score;
}
export function getMemory(ctx, { shard, query, limit } = {}) {
ensureMemoryTree(ctx);
const max = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 10;
if (!shard && !query) return readIfExists(ctx.paths.memoryIndex) ?? '';
const shards = shard ? [shard] : MEMORY_SHARDS;
for (const name of shards) assertShard(name);
let entries = shards.flatMap((name) => readEntries(ctx, name));
if (query) {
entries = entries
.map((entry) => ({ ...entry, score: scoreEntry(entry, query) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || String(b.timestamp).localeCompare(String(a.timestamp)))
.slice(0, max);
} else if (limit !== undefined) {
entries = entries.slice(-max);
}
return entries;
}
function oneLine(text, max = 180) {
const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
if (!flat) return '';
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
function unique(values) {
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))];
}
function compactEntries(shard, entries, keepNewest) {
if (entries.length <= keepNewest + 1) return { entries, changed: false, merged: 0 };
const sorted = [...entries].sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
const newest = sorted.slice(-keepNewest);
const old = sorted.slice(0, -keepNewest);
if (!old.length) return { entries, changed: false, merged: 0 };
const groups = new Map();
for (const entry of [...old].sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)))) {
const key = entry.tags[0] ?? 'untagged';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(entry);
}
const sections = [];
for (const [tag, group] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
const titles = unique(group.map((entry) => entry.title)).join('; ');
const bodies = unique(group.map((entry) => oneLine(entry.body)).filter(Boolean)).slice(0, 5);
sections.push(`### ${tag}\ntitles: ${titles}\n${bodies.map((body) => `- ${body}`).join('\n')}`.trim());
}
const consolidated = {
shard,
timestamp: new Date().toISOString(),
title: `compacted ${old.length} older entries`,
body: [`Consolidated oldest entries; newest ${keepNewest} preserved.`, ...sections].join('\n\n'),
tags: unique(['compacted', ...old.flatMap((entry) => entry.tags)]).slice(0, 12),
};
return {
entries: [...newest, consolidated].sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp))),
changed: true,
merged: old.length,
};
}
export function compact(ctx, { force = false } = {}) {
ensureMemoryTree(ctx);
const budget = Number(ctx.config.memory?.tokenBudget ?? 8000);
const percent = Number(ctx.config.memory?.compactAtPercent ?? 85);
const threshold = Math.ceil((budget * percent) / 100);
const before = listShards(ctx);
const totalBefore = before.reduce((sum, row) => sum + row.tokens, 0);
if (!force && totalBefore <= threshold) {
return { changed: false, totalBefore, totalAfter: totalBefore, threshold, shards: [] };
}
const keepNewest = Number(ctx.config.memory?.keepNewestEntries ?? 5);
const changed = [];
for (const row of [...before].sort((a, b) => b.tokens - a.tokens)) {
const entries = readEntries(ctx, row.shard);
const result = compactEntries(row.shard, entries, keepNewest);
if (!result.changed) continue;
writeShardEntries(ctx, row.shard, result.entries);
changed.push({ shard: row.shard, merged: result.merged, keptNewest: Math.min(keepNewest, entries.length) });
}
rebuildIndex(ctx);
const after = listShards(ctx);
const totalAfter = after.reduce((sum, row) => sum + row.tokens, 0);
return { changed: changed.length > 0, totalBefore, totalAfter, threshold, shards: changed };
}
export function rebuildIndex(ctx) {
ensureDir(ctx.paths.memory);
for (const shard of MEMORY_SHARDS) {
const file = shardFile(ctx, shard);
if (!fs.existsSync(file)) writeFile(file, emptyShardText(shard));
}
const rows = MEMORY_SHARDS.map((shard) => shardStats(ctx, shard));
const shardLines = rows
.map((row) => `| ${row.shard} | ${row.entries} | ${row.tokens} | ${row.updated} | ${row.summary} |`)
.join('\n');
const totalTokens = rows.reduce((sum, row) => sum + row.tokens, 0);
const text = render(readTemplate('memory-index.md'), {
UPDATED: new Date().toISOString(),
TOTAL_TOKENS: totalTokens,
SHARDS: shardLines,
});
writeFile(ctx.paths.memoryIndex, text);
return { rows, totalTokens, file: ctx.paths.memoryIndex };
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Single source of truth for every path the harness reads or writes.
* No other module joins paths by hand.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
export function repoRoot(cwd = process.cwd()) {
try {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return path.resolve(cwd);
}
}
/**
* @param {object} config harness config
* @param {string} cwd
* @returns {{root:string, agents:string, ...}} absolute paths
*/
export function paths(config, cwd = process.cwd()) {
const root = repoRoot(cwd);
const memRoot = config?.memory?.root ?? '.agents';
const agents = path.isAbsolute(memRoot) ? memRoot : path.join(root, memRoot);
return {
root,
agents,
config: path.join(agents, 'harness.config.json'),
architecture: path.join(agents, 'architecture.md'),
conventions: path.join(agents, 'conventions.md'),
memory: path.join(agents, 'memory'),
memoryIndex: path.join(agents, 'memory', 'INDEX.md'),
specs: path.join(agents, 'specs'),
runs: path.join(agents, 'runs'),
cache: path.join(agents, '.cache'),
wasm: path.join(agents, '.cache', 'wasm'),
repomap: path.join(agents, '.cache', 'repomap.json'),
symbols: path.join(agents, '.cache', 'symbols.json'),
lanes: path.join(agents, '.cache', 'lanes.json'),
worktrees: path.join(agents, '.cache', 'worktrees'),
spec: (slug) => path.join(agents, 'specs', slug),
run: (runId) => path.join(agents, 'runs', runId),
};
}
export function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
return dir;
}
export function readIfExists(file) {
try {
return fs.readFileSync(file, 'utf8');
} catch {
return null;
}
}
export function writeFile(file, contents) {
ensureDir(path.dirname(file));
fs.writeFileSync(file, contents);
return file;
}
/** Rough token estimate: good enough for budgets, costs nothing. */
export function estimateTokens(text) {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
+582
View File
@@ -0,0 +1,582 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { detect, fallbackFor, queriesFor } from './langs.mjs';
import { ensureDir, estimateTokens, readIfExists, writeFile } from './paths.mjs';
const INDEX_VERSION = 1;
const MAX_FILE_SIZE = 1024 * 1024;
const DEFAULT_WASM_BASE = 'https://cdn.jsdelivr.net/npm/tree-sitter-wasms@latest/out';
let ParserClass = null;
let parserInit = null;
const languageCache = new Map();
export async function buildIndex(ctx, options = {}) {
const started = Date.now();
const config = ctx.config ?? {};
const p = ctx.paths;
const budget = numberOption(options.budget, config.index?.budget ?? 1200);
const files = listFiles(p.root, config.index ?? {});
const cache = loadCache(p.symbols);
const gitHead = git(['rev-parse', 'HEAD'], p.root);
const changedByGit = changedFiles(p.root, cache?.gitHead, gitHead);
const nextFiles = {};
const degraded = new Map();
const stats = {
files: 0,
parsed: 0,
cacheHits: 0,
skipped: 0,
symbols: 0,
refs: 0,
languages: {},
degradedLanguages: {},
elapsedMs: 0,
tokens: 0,
};
for (const rel of files) {
const abs = path.join(p.root, rel);
const stat = safeStat(abs);
if (!stat?.isFile()) continue;
if (stat.size > MAX_FILE_SIZE || isBinary(abs)) {
stats.skipped += 1;
continue;
}
const lang = detect(rel);
if (!lang) {
stats.skipped += 1;
continue;
}
const cached = cache?.files?.[rel];
if (
cached &&
!options.force &&
cached.mtimeMs === stat.mtimeMs &&
cached.size === stat.size &&
!changedByGit.has(rel)
) {
nextFiles[rel] = cached;
stats.cacheHits += 1;
if (cached.degraded) degraded.set(cached.lang, cached.parser === 'regex' ? 'wasm unavailable' : 'degraded');
countFile(stats, cached);
continue;
}
let source;
try {
source = fs.readFileSync(abs, 'utf8');
} catch {
stats.skipped += 1;
continue;
}
const extracted = await extractFile(ctx, rel, source, lang, options.fetch, degraded);
const entry = {
path: rel,
lang: lang.id,
parser: extracted.parser,
degraded: extracted.degraded,
mtimeMs: stat.mtimeMs,
size: stat.size,
hash: hash(source),
symbols: normalizeSymbols(extracted.symbols, source),
refs: normalizeRefs(extracted.refs),
imports: normalizeRefs(extracted.imports ?? []).map((ref) => ref.name),
};
nextFiles[rel] = entry;
stats.parsed += 1;
countFile(stats, entry);
}
const index = {
version: INDEX_VERSION,
root: p.root,
generatedAt: new Date().toISOString(),
gitHead,
files: nextFiles,
graph: buildReferenceGraph(nextFiles),
ranks: {},
stats,
};
index.ranks = rankIndex(index, { focus: options.focus });
const rendered = renderMap(index, { budget, focus: options.focus });
index.stats.elapsedMs = Date.now() - started;
index.stats.tokens = estimateTokens(rendered);
index.stats.degradedLanguages = Object.fromEntries(degraded);
writeFile(p.symbols, `${JSON.stringify(index, null, 2)}\n`);
writeFile(
p.repomap,
`${JSON.stringify(
{
version: INDEX_VERSION,
generatedAt: index.generatedAt,
budget,
focus: options.focus ?? null,
tokens: index.stats.tokens,
map: rendered,
},
null,
2,
)}\n`,
);
return index;
}
export function renderMap(index, options = {}) {
const budget = numberOption(options.budget, index?.stats?.budget ?? 1200);
const ranks = index.ranks?.files ?? rankIndex(index, { focus: options.focus }).files;
const files = Object.values(index.files ?? {}).sort((a, b) => (ranks[b.path] ?? 0) - (ranks[a.path] ?? 0));
const lines = ['# Repo map'];
if (options.focus) lines.push(`focus: ${options.focus}`);
lines.push(`files: ${files.length} symbols: ${index.stats?.symbols ?? countSymbols(index)}`);
for (const file of files) {
const full = renderFile(file, ranks[file.path] ?? 0, 'full');
if (fits(lines, full, budget)) {
lines.push(...full);
continue;
}
const names = renderFile(file, ranks[file.path] ?? 0, 'names');
if (fits(lines, names, budget)) {
lines.push(...names);
continue;
}
const counts = renderFile(file, ranks[file.path] ?? 0, 'counts');
if (fits(lines, counts, budget)) lines.push(...counts);
}
return `${lines.join('\n')}\n`;
}
export function globMatch(pattern, candidate) {
const normalized = normalizePath(candidate);
return globRegex(pattern).test(normalized);
}
async function extractFile(ctx, rel, source, lang, shouldFetch, degraded) {
const wasmPath = await ensureGrammar(ctx, lang, shouldFetch);
if (wasmPath) {
try {
return { ...(await extractTreeSitter(source, lang, wasmPath)), parser: 'tree-sitter', degraded: false };
} catch (error) {
degraded.set(lang.id, `tree-sitter failed: ${error.message}`);
}
} else {
degraded.set(lang.id, shouldFetch ? 'wasm fetch failed' : 'wasm unavailable');
}
const fallback = fallbackFor(lang.id);
const result = fallback(source, rel) ?? {};
return {
symbols: result.symbols ?? [],
refs: result.refs ?? [],
imports: result.imports ?? [],
parser: 'regex',
degraded: true,
};
}
async function ensureGrammar(ctx, lang, shouldFetch) {
const target = path.join(ctx.paths.wasm, lang.wasm);
if (fs.existsSync(target)) return target;
if (!shouldFetch) return null;
try {
ensureDir(ctx.paths.wasm);
const base = process.env.LH_WASM_BASE_URL || ctx.config?.index?.wasmBaseUrl || DEFAULT_WASM_BASE;
const url = base.includes('{file}') ? base.replace('{file}', lang.wasm) : `${base.replace(/\/$/, '')}/${lang.wasm}`;
const response = await fetch(url);
if (!response.ok) return null;
const bytes = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(target, bytes);
return target;
} catch {
return null;
}
}
async function extractTreeSitter(source, lang, wasmPath) {
await initParser();
const parser = new ParserClass();
let language = languageCache.get(wasmPath);
if (!language) {
language = await ParserClass.Language.load(pathToFileURL(wasmPath).href);
languageCache.set(wasmPath, language);
}
parser.setLanguage(language);
const tree = parser.parse(source);
const queries = queriesFor(lang.id);
const symbols = [];
const imports = [];
const refs = [];
collectDefinitions(language, tree, queries.definitions, source, symbols);
collectImports(language, tree, queries.imports, source, imports, refs);
collectCalls(language, tree, queries.calls, source, refs);
markExports(symbols, source);
return { symbols, imports, refs };
}
async function initParser() {
if (!parserInit) {
parserInit = import('web-tree-sitter').then(async (mod) => {
ParserClass = mod.default ?? mod;
await ParserClass.init();
return ParserClass;
});
}
return parserInit;
}
function collectDefinitions(language, tree, querySource, source, out) {
for (const match of runQuery(language, tree, querySource)) {
const def = match.captures.find((cap) => cap.name.startsWith('def.'));
const nameCap = match.captures.find((cap) => cap.name === 'name');
if (!def || !nameCap) continue;
const name = textFor(source, nameCap.node).replace(/^["']|["']$/g, '');
if (!isIdentifierish(name)) continue;
out.push({
name,
kind: def.name.slice(4) || inferKind(def.node.type),
line: def.node.startPosition.row + 1,
endLine: def.node.endPosition.row + 1,
signature: firstLine(source, def.node),
exported: hasExportAncestor(def.node) || /\b(export|pub|public)\b/.test(firstLine(source, def.node)),
});
}
}
function collectImports(language, tree, querySource, source, imports, refs) {
for (const match of runQuery(language, tree, querySource)) {
for (const cap of match.captures) {
if (!cap.name.startsWith('import.')) continue;
const raw = textFor(source, cap.node).replace(/^["'`<]|["'`>]$/g, '');
const name = cap.name === 'import.source' ? lastPathPart(raw) : raw;
if (!isIdentifierish(name)) continue;
imports.push({ name, kind: 'import', line: cap.node.startPosition.row + 1 });
refs.push({ name, kind: 'import', line: cap.node.startPosition.row + 1 });
}
}
}
function collectCalls(language, tree, querySource, source, refs) {
for (const match of runQuery(language, tree, querySource)) {
for (const cap of match.captures) {
if (cap.name !== 'call.name') continue;
const name = textFor(source, cap.node);
if (!isIdentifierish(name)) continue;
refs.push({ name, kind: 'call', line: cap.node.startPosition.row + 1 });
}
}
}
function runQuery(language, tree, querySource) {
if (!querySource?.trim()) return [];
try {
const query = language.query(querySource);
return query.matches(tree.rootNode);
} catch {
return [];
}
}
function markExports(symbols, source) {
const lines = source.split(/\r?\n/);
for (const symbol of symbols) {
const line = lines[symbol.line - 1] ?? '';
if (/\b(export|pub|public)\b/.test(line)) symbol.exported = true;
}
}
function hasExportAncestor(node) {
let current = node;
while (current) {
if (/export/.test(current.type)) return true;
current = current.parent;
}
return false;
}
function normalizeSymbols(symbols, source) {
const seen = new Set();
const lines = source.split(/\r?\n/);
return (symbols ?? [])
.filter((symbol) => symbol?.name && isIdentifierish(symbol.name))
.map((symbol) => ({
name: String(symbol.name),
kind: symbol.kind ?? 'symbol',
line: Math.max(1, Number(symbol.line) || 1),
endLine: Math.max(Number(symbol.endLine) || Number(symbol.line) || 1, Number(symbol.line) || 1),
signature: String(symbol.signature || lines[(Number(symbol.line) || 1) - 1] || symbol.name).trim().slice(0, 180),
exported: Boolean(symbol.exported),
arity: arity(symbol.signature || ''),
}))
.filter((symbol) => {
const key = `${symbol.name}:${symbol.kind}:${symbol.line}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function normalizeRefs(refs) {
const seen = new Set();
return (refs ?? [])
.filter((ref) => ref?.name && isIdentifierish(ref.name))
.map((ref) => ({ name: String(ref.name), kind: ref.kind ?? 'call', line: Math.max(1, Number(ref.line) || 1) }))
.filter((ref) => {
const key = `${ref.kind}:${ref.name}:${ref.line}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function buildReferenceGraph(files) {
const definitions = new Map();
for (const file of Object.values(files)) {
for (const symbol of file.symbols ?? []) {
if (!definitions.has(symbol.name)) definitions.set(symbol.name, new Set());
definitions.get(symbol.name).add(file.path);
}
}
const edges = {};
for (const file of Object.values(files)) {
const out = new Set();
for (const ref of file.refs ?? []) {
for (const target of definitions.get(ref.name) ?? []) {
if (target !== file.path) out.add(target);
}
}
edges[file.path] = [...out].sort();
}
return { edges };
}
function rankIndex(index, options = {}) {
const paths = Object.keys(index.files ?? {}).sort();
const n = paths.length || 1;
const edges = index.graph?.edges ?? buildReferenceGraph(index.files ?? {}).edges;
let scores = Object.fromEntries(paths.map((file) => [file, 1 / n]));
const focusMatchers = [options.focus].filter(Boolean);
const seeds = Object.fromEntries(
paths.map((file) => {
const focused = focusMatchers.some((pattern) => globMatch(pattern, file));
return [file, focused ? 4 : 1];
}),
);
const seedTotal = Object.values(seeds).reduce((sum, value) => sum + value, 0) || 1;
for (const file of paths) seeds[file] /= seedTotal;
for (let i = 0; i < 20; i += 1) {
const next = Object.fromEntries(paths.map((file) => [file, (1 - 0.85) * seeds[file]]));
for (const from of paths) {
const outs = edges[from] ?? [];
if (!outs.length) {
for (const file of paths) next[file] += (0.85 * scores[from]) / n;
} else {
for (const to of outs) next[to] += (0.85 * scores[from]) / outs.length;
}
}
scores = next;
}
const symbols = {};
for (const file of Object.values(index.files ?? {})) {
for (const symbol of file.symbols ?? []) symbols[`${file.path}:${symbol.name}:${symbol.line}`] = scores[file.path] ?? 0;
}
return { files: scores, symbols };
}
function listFiles(root, indexConfig) {
const include = indexConfig.include?.length ? indexConfig.include : ['**/*'];
const exclude = indexConfig.exclude ?? [];
const depth = Number(indexConfig.depth ?? 0);
const gitFiles = git(['ls-files', '--cached', '--others', '--exclude-standard'], root);
const files = gitFiles ? gitFiles.split(/\r?\n/).filter(Boolean) : manualFiles(root);
return [...new Set(files.map(normalizePath))]
.filter((file) => withinDepth(file, depth))
.filter((file) => include.some((pattern) => globMatch(pattern, file)))
.filter((file) => !exclude.some((pattern) => globMatch(pattern, file)))
.sort();
}
function manualFiles(root) {
const out = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (['.git', 'node_modules', '.agents'].includes(entry.name)) continue;
const abs = path.join(dir, entry.name);
const rel = normalizePath(path.relative(root, abs));
if (entry.isDirectory()) walk(abs);
else if (entry.isFile()) out.push(rel);
}
};
walk(root);
return out;
}
function changedFiles(root, cachedHead, currentHead) {
const changed = new Set();
if (cachedHead && currentHead && cachedHead !== currentHead) {
for (const file of lines(git(['diff', '--name-only', `${cachedHead}..${currentHead}`], root))) changed.add(normalizePath(file));
}
for (const file of lines(git(['diff', '--name-only'], root))) changed.add(normalizePath(file));
for (const file of lines(git(['diff', '--name-only', '--cached'], root))) changed.add(normalizePath(file));
return changed;
}
function renderFile(file, score, mode) {
const suffix = file.degraded ? ' regex' : '';
if (mode === 'counts') return [`${file.path} [${file.lang}${suffix}] ${file.symbols.length} symbols ${file.refs.length} refs`];
const symbols = [...(file.symbols ?? [])].sort((a, b) => a.line - b.line).slice(0, mode === 'full' ? 12 : 18);
if (!symbols.length) return [`${file.path} [${file.lang}${suffix}] score ${score.toFixed(3)}`];
if (mode === 'names') {
return [`${file.path} [${file.lang}${suffix}] ${symbols.map((s) => `${s.exported ? '+' : ''}${s.name}`).join(', ')}`];
}
return [
`${file.path} [${file.lang}${suffix}] score ${score.toFixed(3)}`,
...symbols.map((s) => ` L${s.line} ${s.exported ? 'export ' : ''}${s.kind} ${s.name}: ${s.signature}`),
];
}
function fits(lines, addition, budget) {
return estimateTokens(`${lines.concat(addition).join('\n')}\n`) <= budget;
}
function globRegex(pattern) {
const normalized = normalizePath(pattern);
let rx = '^';
const parts = normalized.split('/');
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i];
const last = i === parts.length - 1;
if (part === '**') {
rx += last ? '.*' : '(?:[^/]+/)*';
continue;
}
rx += [...part]
.map((ch) => {
if (ch === '*') return '[^/]*';
if (ch === '?') return '[^/]';
return ch.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
})
.join('');
if (!last) rx += '/';
}
rx += '$';
return new RegExp(rx);
}
function withinDepth(file, depth) {
if (!depth || depth < 1) return true;
return file.split('/').length - 1 <= depth;
}
function isBinary(file) {
try {
const fd = fs.openSync(file, 'r');
const buffer = Buffer.alloc(8192);
const read = fs.readSync(fd, buffer, 0, buffer.length, 0);
fs.closeSync(fd);
return buffer.subarray(0, read).includes(0);
} catch {
return true;
}
}
function safeStat(file) {
try {
return fs.statSync(file);
} catch {
return null;
}
}
function git(args, cwd) {
try {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
} catch {
return null;
}
}
function lines(text) {
return text ? text.split(/\r?\n/).filter(Boolean) : [];
}
function loadCache(file) {
try {
const cache = JSON.parse(readIfExists(file) ?? 'null');
return cache?.version === INDEX_VERSION ? cache : null;
} catch {
return null;
}
}
function countFile(stats, file) {
stats.files += 1;
stats.symbols += file.symbols?.length ?? 0;
stats.refs += file.refs?.length ?? 0;
stats.languages[file.lang] = (stats.languages[file.lang] ?? 0) + 1;
}
function countSymbols(index) {
return Object.values(index.files ?? {}).reduce((sum, file) => sum + (file.symbols?.length ?? 0), 0);
}
function hash(source) {
return createHash('sha1').update(source).digest('hex');
}
function arity(signature) {
const args = String(signature).match(/\(([^)]*)\)/)?.[1];
if (args === undefined) return -1;
const trimmed = args.trim();
if (!trimmed) return 0;
return trimmed.split(',').filter(Boolean).length;
}
function inferKind(type) {
if (/class/.test(type)) return 'class';
if (/interface/.test(type)) return 'interface';
if (/struct/.test(type)) return 'struct';
if (/enum/.test(type)) return 'enum';
if (/type/.test(type)) return 'type';
if (/method/.test(type)) return 'method';
if (/function/.test(type)) return 'function';
return 'symbol';
}
function textFor(source, node) {
return source.slice(node.startIndex, node.endIndex);
}
function firstLine(source, node) {
return textFor(source, node).split(/\r?\n/, 1)[0].trim().slice(0, 180);
}
function lastPathPart(value) {
return String(value ?? '').split(/[\/.]/).filter(Boolean).pop() ?? '';
}
function isIdentifierish(name) {
return /^[A-Za-z_$#][\w$#-]*$/.test(name) && name.length <= 120;
}
function normalizePath(value) {
return String(value).replace(/\\/g, '/').replace(/^\.\//, '');
}
function numberOption(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
+274
View File
@@ -0,0 +1,274 @@
import fs from 'node:fs';
import path from 'node:path';
import { readIfExists } from './paths.mjs';
import { summarize } from './telemetry.mjs';
function num(n) {
return Number.isFinite(Number(n)) ? Math.round(Number(n)).toLocaleString('en-US') : '0';
}
function ms(n) {
const v = Number(n);
if (!Number.isFinite(v) || v < 0) return '0s';
if (v < 1000) return `${Math.round(v)}ms`;
const s = Math.round(v / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const r = s % 60;
return r ? `${m}m${r}s` : `${m}m`;
}
function mdTable(rows, headers) {
if (!rows.length) return '_none_';
const esc = (v) => String(v ?? '').replaceAll('|', '\\|').replace(/\s+/g, ' ').trim();
return [
`| ${headers.join(' | ')} |`,
`| ${headers.map(() => '---').join(' | ')} |`,
...rows.map((row) => `| ${headers.map((h) => esc(row[h])).join(' | ')} |`),
].join('\n');
}
function tokenTotal(event) {
return Number(event?.['gen_ai.usage.input_tokens'] ?? 0) + Number(event?.['gen_ai.usage.output_tokens'] ?? 0);
}
function bucketRows(obj) {
return Object.entries(obj).map(([name, bucket]) => ({
name,
status: bucket.status ?? '—',
events: bucket.events,
time: ms(bucket.wallMs ?? bucket.durationMs),
in: num(bucket.inputTokens),
out: num(bucket.outputTokens),
total: num(bucket.totalTokens),
errors: bucket.errors,
}));
}
function operationRows(events, sorter, limit = 8) {
return [...events]
.filter((event) => Number(event.durationMs) > 0 || tokenTotal(event) > 0)
.sort(sorter)
.slice(0, limit)
.map((event) => ({
type: event.type,
name: event.name ?? '—',
status: event.status ?? '—',
model: event['gen_ai.request.model'] ?? '—',
time: ms(event.durationMs),
tokens: num(tokenTotal(event)),
}));
}
function signals(summary, events) {
const out = [];
if (summary.verify.failed) out.push(`${summary.verify.failed} verify step(s) failed.`);
if (summary.ralph.retries) out.push(`${summary.ralph.retries} Ralph retry event(s).`);
const repeatedErrors = Object.entries(
events
.filter((event) => event.type === 'error' || event.status === 'fail')
.reduce((acc, event) => {
const key = `${event.type}:${event.name ?? 'unnamed'}`;
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {}),
).filter(([, count]) => count > 1);
for (const [key, count] of repeatedErrors) out.push(`${key} repeated ${count}x.`);
const phases = Object.entries(summary.byPhase);
for (const [name, phase] of phases) {
if (summary.totals.totalTokens > 0 && phase.totalTokens / summary.totals.totalTokens >= 0.6) {
out.push(`Phase ${name} consumed ${Math.round((phase.totalTokens / summary.totals.totalTokens) * 100)}% tokens.`);
}
}
const maxRetryLane = Object.entries(summary.byLane).find(([, lane]) => lane.retries >= lane.iterations && lane.retries > 0);
if (maxRetryLane) out.push(`Lane ${maxRetryLane[0]} retried every Ralph iteration.`);
return out.length ? out : ['No obvious anomalies.'];
}
function collectFiles(events) {
const files = new Set();
for (const event of events) {
const value = event.attrs?.files ?? event.attrs?.file;
if (Array.isArray(value)) value.forEach((file) => files.add(file));
else if (value) files.add(value);
}
return [...files].sort();
}
function collectCommands(events) {
return events
.filter((event) => ['tool.call', 'verify', 'gate'].includes(event.type))
.map((event) => event.attrs?.command ?? event.name)
.filter(Boolean);
}
function criteriaRows(events) {
const rows = [];
for (const event of events.filter((e) => e.type === 'verify' || e.type === 'gate')) {
const criteria = event.attrs?.criteria;
if (Array.isArray(criteria)) {
for (const c of criteria) rows.push({ criterion: c, status: event.status ?? '—', evidence: event.name ?? event.type });
} else {
rows.push({ criterion: criteria ?? event.name ?? event.type, status: event.status ?? '—', evidence: event.attrs?.command ?? event.name ?? '—' });
}
}
return rows;
}
function templateRoot(runHeader) {
if (runHeader?.root) return runHeader.root;
const runDir = runHeader?.runDir;
if (runDir) return path.resolve(runDir, '../../..');
return process.cwd();
}
export function renderReport(runHeader, events) {
const summary = summarize(events);
const summaryRows = [
{ metric: 'status', value: runHeader?.status ?? 'running' },
{ metric: 'wall time', value: ms(summary.totals.wallMs) },
{ metric: 'input tokens', value: num(summary.totals.inputTokens) },
{ metric: 'output tokens', value: num(summary.totals.outputTokens) },
{ metric: 'total tokens', value: num(summary.totals.totalTokens) },
{ metric: 'tool calls', value: summary.totals.toolCalls },
{ metric: 'errors/fails', value: summary.totals.errors },
];
return [
`# Telemetry report: ${runHeader?.runId ?? 'unknown'}`,
'',
`**Objective:** ${runHeader?.objective ?? '—'}`,
`**Summary:** ${runHeader?.summary ?? '—'}`,
'',
'## Summary',
mdTable(summaryRows, ['metric', 'value']),
'',
'## Signals',
signals(summary, events).map((s) => `- ${s}`).join('\n'),
'',
'## Phase timing',
mdTable(bucketRows(summary.byPhase), ['name', 'status', 'events', 'time', 'in', 'out', 'total', 'errors']),
'',
'## Agent token usage',
mdTable(bucketRows(summary.byAgent), ['name', 'status', 'events', 'time', 'in', 'out', 'total', 'errors']),
'',
'## Model usage',
mdTable(bucketRows(summary.byModel), ['name', 'status', 'events', 'time', 'in', 'out', 'total', 'errors']),
'',
'## Lane outcomes',
mdTable(
Object.entries(summary.byLane).map(([name, lane]) => ({
lane: name,
kind: lane.kind ?? '—',
status: lane.status ?? '—',
time: ms(lane.wallMs ?? lane.durationMs),
tokens: num(lane.totalTokens),
iterations: lane.iterations,
retries: lane.retries,
errors: lane.errors,
})),
['lane', 'kind', 'status', 'time', 'tokens', 'iterations', 'retries', 'errors'],
),
'',
'## Ralph',
mdTable([{ iterations: summary.ralph.iterations, retries: summary.ralph.retries }], ['iterations', 'retries']),
'',
'## Verify',
mdTable([{ passed: summary.verify.passed, failed: summary.verify.failed }], ['passed', 'failed']),
'',
'## Gates',
mdTable(
Object.entries(summary.gates).map(([name, gate]) => ({
gate: name,
passed: gate.passed,
failed: gate.failed,
skipped: gate.skipped,
blocked: gate.blocked,
last: gate.lastStatus ?? '—',
})),
['gate', 'passed', 'failed', 'skipped', 'blocked', 'last'],
),
'',
'## Slowest operations',
mdTable(operationRows(events, (a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0)), [
'type',
'name',
'status',
'model',
'time',
'tokens',
]),
'',
'## Most expensive operations',
mdTable(operationRows(events, (a, b) => tokenTotal(b) - tokenTotal(a)), ['type', 'name', 'status', 'model', 'time', 'tokens']),
'',
].join('\n');
}
export function renderJournal(runHeader, events, { spec } = {}) {
const summary = summarize(events);
const phases = Object.keys(summary.byPhase);
const lanes = Object.entries(summary.byLane).map(([lane, data]) => `${lane}${data.kind ? ` (${data.kind})` : ''}`);
const files = collectFiles(events);
const commands = collectCommands(events);
const criteria = criteriaRows(events);
const followUps = events
.filter((event) => event.type === 'error' || event.attrs?.followUp)
.map((event) => event.attrs?.followUp ?? `${event.type}: ${event.name ?? 'unnamed'} (${event.status ?? 'unknown'})`);
const values = {
runId: runHeader?.runId ?? 'unknown',
objective: runHeader?.objective ?? '—',
spec: spec ?? runHeader?.spec ?? '—',
status: runHeader?.status ?? 'running',
summary: runHeader?.summary ?? '—',
plan: phases.length ? phases.map((p) => `- ${p}`).join('\n') : '- No phases recorded.',
lanes: lanes.length ? lanes.map((lane) => `- ${lane}`).join('\n') : '- No lanes recorded.',
files: files.length ? files.map((file) => `- ${file}`).join('\n') : '- None recorded.',
commands: commands.length ? commands.map((cmd) => `- ${cmd}`).join('\n') : '- None recorded.',
tokens: `${num(summary.totals.totalTokens)} total (${num(summary.totals.inputTokens)} in / ${num(summary.totals.outputTokens)} out)`,
wallTime: ms(summary.totals.wallMs),
criteria: criteria.length ? mdTable(criteria, ['criterion', 'status', 'evidence']) : '_none recorded_',
followUps: followUps.length ? followUps.map((item) => `- ${item}`).join('\n') : '- None.',
};
const skeleton = readIfExists(path.join(templateRoot(runHeader), 'templates', 'journal.md'));
if (skeleton) {
return skeleton.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? '');
}
return [
`# Run journal: ${values.runId}`,
'',
`**Objective:** ${values.objective}`,
`**Spec:** ${values.spec}`,
`**Status:** ${values.status}`,
`**Summary:** ${values.summary}`,
'',
'## Executed plan',
values.plan,
'',
'## Lanes and scopes',
values.lanes,
'',
'## Files touched',
values.files,
'',
'## Commands run',
values.commands,
'',
'## Telemetry',
`- Tokens: ${values.tokens}`,
`- Wall time: ${values.wallTime}`,
'',
'## Acceptance criteria',
values.criteria,
'',
'## Follow-ups',
values.followUps,
'',
].join('\n');
}
+318
View File
@@ -0,0 +1,318 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { loadConfig, defaultConfig } from './config.mjs';
import { paths, ensureDir, readIfExists, writeFile } from './paths.mjs';
import { renderBoard } from './board.mjs';
function configFor(ctx = {}) {
return ctx.config ?? loadConfig(ctx.cwd ?? process.cwd())?.config ?? defaultConfig();
}
function pathsFor(ctx = {}) {
return paths(configFor(ctx), ctx.cwd ?? process.cwd());
}
function nowIso() {
return new Date().toISOString();
}
function spanId() {
return crypto.randomBytes(8).toString('hex');
}
function rand4() {
return crypto.randomBytes(3).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').slice(0, 4).padEnd(4, '0');
}
function readHeader(ctx, runId) {
const file = path.join(pathsFor(ctx).run(runId), 'run.json');
const raw = readIfExists(file);
if (!raw) return { runId };
try {
return JSON.parse(raw);
} catch {
return { runId };
}
}
function writeHeader(ctx, runId, header) {
writeFile(path.join(pathsFor(ctx).run(runId), 'run.json'), `${JSON.stringify(header, null, 2)}\n`);
}
function refreshBoard(ctx, runId) {
const p = pathsFor(ctx);
const header = readHeader(ctx, runId);
const events = readEvents(ctx, runId);
writeFile(path.join(p.run(runId), 'board.md'), renderBoard(header, events));
}
export function newRunId() {
const d = new Date();
const stamp = d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '').replace('T', 'T');
return `${stamp}-${rand4()}`;
}
export function startRun(ctx = {}, { objective, spec, host, strategy, attrs = {} } = {}) {
const runId = attrs.runId ?? newRunId();
const p = pathsFor(ctx);
const dir = p.run(runId);
ensureDir(dir);
const header = {
runId,
objective: objective ?? '',
spec: spec ?? null,
host: host ?? null,
strategy: strategy ?? null,
status: 'running',
startedAt: nowIso(),
endedAt: null,
summary: null,
totals: null,
attrs,
};
writeHeader(ctx, runId, header);
appendEvent(ctx, runId, {
type: 'run.start',
name: 'run',
status: 'ok',
'gen_ai.operation.name': 'run.start',
attrs: { objective, spec, host, strategy, ...attrs },
});
return runId;
}
export function appendEvent(ctx = {}, runId, event = {}) {
if (!runId) throw new Error('runId is required');
const p = pathsFor(ctx);
const dir = p.run(runId);
ensureDir(dir);
const filled = {
ts: event.ts ?? nowIso(),
runId,
spanId: event.spanId ?? spanId(),
parentSpanId: event.parentSpanId ?? null,
type: event.type ?? 'note',
name: event.name ?? null,
status: event.status ?? null,
durationMs: event.durationMs === undefined ? null : Number(event.durationMs),
'gen_ai.request.model': event['gen_ai.request.model'] ?? null,
'gen_ai.operation.name': event['gen_ai.operation.name'] ?? event.type ?? null,
'gen_ai.usage.input_tokens':
event['gen_ai.usage.input_tokens'] === undefined ? 0 : Number(event['gen_ai.usage.input_tokens']),
'gen_ai.usage.output_tokens':
event['gen_ai.usage.output_tokens'] === undefined ? 0 : Number(event['gen_ai.usage.output_tokens']),
attrs: event.attrs ?? {},
};
fs.appendFileSync(path.join(dir, 'events.ndjson'), `${JSON.stringify(filled)}\n`, {
encoding: 'utf8',
flag: 'a',
});
try {
refreshBoard(ctx, runId);
} catch {
// Telemetry writes must survive board rendering bugs.
}
return filled;
}
export function endRun(ctx = {}, runId, { status = 'ok', summary = '' } = {}) {
const header = readHeader(ctx, runId);
const start = Date.parse(header.startedAt ?? new Date().toISOString());
const durationMs = Number.isFinite(start) ? Date.now() - start : null;
appendEvent(ctx, runId, {
type: 'run.end',
name: 'run',
status,
durationMs,
'gen_ai.operation.name': 'run.end',
attrs: { summary },
});
const events = readEvents(ctx, runId);
const totals = summarize(events).totals;
const updated = {
...header,
status,
summary,
endedAt: nowIso(),
totals,
};
writeHeader(ctx, runId, updated);
try {
refreshBoard(ctx, runId);
} catch {
// Best-effort board refresh only.
}
return updated;
}
export function readEvents(ctx = {}, runId) {
if (!runId) return [];
const file = path.join(pathsFor(ctx).run(runId), 'events.ndjson');
const raw = readIfExists(file);
if (!raw) return [];
const events = [];
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event && typeof event === 'object') events.push(event);
} catch {
// Ignore malformed crash residue.
}
}
return events;
}
export function latestRunId(ctx = {}) {
const runs = pathsFor(ctx).runs;
try {
const dirs = fs
.readdirSync(runs, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
return dirs.at(-1) ?? null;
} catch {
return null;
}
}
function emptyBucket() {
return {
events: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
durationMs: 0,
toolCalls: 0,
errors: 0,
status: null,
};
}
function add(bucket, event) {
const input = Number(event?.['gen_ai.usage.input_tokens'] ?? 0) || 0;
const output = Number(event?.['gen_ai.usage.output_tokens'] ?? 0) || 0;
bucket.events += 1;
bucket.inputTokens += input;
bucket.outputTokens += output;
bucket.totalTokens += input + output;
bucket.durationMs += Number(event?.durationMs ?? 0) || 0;
if (event.type === 'tool.call') bucket.toolCalls += 1;
if (event.type === 'error' || event.status === 'fail') bucket.errors += 1;
if (event.status) bucket.status = event.status;
}
function sortedEntries(obj) {
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
}
export function summarize(events = []) {
const totals = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
wallMs: 0,
toolCalls: 0,
errors: 0,
};
const byPhase = {};
const byAgent = {};
const byModel = {};
const byLane = {};
const verify = { passed: 0, failed: 0 };
const gates = {};
const ralph = { iterations: 0, retries: 0 };
const phaseStarts = {};
const laneStarts = {};
let activePhase = null;
const validDates = events.map((e) => Date.parse(e.ts)).filter(Number.isFinite);
if (validDates.length) totals.wallMs = Math.max(...validDates) - Math.min(...validDates);
for (const event of events) {
const input = Number(event?.['gen_ai.usage.input_tokens'] ?? 0) || 0;
const output = Number(event?.['gen_ai.usage.output_tokens'] ?? 0) || 0;
totals.inputTokens += input;
totals.outputTokens += output;
totals.totalTokens += input + output;
if (event.type === 'tool.call') totals.toolCalls += 1;
if (event.type === 'error' || event.status === 'fail') totals.errors += 1;
if (event.type === 'phase.start') {
activePhase = event.name ?? 'phase';
phaseStarts[activePhase] = Date.parse(event.ts);
}
const phaseName = event.attrs?.phase ?? (event.type?.startsWith('phase.') ? event.name : activePhase);
if (phaseName) {
byPhase[phaseName] ??= emptyBucket();
add(byPhase[phaseName], event);
if (event.type === 'phase.end') {
const start = phaseStarts[phaseName];
const end = Date.parse(event.ts);
if (Number.isFinite(start) && Number.isFinite(end)) byPhase[phaseName].wallMs = Math.max(0, end - start);
if (activePhase === phaseName) activePhase = null;
}
}
const agentName = event.attrs?.agent ?? (event.type?.startsWith('agent.') ? event.name : null);
if (agentName) {
byAgent[agentName] ??= emptyBucket();
add(byAgent[agentName], event);
}
const model = event['gen_ai.request.model'];
if (model) {
byModel[model] ??= emptyBucket();
add(byModel[model], event);
}
const laneId = event.attrs?.laneId ?? event.attrs?.lane ?? (event.type?.startsWith('lane.') ? event.name : null);
if (laneId) {
byLane[laneId] ??= { ...emptyBucket(), kind: event.attrs?.kind ?? null, iterations: 0, retries: 0, wallMs: 0 };
add(byLane[laneId], event);
byLane[laneId].kind = event.attrs?.kind ?? byLane[laneId].kind;
if (event.type === 'lane.start') laneStarts[laneId] = Date.parse(event.ts);
if (event.type === 'lane.end') {
const start = laneStarts[laneId];
const end = Date.parse(event.ts);
if (Number.isFinite(start) && Number.isFinite(end)) byLane[laneId].wallMs = Math.max(0, end - start);
}
if (event.type === 'ralph.iteration') {
byLane[laneId].iterations += 1;
if (event.status === 'fail' || event.attrs?.retry) byLane[laneId].retries += 1;
}
}
if (event.type === 'ralph.iteration') {
ralph.iterations += 1;
if (event.status === 'fail' || event.attrs?.retry) ralph.retries += 1;
}
if (event.type === 'verify') {
if (event.status === 'ok') verify.passed += 1;
if (event.status === 'fail') verify.failed += 1;
}
if (event.type === 'gate') {
const name = event.name ?? 'gate';
gates[name] ??= { passed: 0, failed: 0, skipped: 0, blocked: 0, lastStatus: null };
if (event.status === 'ok') gates[name].passed += 1;
else if (event.status === 'fail') gates[name].failed += 1;
else if (event.status === 'skipped') gates[name].skipped += 1;
else if (event.status === 'blocked') gates[name].blocked += 1;
gates[name].lastStatus = event.status ?? gates[name].lastStatus;
}
}
return {
totals,
byPhase: sortedEntries(byPhase),
byAgent: sortedEntries(byAgent),
byModel: sortedEntries(byModel),
byLane: sortedEntries(byLane),
ralph,
verify,
gates: sortedEntries(gates),
};
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Terse output helpers. The harness prints briefs, never dumps —
* every line here is consumed by an LLM, so bytes cost money.
*/
const NO_COLOR = process.env.NO_COLOR !== undefined || !process.stdout.isTTY;
const wrap = (code, s) => (NO_COLOR ? s : `\u001b[${code}m${s}\u001b[0m`);
export const dim = (s) => wrap('2', s);
export const bold = (s) => wrap('1', s);
export const red = (s) => wrap('31', s);
export const green = (s) => wrap('32', s);
export const yellow = (s) => wrap('33', s);
export function out(line = '') {
process.stdout.write(`${line}\n`);
}
export function err(line) {
process.stderr.write(`${line}\n`);
}
export function fail(message, code = 1) {
err(`${red('error')} ${message}`);
process.exit(code);
}
export function ok(message) {
out(`${green('ok')} ${message}`);
}
export function warn(message) {
out(`${yellow('warn')} ${message}`);
}
/** Render an aligned table. Used by report/board/graph briefs. */
export function table(rows, headers) {
if (!rows.length) return '';
const cols = headers ?? Object.keys(rows[0]);
const widths = cols.map((c) =>
Math.max(String(c).length, ...rows.map((r) => String(r[c] ?? '').length)),
);
const line = (cells) => cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' ').trimEnd();
return [line(cols), line(widths.map((w) => '-'.repeat(w))), ...rows.map((r) => line(cols.map((c) => r[c])))].join(
'\n',
);
}
/** Minimal flag parser: --key value, --key=value, --bool, positionals. */
export function parseArgs(argv) {
const flags = {};
const positional = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) {
positional.push(arg);
continue;
}
const body = arg.slice(2);
const eq = body.indexOf('=');
if (eq !== -1) {
flags[body.slice(0, eq)] = body.slice(eq + 1);
} else if (argv[i + 1] && !argv[i + 1].startsWith('--')) {
flags[body] = argv[i + 1];
i += 1;
} else {
flags[body] = true;
}
}
return { flags, positional };
}
+23
View File
@@ -0,0 +1,23 @@
# ADR {{ID}}: {{TITLE}}
## Status
Proposed
## Context
What forces, constraints, and facts drive this decision?
## Decision
What option is chosen?
## Consequences
- Positive:
- Negative:
- Follow-up:
## Alternatives considered
- Option: why not.
+30
View File
@@ -0,0 +1,30 @@
# {{PROJECT}} architecture
<!-- Living document. Update when components, data flow, invariants, or ADR outcomes change. Keep claims concrete and current. -->
## Purpose
Describe what this system exists to do, who uses it, and what success means.
## System context
List upstream callers, downstream services, data stores, queues, files, and trust boundaries.
## Components
- Component: responsibility, owner, key files.
## Data flow
1. Input source.
2. Validation and transformation.
3. Persistence or external calls.
4. Output and observability.
## Key invariants
- Invariant: why it must hold, where enforced, how verified.
## Decision log (ADRs)
<!-- Append ADR links or summaries here. Newest last. -->
+38
View File
@@ -0,0 +1,38 @@
# {{PROJECT}} conventions
<!-- Living document. Promote repeated review feedback and user corrections here. Keep rules specific, checkable, and current. -->
## Code style
- Prefer simple control flow and explicit names.
- Keep side effects at boundaries.
## Naming
- Use domain terms consistently.
- Name files after their primary responsibility.
## Testing
- Cover changed behaviour with the smallest meaningful test.
- Add regression tests for fixed bugs.
## Error handling
- Fail fast on invalid inputs.
- Preserve actionable error context without leaking secrets.
## Commit format
- Use concise conventional commits when possible.
- Explain why in the body when not obvious.
## Directory layout
- Keep generated artifacts out of source directories.
- Put shared deterministic helpers in library modules.
## Review rules
- Verify scoped files only.
- Flag correctness, security, and contract drift before style.
+42
View File
@@ -0,0 +1,42 @@
# Run journal: {{RUN_ID}}
## Objective
{{OBJECTIVE}}
## Plan
- Step:
## Lanes
| lane | role | scope | status |
| --- | --- | --- | --- |
## Files touched
- path: reason
## Commands run
| command | result |
| --- | --- |
## Tokens
- input:
- output:
- cache:
## Wall time
- started:
- ended:
## Outcome
- Result:
## Follow-ups
- Item:
+10
View File
@@ -0,0 +1,10 @@
# Memory index
Only this file is always loaded. Open shard files only when needed.
updated: {{UPDATED}}
total_tokens: {{TOTAL_TOKENS}}
| shard | entries | tokens | updated | summary |
| --- | ---: | ---: | --- | --- |
{{SHARDS}}
+9
View File
@@ -0,0 +1,9 @@
# {{SHARD}} memory
{{DESCRIPTION}}
Entries use:
`## <ISO timestamp> — <title>`
Optional next line: `tags: a, b`
+67
View File
@@ -0,0 +1,67 @@
# Design questionnaire: {{TITLE}}
Edit answers in place, keep one numbered answer per question, then say "go". Recommended answers are marked.
## 1. Primary goal?
1. **Recommended:** Implement smallest complete behaviour that satisfies acceptance criteria.
2. Explore architecture only; no code changes.
3. Prototype disposable spike.
Answer: 1
## 2. Risk tolerance?
1. **Recommended:** Conservative; preserve existing contracts and APIs.
2. Moderate; allow internal refactors when verified.
3. Aggressive; redesign if simpler.
Answer: 1
## 3. Compatibility target?
1. **Recommended:** Backward compatible unless requirement says otherwise.
2. Breaking changes allowed with migration notes.
3. New surface only; no compatibility promise.
Answer: 1
## 4. Verification depth?
1. **Recommended:** Run targeted tests plus configured verify commands that cover changed behaviour.
2. Run targeted tests only.
3. Full suite required before completion.
Answer: 1
## 5. Documentation updates?
1. **Recommended:** Update docs directly tied to changed behaviour.
2. No docs unless tests require them.
3. Full architecture/conventions refresh.
Answer: 1
## 6. Parallelism?
1. **Recommended:** Use read-only fan-out for independent research; serialize writes by scope.
2. Single-lane implementation.
3. Maximize lanes even for moderate coupling.
Answer: 1
## 7. External services?
1. **Recommended:** Avoid new external calls unless existing workflow requires them.
2. Use documented services when helpful.
3. Ask user before any external call.
Answer: 1
## 8. Secret handling?
1. **Recommended:** Never write secrets; require env vars or existing secret stores.
2. Allow local uncommitted secret files.
3. Inline placeholders only.
Answer: 1
+32
View File
@@ -0,0 +1,32 @@
# Spec: {{TITLE}}
## Problem
What user or system problem must be solved?
## Scope
### In
- Included behaviour.
### Out
- Explicit non-goals.
## Acceptance criteria
1. Checkable outcome.
2. Checkable outcome.
## Constraints
- Technical, product, security, timing, or compatibility constraints.
## Risks
- Risk: mitigation.
## Verification plan
- Command or manual check: expected result.
+287
View File
@@ -0,0 +1,287 @@
import { test, describe, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { tempRepo, write, commitAll, lh, cleanup } from './helpers.mjs';
after(cleanup);
const PKG = JSON.stringify({
name: 'fixture',
version: '1.0.0',
scripts: { test: "node -e \"console.log('ok')\"" },
}, null, 2);
/** A brownfield repo carrying two planted structural defects. */
function brownfield() {
return tempRepo({
'package.json': PKG,
'src/a.js': 'export function helper(x) { return x + 1; }\nexport function shared() { return 1; }\n',
'src/b.js': "import { helper } from './a.js';\nexport function shared() { return 2; }\nexport function main() { return helper(missingFn()); }\n",
'README.md': '# fixture\n',
});
}
describe('cli contract', () => {
test('--help exits 0 and lists the command surface', () => {
const r = lh(process.cwd(), ['--help']);
assert.equal(r.code, 0);
for (const cmd of ['init', 'index', 'graph', 'lane', 'run', 'memory', 'host', 'report', 'doctor']) {
assert.ok(r.stdout.includes(cmd), `help should mention ${cmd}`);
}
});
test('an unknown command fails rather than silently succeeding', () => {
assert.notEqual(lh(process.cwd(), ['no-such-command']).code, 0);
});
test('host detection always emits a usable strategy', () => {
const r = lh(brownfield(), ['host', '--json']);
assert.equal(r.code, 0);
const host = JSON.parse(r.stdout);
assert.ok(host.host, 'a host must always be named');
assert.ok(host.maxWriteLanes >= 1, 'must allow at least one write lane');
assert.ok(host.readOnlyFanOut >= 1, 'must allow at least one scout');
});
});
describe('init (greenfield + brownfield)', () => {
test('greenfield init creates config and memory shards', () => {
const dir = tempRepo({ 'README.md': '# empty\n' });
assert.equal(lh(dir, ['init', '--yes']).code, 0);
assert.ok(existsSync(join(dir, '.agents/harness.config.json')));
assert.ok(existsSync(join(dir, '.agents/memory/INDEX.md')));
for (const shard of ['failures', 'corrections', 'insights', 'conventions', 'quirks']) {
assert.ok(existsSync(join(dir, `.agents/memory/${shard}.md`)), `missing shard ${shard}`);
}
});
test('brownfield init autodetects the existing verify command', () => {
const dir = brownfield();
const r = lh(dir, ['init', '--yes', '--json']);
assert.equal(r.code, 0);
const out = JSON.parse(r.stdout);
assert.ok(out.verify.some((v) => v.command.includes('test')), 'should detect npm test');
});
test('the Context7 key is never written to disk', () => {
const dir = brownfield();
lh(dir, ['init', '--yes']);
const raw = readFileSync(join(dir, '.agents/harness.config.json'), 'utf8');
assert.ok(raw.includes('CONTEXT7_API_KEY'), 'config should reference the env var by name');
if (process.env.CONTEXT7_API_KEY) {
assert.ok(!raw.includes(process.env.CONTEXT7_API_KEY), 'the key value must never be persisted');
}
});
test('re-running init refuses to clobber an existing config', () => {
const dir = brownfield();
assert.equal(lh(dir, ['init', '--yes']).code, 0);
assert.notEqual(lh(dir, ['init', '--yes']).code, 0, 'second init must refuse');
});
test('doctor passes once the repo is initialised', () => {
const dir = brownfield();
lh(dir, ['init', '--yes']);
lh(dir, ['index']);
assert.equal(lh(dir, ['doctor']).code, 0);
});
test('doctor fails on an uninitialised repo', () => {
assert.notEqual(lh(tempRepo({ 'README.md': '#\n' }), ['doctor']).code, 0);
});
});
describe('index and structural gate', () => {
test('index works offline and reports symbols', () => {
const dir = brownfield();
lh(dir, ['init', '--yes']);
const r = lh(dir, ['index', '--stats']);
assert.equal(r.code, 0);
assert.match(r.stdout, /symbols\s+[1-9]/, 'should discover symbols without network access');
assert.ok(existsSync(join(dir, '.agents/.cache/symbols.json')));
});
test('a second index run hits the cache', () => {
const dir = brownfield();
lh(dir, ['init', '--yes']);
lh(dir, ['index']);
assert.match(lh(dir, ['index', '--stats']).stdout, /cacheHits\s+[1-9]/);
});
test('the gate exits 1 and names both planted defects', () => {
const dir = brownfield();
lh(dir, ['init', '--yes']);
lh(dir, ['index']);
const r = lh(dir, ['graph', '--brief']);
assert.equal(r.code, 1, 'structural violations must exit 1 — the Ralph loop gates on this');
assert.match(r.stdout, /duplicate-definition/);
assert.match(r.stdout, /shared/);
assert.match(r.stdout, /unresolved-call/);
assert.match(r.stdout, /missingFn/);
});
test('the gate exits 0 on a clean repo', () => {
const dir = tempRepo({
'package.json': PKG,
'src/a.js': 'export function helper(x) { return x + 1; }\n',
'src/b.js': "import { helper } from './a.js';\nexport function main() { return helper(1); }\n",
});
lh(dir, ['init', '--yes']);
lh(dir, ['index']);
const r = lh(dir, ['graph', '--brief']);
assert.equal(r.code, 0, `clean repo must pass, got: ${r.stdout}${r.stderr}`);
});
});
describe('parallel write lanes', () => {
function laneRepo() {
const dir = tempRepo({
'package.json': PKG,
'src/a/deep/f.txt': 'one\n',
'src/b/g.txt': 'two\n',
'root.txt': 'base\n',
});
lh(dir, ['init', '--yes']);
return dir;
}
test('disjoint lanes both get a worktree', () => {
const dir = laneRepo();
assert.equal(lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']).code, 0);
assert.equal(lh(dir, ['lane', 'create', '--id', 'b', '--title', 'B', '--kind', 'write', '--scope', 'src/b/**']).code, 0);
assert.match(lh(dir, ['lane', 'list']).stdout, /\ba\b[\s\S]*\bb\b/);
});
test('an overlapping write lane is rejected', () => {
const dir = laneRepo();
lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']);
// src/a/deep/** is a strict subset of src/a/** — must still be refused.
const r = lh(dir, ['lane', 'create', '--id', 'c', '--title', 'C', '--kind', 'write', '--scope', 'src/a/deep/**']);
assert.notEqual(r.code, 0, 'overlapping write scopes must never both be leased');
assert.match(r.stderr + r.stdout, /overlap|intersect/i);
});
test('out-of-scope writes are detected and exit 1', () => {
const dir = laneRepo();
lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']);
const wt = join(dir, '.agents/.cache/worktrees/a');
write(wt, 'src/a/deep/f.txt', 'legit edit\n');
write(wt, 'root.txt', 'OUT OF SCOPE\n');
commitAll(wt, 'one legit, one violation');
const r = lh(dir, ['lane', 'status', 'a']);
assert.equal(r.code, 1, 'scope violation is Ralph exit criterion 5 — must be machine-detectable');
assert.match(r.stdout, /root\.txt/);
assert.match(r.stdout, /OUT/);
});
test('a clean lane merges, then a conflicting lane is blocked not auto-resolved', () => {
const dir = laneRepo();
lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']);
lh(dir, ['lane', 'create', '--id', 'b', '--title', 'B', '--kind', 'write', '--scope', 'src/b/**']);
const wtA = join(dir, '.agents/.cache/worktrees/a');
write(wtA, 'root.txt', 'LANE A\n');
commitAll(wtA, 'lane a');
const wtB = join(dir, '.agents/.cache/worktrees/b');
write(wtB, 'root.txt', 'LANE B\n');
commitAll(wtB, 'lane b');
assert.equal(lh(dir, ['lane', 'merge', 'a']).code, 0, 'first merge should be clean');
const conflict = lh(dir, ['lane', 'merge', 'b']);
assert.notEqual(conflict.code, 0, 'a conflict must fail loudly');
assert.match(conflict.stdout + conflict.stderr, /root\.txt/);
assert.match(lh(dir, ['lane', 'list']).stdout, /blocked/);
});
test('merge --abort restores a clean tree', () => {
const dir = laneRepo();
lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']);
lh(dir, ['lane', 'create', '--id', 'b', '--title', 'B', '--kind', 'write', '--scope', 'src/b/**']);
for (const id of ['a', 'b']) {
const wt = join(dir, `.agents/.cache/worktrees/${id}`);
write(wt, 'root.txt', `LANE ${id}\n`);
commitAll(wt, `lane ${id}`);
}
lh(dir, ['lane', 'merge', 'a']);
lh(dir, ['lane', 'merge', 'b']);
assert.equal(lh(dir, ['lane', 'merge', 'b', '--abort']).code, 0);
assert.equal(lh(dir, ['lane', 'drop', 'b', '--force']).code, 0);
});
});
describe('memory', () => {
function memRepo() {
const dir = brownfield();
lh(dir, ['init', '--yes']);
return dir;
}
test('put then retrieve by query', () => {
const dir = memRepo();
assert.equal(lh(dir, ['memory', 'put', '--shard', 'failures', '--title', 'flaky suite', '--body', 'Retry once before reporting.']).code, 0);
const r = lh(dir, ['memory', 'get', '--query', 'flaky']);
assert.equal(r.code, 0);
assert.match(r.stdout, /Retry once/);
});
test('writing a secret is refused', () => {
const dir = memRepo();
const r = lh(dir, ['memory', 'put', '--shard', 'insights', '--title', 'leak',
'--body', 'key ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789']);
assert.notEqual(r.code, 0, 'secrets must never reach the memory shards');
const shard = readFileSync(join(dir, '.agents/memory/insights.md'), 'utf8');
assert.ok(!shard.includes('ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), 'shard must stay clean');
});
test('INDEX.md stays small — it is the only always-loaded file', () => {
const dir = memRepo();
for (let i = 0; i < 20; i++) {
lh(dir, ['memory', 'put', '--shard', 'insights', '--title', `note ${i}`, '--body', 'x'.repeat(200)]);
}
const index = readFileSync(join(dir, '.agents/memory/INDEX.md'), 'utf8');
assert.ok(index.length < 2000, `INDEX.md must stay lean, was ${index.length} bytes`);
});
});
describe('telemetry', () => {
test('a full run produces events, a board and a report', () => {
const dir = brownfield();
lh(dir, ['init', '--yes']);
const start = lh(dir, ['run', 'start', '--objective', 'test run', '--json']);
assert.equal(start.code, 0);
const runId = JSON.parse(start.stdout).runId;
assert.ok(runId, 'run start must return a runId');
assert.equal(lh(dir, ['run', 'event', '--run', runId, '--type', 'agent.end',
'--agent', 'scout', '--input-tokens', '100', '--output-tokens', '50']).code, 0);
assert.equal(lh(dir, ['run', 'end', '--run', runId]).code, 0);
assert.ok(existsSync(join(dir, `.agents/runs/${runId}/events.ndjson`)));
assert.ok(existsSync(join(dir, `.agents/runs/${runId}/board.md`)));
const report = lh(dir, ['report', runId, '--json']);
assert.equal(report.code, 0);
assert.equal(JSON.parse(report.stdout).totals.totalTokens, 150);
});
test('a malformed NDJSON line does not break reporting', () => {
const dir = brownfield();
lh(dir, ['init', '--yes']);
const runId = JSON.parse(lh(dir, ['run', 'start', '--objective', 'test run', '--json']).stdout).runId;
lh(dir, ['run', 'event', '--run', runId, '--type', 'agent.end', '--input-tokens', '10']);
const events = join(dir, `.agents/runs/${runId}/events.ndjson`);
appendFileSync(events, '{ this is not json\n');
lh(dir, ['run', 'event', '--run', runId, '--type', 'agent.end', '--input-tokens', '5']);
const r = lh(dir, ['report', runId, '--json']);
assert.equal(r.code, 0, 'the parser must tolerate corrupt lines');
assert.equal(JSON.parse(r.stdout).totals.inputTokens, 15, 'valid events must still be counted');
});
});
+56
View File
@@ -0,0 +1,56 @@
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const CLI = join(ROOT, 'src', 'cli.mjs');
const created = [];
export function tempRepo(files = {}) {
const dir = mkdtempSync(join(tmpdir(), 'lh-test-'));
created.push(dir);
git(dir, ['init', '-q', '-b', 'main']);
git(dir, ['config', 'user.email', 'test@example.com']);
git(dir, ['config', 'user.name', 'Test']);
git(dir, ['config', 'commit.gpgsign', 'false']);
for (const [rel, body] of Object.entries(files)) write(dir, rel, body);
if (Object.keys(files).length) commitAll(dir, 'init');
return dir;
}
export function write(dir, rel, body) {
const full = join(dir, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, body);
return full;
}
export function git(dir, args) {
return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
}
export function commitAll(dir, msg) {
git(dir, ['add', '-A']);
git(dir, ['commit', '-qm', msg]);
}
/** Run the lh CLI. Never throws — returns {code, stdout, stderr}. */
export function lh(dir, args) {
try {
const stdout = execFileSync(process.execPath, [CLI, ...args], {
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
});
return { code: 0, stdout, stderr: '' };
} catch (e) {
return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
}
}
export function cleanup() {
while (created.length) {
try { rmSync(created.pop(), { recursive: true, force: true }); } catch {}
}
}
+158
View File
@@ -0,0 +1,158 @@
import { test, describe, after } from 'node:test';
import assert from 'node:assert/strict';
import { defaultConfig, validateConfig, modelForRole, MEMORY_SHARDS, RALPH_EXIT_CRITERIA } from '../src/lib/config.mjs';
import { globsIntersect } from '../src/lib/lane.mjs';
import { scanSecrets } from '../src/lib/memory.mjs';
import { summarize } from '../src/lib/telemetry.mjs';
import { globMatch } from '../src/lib/repomap.mjs';
import { cleanup } from './helpers.mjs';
after(cleanup);
describe('config', () => {
test('default config is valid', () => {
assert.deepEqual(validateConfig(defaultConfig()), []);
});
test('rejects a bad isolation backend', () => {
const c = defaultConfig();
c.isolation.backend = 'kubernetes';
assert.ok(validateConfig(c).some((p) => p.includes('isolation.backend')));
});
test('rejects an unknown ralph exit criterion', () => {
const c = defaultConfig();
c.ralph.exitCriteria = ['vibes'];
assert.ok(validateConfig(c).some((p) => p.includes('vibes')));
});
test('rejects a non-positive memory budget', () => {
const c = defaultConfig();
c.memory.tokenBudget = 0;
assert.ok(validateConfig(c).some((p) => p.includes('memory.tokenBudget')));
});
test('cheap roles resolve to the cheap tier model', () => {
const c = defaultConfig();
const cheap = modelForRole(c, 'scout');
assert.equal(cheap, modelForRole(c, 'verifier'));
assert.notEqual(cheap, modelForRole(c, 'conductor'));
});
test('a role override is honoured', () => {
const c = defaultConfig();
c.models.roles.scout = 'strong';
assert.equal(modelForRole(c, 'scout'), modelForRole(c, 'conductor'));
});
test('exposes the five memory shards plus seed', () => {
for (const s of ['failures', 'corrections', 'insights', 'conventions', 'quirks']) {
assert.ok(MEMORY_SHARDS.includes(s), `missing shard ${s}`);
}
assert.ok(RALPH_EXIT_CRITERIA.length >= 5);
});
});
describe('lane scope safety', () => {
// The load-bearing invariant: two write lanes must never share a file.
const overlapping = [
['src/**', 'src/a/deep/**'],
['src/a/**', 'src/a/**'],
['**', 'anything/at/all.js'],
['src/a.js', 'src/a.js'],
['src/*.js', 'src/a.js'],
['lib/**/*.ts', 'lib/deep/nested/x.ts'],
];
for (const [a, b] of overlapping) {
test(`rejects overlap: ${a} vs ${b}`, () => {
assert.equal(globsIntersect(a, b), true, `${a} should intersect ${b}`);
assert.equal(globsIntersect(b, a), true, 'intersection must be symmetric');
});
}
const disjoint = [
['src/a/**', 'src/b/**'],
['src/a.js', 'src/b.js'],
['docs/**', 'src/**'],
['lib/**/*.ts', 'lib/x.js'],
];
for (const [a, b] of disjoint) {
test(`allows disjoint: ${a} vs ${b}`, () => {
assert.equal(globsIntersect(a, b), false, `${a} should not intersect ${b}`);
assert.equal(globsIntersect(b, a), false, 'disjointness must be symmetric');
});
}
});
describe('glob matching', () => {
test('** crosses directory boundaries', () => {
assert.equal(globMatch('src/**', 'src/a/b/c.js'), true);
});
test('* does not cross directory boundaries', () => {
assert.equal(globMatch('src/*.js', 'src/a/b.js'), false);
});
test('non-matching prefix fails', () => {
assert.equal(globMatch('src/**', 'docs/a.js'), false);
});
});
describe('secret scanning', () => {
const secrets = [
['github token', 'token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'],
['openai key', 'sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrst'],
['private key', '-----BEGIN RSA PRIVATE KEY-----'],
['aws key', 'AKIAIOSFODNN7EXAMPLE'],
];
for (const [name, text] of secrets) {
test(`detects a ${name}`, () => {
const hits = scanSecrets(text);
assert.ok(hits.length > 0, `expected a hit for ${name}`);
});
}
test('does not fire on ordinary prose', () => {
assert.deepEqual(scanSecrets('The build failed because the test timed out after 30s.'), []);
});
test('never echoes the raw secret back', () => {
const raw = 'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
for (const hit of scanSecrets(`token ${raw}`)) {
assert.ok(!JSON.stringify(hit).includes(raw), 'secret must be redacted in the finding');
}
});
});
describe('telemetry summarize', () => {
test('sums token usage across events', () => {
const s = summarize([
{ ts: '2026-01-01T00:00:00.000Z', type: 'run.start' },
{ ts: '2026-01-01T00:00:10.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 100, 'gen_ai.usage.output_tokens': 50 },
{ ts: '2026-01-01T00:01:00.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 200, 'gen_ai.usage.output_tokens': 25 },
]);
assert.equal(s.totals.inputTokens, 300);
assert.equal(s.totals.outputTokens, 75);
assert.equal(s.totals.totalTokens, 375);
assert.equal(s.totals.wallMs, 60_000);
});
test('tolerates empty input', () => {
const s = summarize([]);
assert.equal(s.totals.totalTokens, 0);
assert.equal(s.totals.wallMs, 0);
});
test('ignores malformed timestamps rather than producing NaN', () => {
const s = summarize([
{ ts: 'not-a-date', type: 'run.start' },
{ ts: '2026-01-01T00:00:00.000Z', type: 'agent.end', 'gen_ai.usage.input_tokens': 10 },
]);
assert.ok(Number.isFinite(s.totals.wallMs));
assert.equal(s.totals.inputTokens, 10);
});
test('ignores non-numeric token fields', () => {
const s = summarize([{ ts: '2026-01-01T00:00:00.000Z', 'gen_ai.usage.input_tokens': 'lots' }]);
assert.equal(s.totals.inputTokens, 0);
});
});