commit 383129f571b00d9ab479a2dd209f48ff75a8a843 Author: Giancarmine Salucci Date: Wed Sep 9 22:44:15 2026 +0200 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> diff --git a/.github/agents/architect.agent.md b/.github/agents/architect.agent.md new file mode 100644 index 0000000..35a625d --- /dev/null +++ b/.github/agents/architect.agent.md @@ -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//decisions.md` first. +2. Stop if decisions are missing or incomplete. +3. Run `lh index --budget 6000 --focus `. +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//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--`. +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: `. +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. diff --git a/.github/agents/builder.agent.md b/.github/agents/builder.agent.md new file mode 100644 index 0000000..61a72aa --- /dev/null +++ b/.github/agents/builder.agent.md @@ -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. diff --git a/.github/agents/conductor.agent.md b/.github/agents/conductor.agent.md new file mode 100644 index 0000000..b4c61a5 --- /dev/null +++ b/.github/agents/conductor.agent.md @@ -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. diff --git a/.github/agents/integrator.agent.md b/.github/agents/integrator.agent.md new file mode 100644 index 0000000..3f25828 --- /dev/null +++ b/.github/agents/integrator.agent.md @@ -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. diff --git a/.github/agents/interrogator.agent.md b/.github/agents/interrogator.agent.md new file mode 100644 index 0000000..a34a541 --- /dev/null +++ b/.github/agents/interrogator.agent.md @@ -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. diff --git a/.github/agents/reviewer.agent.md b/.github/agents/reviewer.agent.md new file mode 100644 index 0000000..3ec44cb --- /dev/null +++ b/.github/agents/reviewer.agent.md @@ -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. diff --git a/.github/agents/scout.agent.md b/.github/agents/scout.agent.md new file mode 100644 index 0000000..606d12a --- /dev/null +++ b/.github/agents/scout.agent.md @@ -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. diff --git a/.github/agents/scribe.agent.md b/.github/agents/scribe.agent.md new file mode 100644 index 0000000..1cec80b --- /dev/null +++ b/.github/agents/scribe.agent.md @@ -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. diff --git a/.github/agents/splitter.agent.md b/.github/agents/splitter.agent.md new file mode 100644 index 0000000..9d39b0f --- /dev/null +++ b/.github/agents/splitter.agent.md @@ -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. diff --git a/.github/agents/verifier.agent.md b/.github/agents/verifier.agent.md new file mode 100644 index 0000000..1e207b2 --- /dev/null +++ b/.github/agents/verifier.agent.md @@ -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. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..bba6993 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. diff --git a/.github/instructions/context7.instructions.md b/.github/instructions/context7.instructions.md new file mode 100644 index 0000000..c284210 --- /dev/null +++ b/.github/instructions/context7.instructions.md @@ -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. diff --git a/.github/instructions/harness.instructions.md b/.github/instructions/harness.instructions.md new file mode 100644 index 0000000..0268041 --- /dev/null +++ b/.github/instructions/harness.instructions.md @@ -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. diff --git a/.github/instructions/memory.instructions.md b/.github/instructions/memory.instructions.md new file mode 100644 index 0000000..f32936e --- /dev/null +++ b/.github/instructions/memory.instructions.md @@ -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. diff --git a/.github/mcp.json b/.github/mcp.json new file mode 100644 index 0000000..4858674 --- /dev/null +++ b/.github/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "${env:CONTEXT7_API_KEY}" + }, + "tools": ["*"] + } + } +} diff --git a/.github/prompts/build.prompt.md b/.github/prompts/build.prompt.md new file mode 100644 index 0000000..93aedd5 --- /dev/null +++ b/.github/prompts/build.prompt.md @@ -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. diff --git a/.github/prompts/design.prompt.md b/.github/prompts/design.prompt.md new file mode 100644 index 0000000..e65732e --- /dev/null +++ b/.github/prompts/design.prompt.md @@ -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. diff --git a/.github/prompts/fast-track.prompt.md b/.github/prompts/fast-track.prompt.md new file mode 100644 index 0000000..0c269bf --- /dev/null +++ b/.github/prompts/fast-track.prompt.md @@ -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. diff --git a/.github/prompts/onboard.prompt.md b/.github/prompts/onboard.prompt.md new file mode 100644 index 0000000..a24d5cb --- /dev/null +++ b/.github/prompts/onboard.prompt.md @@ -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. diff --git a/.github/skills/build/SKILL.md b/.github/skills/build/SKILL.md new file mode 100644 index 0000000..34b933c --- /dev/null +++ b/.github/skills/build/SKILL.md @@ -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. diff --git a/.github/skills/design/SKILL.md b/.github/skills/design/SKILL.md new file mode 100644 index 0000000..fb5fa54 --- /dev/null +++ b/.github/skills/design/SKILL.md @@ -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. diff --git a/.github/skills/doctor/SKILL.md b/.github/skills/doctor/SKILL.md new file mode 100644 index 0000000..56c13d5 --- /dev/null +++ b/.github/skills/doctor/SKILL.md @@ -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. diff --git a/.github/skills/fast-track/SKILL.md b/.github/skills/fast-track/SKILL.md new file mode 100644 index 0000000..7352719 --- /dev/null +++ b/.github/skills/fast-track/SKILL.md @@ -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. diff --git a/.github/skills/index/SKILL.md b/.github/skills/index/SKILL.md new file mode 100644 index 0000000..a3a7920 --- /dev/null +++ b/.github/skills/index/SKILL.md @@ -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. diff --git a/.github/skills/init/SKILL.md b/.github/skills/init/SKILL.md new file mode 100644 index 0000000..0000d7e --- /dev/null +++ b/.github/skills/init/SKILL.md @@ -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. diff --git a/.github/skills/integrate/SKILL.md b/.github/skills/integrate/SKILL.md new file mode 100644 index 0000000..5ec73cc --- /dev/null +++ b/.github/skills/integrate/SKILL.md @@ -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. diff --git a/.github/skills/memory/SKILL.md b/.github/skills/memory/SKILL.md new file mode 100644 index 0000000..0eaf399 --- /dev/null +++ b/.github/skills/memory/SKILL.md @@ -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. diff --git a/.github/skills/onboard/SKILL.md b/.github/skills/onboard/SKILL.md new file mode 100644 index 0000000..199b8c9 --- /dev/null +++ b/.github/skills/onboard/SKILL.md @@ -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. diff --git a/.github/skills/plan/SKILL.md b/.github/skills/plan/SKILL.md new file mode 100644 index 0000000..60f9586 --- /dev/null +++ b/.github/skills/plan/SKILL.md @@ -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. diff --git a/.github/skills/telemetry/SKILL.md b/.github/skills/telemetry/SKILL.md new file mode 100644 index 0000000..74818b6 --- /dev/null +++ b/.github/skills/telemetry/SKILL.md @@ -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. diff --git a/.github/skills/verify/SKILL.md b/.github/skills/verify/SKILL.md new file mode 100644 index 0000000..53595d9 --- /dev/null +++ b/.github/skills/verify/SKILL.md @@ -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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1177a1 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c0e9230 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a19fbeb --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a43eaaf --- /dev/null +++ b/README.md @@ -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 diff --git a/docs/adr/0001-markdown-behaviour-node-determinism.md b/docs/adr/0001-markdown-behaviour-node-determinism.md new file mode 100644 index 0000000..3549c47 --- /dev/null +++ b/docs/adr/0001-markdown-behaviour-node-determinism.md @@ -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. diff --git a/docs/adr/0002-web-tree-sitter-index.md b/docs/adr/0002-web-tree-sitter-index.md new file mode 100644 index 0000000..6583c8c --- /dev/null +++ b/docs/adr/0002-web-tree-sitter-index.md @@ -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. diff --git a/docs/adr/0003-worktree-lane-isolation.md b/docs/adr/0003-worktree-lane-isolation.md new file mode 100644 index 0000000..4ba75fc --- /dev/null +++ b/docs/adr/0003-worktree-lane-isolation.md @@ -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. diff --git a/docs/adr/0004-no-vendoring-elastic-licensed-code.md b/docs/adr/0004-no-vendoring-elastic-licensed-code.md new file mode 100644 index 0000000..184e4a0 --- /dev/null +++ b/docs/adr/0004-no-vendoring-elastic-licensed-code.md @@ -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.** diff --git a/docs/adr/0005-file-only-memory.md b/docs/adr/0005-file-only-memory.md new file mode 100644 index 0000000..e0bf8f5 --- /dev/null +++ b/docs/adr/0005-file-only-memory.md @@ -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. diff --git a/docs/adr/0006-local-ndjson-telemetry.md b/docs/adr/0006-local-ndjson-telemetry.md new file mode 100644 index 0000000..c4e3d28 --- /dev/null +++ b/docs/adr/0006-local-ndjson-telemetry.md @@ -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. diff --git a/marketplace.json b/marketplace.json new file mode 100644 index 0000000..8bc7efb --- /dev/null +++ b/marketplace.json @@ -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." + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0a966ab --- /dev/null +++ b/package-lock.json @@ -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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1b724c7 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..6e3bd1b --- /dev/null +++ b/plugin.json @@ -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" + } + } +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..7948366 --- /dev/null +++ b/scripts/validate.mjs @@ -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); diff --git a/src/cli.mjs b/src/cli.mjs new file mode 100755 index 0000000..fcaf00b --- /dev/null +++ b/src/cli.mjs @@ -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); + }); diff --git a/src/commands/doctor.mjs b/src/commands/doctor.mjs new file mode 100644 index 0000000..8338734 --- /dev/null +++ b/src/commands/doctor.mjs @@ -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; +} diff --git a/src/commands/graph.mjs b/src/commands/graph.mjs new file mode 100644 index 0000000..f470dfe --- /dev/null +++ b/src/commands/graph.mjs @@ -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; +} diff --git a/src/commands/host.mjs b/src/commands/host.mjs new file mode 100644 index 0000000..03ef7c7 --- /dev/null +++ b/src/commands/host.mjs @@ -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; +} diff --git a/src/commands/index.mjs b/src/commands/index.mjs new file mode 100644 index 0000000..541182c --- /dev/null +++ b/src/commands/index.mjs @@ -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; +} diff --git a/src/commands/init.mjs b/src/commands/init.mjs new file mode 100644 index 0000000..8186531 --- /dev/null +++ b/src/commands/init.mjs @@ -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; +} diff --git a/src/commands/lane.mjs b/src/commands/lane.mjs new file mode 100644 index 0000000..5232d36 --- /dev/null +++ b/src/commands/lane.mjs @@ -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}`); +} diff --git a/src/commands/memory.mjs b/src/commands/memory.mjs new file mode 100644 index 0000000..b1e735b --- /dev/null +++ b/src/commands/memory.mjs @@ -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; + } +} diff --git a/src/commands/report.mjs b/src/commands/report.mjs new file mode 100644 index 0000000..19450fe --- /dev/null +++ b/src/commands/report.mjs @@ -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; +} diff --git a/src/commands/run.mjs b/src/commands/run.mjs new file mode 100644 index 0000000..e30e473 --- /dev/null +++ b/src/commands/run.mjs @@ -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; + } +} diff --git a/src/lib/board.mjs b/src/lib/board.mjs new file mode 100644 index 0000000..e5f8701 --- /dev/null +++ b/src/lib/board.mjs @@ -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'); +} diff --git a/src/lib/config.mjs b/src/lib/config.mjs new file mode 100644 index 0000000..b498170 --- /dev/null +++ b/src/lib/config.mjs @@ -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; +} diff --git a/src/lib/graph.mjs b/src/lib/graph.mjs new file mode 100644 index 0000000..f6933af --- /dev/null +++ b/src/lib/graph.mjs @@ -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; +} diff --git a/src/lib/host.mjs b/src/lib/host.mjs new file mode 100644 index 0000000..945766f --- /dev/null +++ b/src/lib/host.mjs @@ -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'); +} diff --git a/src/lib/lane.mjs b/src/lib/lane.mjs new file mode 100644 index 0000000..a130083 --- /dev/null +++ b/src/lib/lane.mjs @@ -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; + } + }); +} diff --git a/src/lib/langs.mjs b/src/lib/langs.mjs new file mode 100644 index 0000000..68e4dbc --- /dev/null +++ b/src/lib/langs.mjs @@ -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; +} diff --git a/src/lib/memory.mjs b/src/lib/memory.mjs new file mode 100644 index 0000000..6045d30 --- /dev/null +++ b/src/lib/memory.mjs @@ -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 }; +} diff --git a/src/lib/paths.mjs b/src/lib/paths.mjs new file mode 100644 index 0000000..43d656e --- /dev/null +++ b/src/lib/paths.mjs @@ -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); +} diff --git a/src/lib/repomap.mjs b/src/lib/repomap.mjs new file mode 100644 index 0000000..527fd6f --- /dev/null +++ b/src/lib/repomap.mjs @@ -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; +} diff --git a/src/lib/report.mjs b/src/lib/report.mjs new file mode 100644 index 0000000..49d4cfa --- /dev/null +++ b/src/lib/report.mjs @@ -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'); +} diff --git a/src/lib/telemetry.mjs b/src/lib/telemetry.mjs new file mode 100644 index 0000000..903c98f --- /dev/null +++ b/src/lib/telemetry.mjs @@ -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), + }; +} diff --git a/src/lib/ui.mjs b/src/lib/ui.mjs new file mode 100644 index 0000000..67c64bd --- /dev/null +++ b/src/lib/ui.mjs @@ -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 }; +} diff --git a/templates/adr.md b/templates/adr.md new file mode 100644 index 0000000..25c3a31 --- /dev/null +++ b/templates/adr.md @@ -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. diff --git a/templates/architecture.md b/templates/architecture.md new file mode 100644 index 0000000..8eb29eb --- /dev/null +++ b/templates/architecture.md @@ -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. --> diff --git a/templates/conventions.md b/templates/conventions.md new file mode 100644 index 0000000..52829d7 --- /dev/null +++ b/templates/conventions.md @@ -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. diff --git a/templates/journal.md b/templates/journal.md new file mode 100644 index 0000000..01b9790 --- /dev/null +++ b/templates/journal.md @@ -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: diff --git a/templates/memory-index.md b/templates/memory-index.md new file mode 100644 index 0000000..3036e20 --- /dev/null +++ b/templates/memory-index.md @@ -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}} diff --git a/templates/memory-shard.md b/templates/memory-shard.md new file mode 100644 index 0000000..d5a2a56 --- /dev/null +++ b/templates/memory-shard.md @@ -0,0 +1,9 @@ +# {{SHARD}} memory + +{{DESCRIPTION}} + +Entries use: + +`## <ISO timestamp> — <title>` + +Optional next line: `tags: a, b` diff --git a/templates/questionnaire.md b/templates/questionnaire.md new file mode 100644 index 0000000..efc096a --- /dev/null +++ b/templates/questionnaire.md @@ -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 diff --git a/templates/spec.md b/templates/spec.md new file mode 100644 index 0000000..43cf5b3 --- /dev/null +++ b/templates/spec.md @@ -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. diff --git a/tests/e2e.test.mjs b/tests/e2e.test.mjs new file mode 100644 index 0000000..97f44e7 --- /dev/null +++ b/tests/e2e.test.mjs @@ -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'); + }); +}); diff --git a/tests/helpers.mjs b/tests/helpers.mjs new file mode 100644 index 0000000..ecf4297 --- /dev/null +++ b/tests/helpers.mjs @@ -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 {} + } +} diff --git a/tests/unit.test.mjs b/tests/unit.test.mjs new file mode 100644 index 0000000..2f33600 --- /dev/null +++ b/tests/unit.test.mjs @@ -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); + }); +});