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:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"context7": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.context7.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"CONTEXT7_API_KEY": "${env:CONTEXT7_API_KEY}"
|
||||||
|
},
|
||||||
|
"tools": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.**
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+36
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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. -->
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
@@ -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}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# {{SHARD}} memory
|
||||||
|
|
||||||
|
{{DESCRIPTION}}
|
||||||
|
|
||||||
|
Entries use:
|
||||||
|
|
||||||
|
`## <ISO timestamp> — <title>`
|
||||||
|
|
||||||
|
Optional next line: `tags: a, b`
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user