From ddf04792c764ed1a1964bb969c5d4b45d42c4820 Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 29 May 2026 15:13:45 +0000 Subject: [PATCH] feat(P03): multi-project support, NFR milestone versioning, phase context reset, install scripts (v0.3.0) --- opencode/agents/ci-challenger.md | 7 + opencode/agents/ci-code-reviewer.md | 7 + opencode/agents/ci-debugger.md | 7 + opencode/agents/ci-doc-verifier.md | 7 + opencode/agents/ci-doc-writer.md | 7 + opencode/agents/ci-executor.md | 7 + opencode/agents/ci-ideation-agent.md | 7 + opencode/agents/ci-orchestrator.md | 7 + opencode/agents/ci-phase-researcher.md | 7 + opencode/agents/ci-plan-checker.md | 7 + opencode/agents/ci-planner.md | 7 + opencode/agents/ci-project-researcher.md | 7 + opencode/agents/ci-research-synthesizer.md | 7 + opencode/agents/ci-researcher.md | 7 + opencode/agents/ci-roadmapper.md | 7 + opencode/agents/ci-security-auditor.md | 7 + opencode/agents/ci-solution-writer.md | 7 + opencode/agents/ci-verifier.md | 7 + opencode/ci/VERSION | 2 +- opencode/ci/contexts/dev.md | 12 + opencode/ci/contexts/research.md | 4 +- opencode/ci/contexts/review.md | 8 + opencode/ci/references/branch-strategy.md | 27 ++ opencode/ci/references/ci-files-discipline.md | 38 ++- opencode/ci/references/commit-schema.md | 18 ++ opencode/ci/references/decision-engine.md | 4 + opencode/ci/references/git-context-loading.md | 28 ++ opencode/ci/workflows/audit.md | 12 + opencode/ci/workflows/clarify.md | 11 + opencode/ci/workflows/debug.md | 12 + opencode/ci/workflows/init.md | 27 +- opencode/ci/workflows/quick.md | 11 + opencode/ci/workflows/review.md | 11 + opencode/ci/workflows/rollback.md | 12 + opencode/ci/workflows/run.md | 28 ++ opencode/ci/workflows/ship.md | 34 ++- opencode/ci/workflows/status.md | 17 +- opencode/ci/workflows/verify.md | 14 + package.json | 16 +- scripts/install.sh | 154 ++++++++++ scripts/postinstall.js | 127 ++++++++ src/core/ci-files.test.ts | 288 ++++++++++++++++++ src/core/ci-files.ts | 262 ++++++++++++++-- src/core/commit-builder.test.ts | 51 ++++ src/core/commit-builder.ts | 6 + src/core/commit-parser.test.ts | 105 +++++++ src/core/commit-parser.ts | 3 + src/core/config.test.ts | 51 ++++ src/core/config.ts | 15 +- src/core/git-branch.test.ts | 24 ++ src/core/git-branch.ts | 21 +- src/core/git-context.test.ts | 91 ++++++ src/core/git-context.ts | 46 ++- src/types/commit-meta.ts | 27 +- src/types/config.test.ts | 52 +++- src/types/config.ts | 10 + src/version.ts | 2 +- 57 files changed, 1748 insertions(+), 59 deletions(-) create mode 100644 scripts/install.sh create mode 100644 scripts/postinstall.js diff --git a/opencode/agents/ci-challenger.md b/opencode/agents/ci-challenger.md index d117e10..a3b7c65 100644 --- a/opencode/agents/ci-challenger.md +++ b/opencode/agents/ci-challenger.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before challenging, load context from git first: 1. Run `git log --max-count=30` for recent decisions and project history diff --git a/opencode/agents/ci-code-reviewer.md b/opencode/agents/ci-code-reviewer.md index 6e4355e..200faf1 100644 --- a/opencode/agents/ci-code-reviewer.md +++ b/opencode/agents/ci-code-reviewer.md @@ -19,6 +19,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before reviewing, load context from git first: 1. Run `git log --max-count=10` for recent changes diff --git a/opencode/agents/ci-debugger.md b/opencode/agents/ci-debugger.md index 8acdeec..f4f9d2c 100644 --- a/opencode/agents/ci-debugger.md +++ b/opencode/agents/ci-debugger.md @@ -20,6 +20,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before debugging, load context from git first: 1. Run `git log --max-count=20` for recent changes that may have caused the bug diff --git a/opencode/agents/ci-doc-verifier.md b/opencode/agents/ci-doc-verifier.md index fd954fe..2535b68 100644 --- a/opencode/agents/ci-doc-verifier.md +++ b/opencode/agents/ci-doc-verifier.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before verifying, load context from git first: 1. Run `git diff HEAD~10` to see recent code changes diff --git a/opencode/agents/ci-doc-writer.md b/opencode/agents/ci-doc-writer.md index a871f68..8a65077 100644 --- a/opencode/agents/ci-doc-writer.md +++ b/opencode/agents/ci-doc-writer.md @@ -20,6 +20,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before writing, load context from git first: 1. Run `git log --max-count=20` for recent changes that affect docs diff --git a/opencode/agents/ci-executor.md b/opencode/agents/ci-executor.md index c1bc894..eacf9d0 100644 --- a/opencode/agents/ci-executor.md +++ b/opencode/agents/ci-executor.md @@ -20,6 +20,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before executing, load context from git first: 1. Run `git log --max-count=20` for recent project history diff --git a/opencode/agents/ci-ideation-agent.md b/opencode/agents/ci-ideation-agent.md index 395c38f..fa2f943 100644 --- a/opencode/agents/ci-ideation-agent.md +++ b/opencode/agents/ci-ideation-agent.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before ideating, load context from git first: 1. Run `git log --max-count=50` for full project history diff --git a/opencode/agents/ci-orchestrator.md b/opencode/agents/ci-orchestrator.md index f28c9b8..2bea390 100644 --- a/opencode/agents/ci-orchestrator.md +++ b/opencode/agents/ci-orchestrator.md @@ -22,6 +22,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before any operation, load project context from git first: 1. Run `git log --max-count=20` and `git branch -a` to discover project structure diff --git a/opencode/agents/ci-phase-researcher.md b/opencode/agents/ci-phase-researcher.md index 6551c57..0dc5534 100644 --- a/opencode/agents/ci-phase-researcher.md +++ b/opencode/agents/ci-phase-researcher.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before researching, load context from git first: 1. Run `git log --max-count=50` for full project history diff --git a/opencode/agents/ci-plan-checker.md b/opencode/agents/ci-plan-checker.md index e4f79f0..4169fb0 100644 --- a/opencode/agents/ci-plan-checker.md +++ b/opencode/agents/ci-plan-checker.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before checking, load context from git first: 1. Run `git log --max-count=20` for recent decisions affecting this phase diff --git a/opencode/agents/ci-planner.md b/opencode/agents/ci-planner.md index c8ba11f..f7e4854 100644 --- a/opencode/agents/ci-planner.md +++ b/opencode/agents/ci-planner.md @@ -19,6 +19,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before planning, load context from git first: 1. Run `git log --max-count=50` to see recent decisions and project history diff --git a/opencode/agents/ci-project-researcher.md b/opencode/agents/ci-project-researcher.md index 48c6cd8..73ec333 100644 --- a/opencode/agents/ci-project-researcher.md +++ b/opencode/agents/ci-project-researcher.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before researching, load context from git first: 1. Run `git log --max-count=20` for any prior project history diff --git a/opencode/agents/ci-research-synthesizer.md b/opencode/agents/ci-research-synthesizer.md index 4c53a47..79fb76a 100644 --- a/opencode/agents/ci-research-synthesizer.md +++ b/opencode/agents/ci-research-synthesizer.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before synthesizing, load context from git first: 1. Run `git log --grep="research" --max-count=20` for prior research commits diff --git a/opencode/agents/ci-researcher.md b/opencode/agents/ci-researcher.md index bf60950..ccec160 100644 --- a/opencode/agents/ci-researcher.md +++ b/opencode/agents/ci-researcher.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before researching, load context from git first: 1. Run `git log --max-count=50` for project history and prior research diff --git a/opencode/agents/ci-roadmapper.md b/opencode/agents/ci-roadmapper.md index 33710af..1725c04 100644 --- a/opencode/agents/ci-roadmapper.md +++ b/opencode/agents/ci-roadmapper.md @@ -19,6 +19,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before roadmapping, load context from git first: 1. Run `git log --max-count=30` for project history diff --git a/opencode/agents/ci-security-auditor.md b/opencode/agents/ci-security-auditor.md index 638cd21..1ece784 100644 --- a/opencode/agents/ci-security-auditor.md +++ b/opencode/agents/ci-security-auditor.md @@ -20,6 +20,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before auditing, load context from git first: 1. Run `git log --grep="security" --max-count=20` for prior security decisions diff --git a/opencode/agents/ci-solution-writer.md b/opencode/agents/ci-solution-writer.md index 7d9fd31..7b560b2 100644 --- a/opencode/agents/ci-solution-writer.md +++ b/opencode/agents/ci-solution-writer.md @@ -19,6 +19,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before analyzing, load context from git first: 1. Run `git log --max-count=20` for recent problem-solving history diff --git a/opencode/agents/ci-verifier.md b/opencode/agents/ci-verifier.md index ff14de9..3e8aaac 100644 --- a/opencode/agents/ci-verifier.md +++ b/opencode/agents/ci-verifier.md @@ -18,6 +18,13 @@ If the prompt contains a `` block, you MUST use the Read tool to +If .ci/config.json has projects[] with length > 0, you are in multi-project mode. +- Read active_project from .ci/config.json +- All commits must include `project: ` in ---ci--- block +- Branch names are prefixed with / in multi-project mode +- .ci/ files are in .ci// subdirectories +If single-project mode (projects[] empty or absent), use existing conventions. + Before verifying, load context from git first: 1. Run `git log --grep="P##" --max-count=50` for all phase commits diff --git a/opencode/ci/VERSION b/opencode/ci/VERSION index 341cf11..9325c3c 100644 --- a/opencode/ci/VERSION +++ b/opencode/ci/VERSION @@ -1 +1 @@ -0.2.0 \ No newline at end of file +0.3.0 \ No newline at end of file diff --git a/opencode/ci/contexts/dev.md b/opencode/ci/contexts/dev.md index acf41c8..9bae4d8 100644 --- a/opencode/ci/contexts/dev.md +++ b/opencode/ci/contexts/dev.md @@ -4,6 +4,18 @@ Agent output guidance for CI dev mode. Loaded when the orchestrator operates in --- +## Multi-Project and NFR Versioning + +When in multi-project mode (`.ci/config.json` has `projects[]` with length > 0): +- All commits include `project: ` in `---ci---` block +- Branch names are prefixed with `/` +- `.ci/` files are in `.ci//` subdirectories +- Project scoping applies to all operations + +NFR milestone versioning: +- NFR milestones (all phases are fix/chore/docs/perf/refactor/test): progressive patch versions only, no minor tag +- Feature milestones (any feat phase): progressive patch versions + minor milestone tag + ## Output Style - Concise, action-oriented responses diff --git a/opencode/ci/contexts/research.md b/opencode/ci/contexts/research.md index 70532e4..faae2ee 100644 --- a/opencode/ci/contexts/research.md +++ b/opencode/ci/contexts/research.md @@ -21,11 +21,13 @@ Agent output guidance for CI research mode. Loaded when the orchestrator operate ## Research Output -Research commits update `.ci/` static files (ARCHITECTURE.md, PROJECT.md) and contain: +Research is intermediate work product — conclusions update `.ci//` static files (ARCHITECTURE.md, PROJECT.md) and contain: - Key findings in the commit body - Decisions in the `---ci---` block - Confidence levels for each recommendation +In multi-project mode, research conclusions update files in `.ci//` subdirectories, not the root `.ci/` directory. + ## Verbosity High. Explain reasoning, show evidence, and document assumptions. diff --git a/opencode/ci/contexts/review.md b/opencode/ci/contexts/review.md index 83567d1..9f30edb 100644 --- a/opencode/ci/contexts/review.md +++ b/opencode/ci/contexts/review.md @@ -4,6 +4,14 @@ Agent output guidance for CI review mode. Loaded when the orchestrator operates --- +## Multi-Project Awareness + +When in multi-project mode (`.ci/config.json` has `projects[]` with length > 0): +- All reviews are scoped to the active project +- Commits include `project: ` in `---ci---` blocks +- Branch names are prefixed with `/` +- Review findings reference project-scoped paths + ## Output Style - Critical, detail-focused responses that prioritize correctness diff --git a/opencode/ci/references/branch-strategy.md b/opencode/ci/references/branch-strategy.md index 85dbba0..5151bdc 100644 --- a/opencode/ci/references/branch-strategy.md +++ b/opencode/ci/references/branch-strategy.md @@ -115,6 +115,33 @@ git push origin main --tags When the project undergoes a schema-breaking change (e.g., switching from file-based to git-native architecture), bump the major version. Major releases follow the same merge → tag → release flow. +## NFR Milestone Versioning + +NFR milestones and feature milestones follow different versioning rules: + +**NFR milestones** — all phases are `fix`, `chore`, `docs`, `perf`, `refactor`, or `test`: +- Each phase gets a progressive patch version (v0.1.1, v0.1.2, v0.1.3) +- No separate milestone tag — the milestone is implicit from the patch sequence +- Example: milestone v0.1 with phases P01 (chore), P02 (test), P03 (perf) → v0.1.1, v0.1.2, v0.1.3 + +**Feature milestones** — any phase is `feat`: +- Each phase gets a progressive patch version +- On milestone completion, tag a minor version (e.g., v0.2.0) +- Example: milestone v0.2 with phases P01 (feat), P02 (feat), P03 (fix) → v0.2.1, v0.2.2, v0.2.3 + milestone tag v0.3.0 + +Determine milestone type by checking `isNfrMilestone()` which inspects all phase commit types within the milestone. + +## Multi-Project Branch Naming + +When operating in multi-project mode (`.ci/config.json` has `projects[]` with length > 0): + +| Branch Type | Format | Example | +|-------------|--------|---------| +| Phase | `/phase/NN-slug` | `auth/phase/01-jwt-setup` | +| Milestone | `/milestone/vX.X-slug` | `auth/milestone/v0.2-login` | + +Single-project mode keeps the existing `phase/NN-slug` and `milestone/vX.X-slug` conventions (no slug prefix). + ## Phase Discovery ```typescript diff --git a/opencode/ci/references/ci-files-discipline.md b/opencode/ci/references/ci-files-discipline.md index 3de01d0..1ddf8ac 100644 --- a/opencode/ci/references/ci-files-discipline.md +++ b/opencode/ci/references/ci-files-discipline.md @@ -4,15 +4,33 @@ How CI manages the `.ci/` directory — long-lived reference documents only. Dyn --- +## Multi-Project Directory Structure + +In multi-project mode, `.ci/` uses subdirectories per project: + +``` +.ci/ + config.json # Registry with projects[] and active_project + / + PROJECT.md + ARCHITECTURE.md + ROADMAP.md + REQUIREMENTS.md +``` + +`.ci/config.json` serves as the registry with `projects[]` (array of project entries) and `active_project` (slug of the currently active project). + +**Backward compatibility:** if `.ci/` has flat files (PROJECT.md, ARCHITECTURE.md, etc.) and no project subdirectories, auto-migrate by creating `/` and moving files into it, then updating `config.json` with a single `projects[]` entry. + ## What Lives in `.ci/` | File | Purpose | Update Frequency | |------|---------|-------------------| -| `config.json` | Project-level CI configuration | Rare (initialization, setting changes) | -| `PROJECT.md` | Vision, core value, requirements, constraints, key decisions | Low (phase boundaries) | -| `ARCHITECTURE.md` | System architecture, component boundaries, data flow | Low (major refactors) | -| `ROADMAP.md` | Phase breakdown, milestone mapping, success criteria | Low (phase transitions) | -| `REQUIREMENTS.md` | v1/v2 requirements with REQ-IDs, out of scope, traceability | Low (requirement changes) | +| `config.json` | Project registry with `projects[]` and `active_project` | Rare (initialization, project changes) | +| `/PROJECT.md` | Vision, core value, requirements, constraints, key decisions per project | Low (phase boundaries) | +| `/ARCHITECTURE.md` | System architecture, component boundaries, data flow per project | Low (major refactors) | +| `/ROADMAP.md` | Phase breakdown, milestone mapping, success criteria per project | Low (phase transitions) | +| `/REQUIREMENTS.md` | v1/v2 requirements with REQ-IDs, out of scope, traceability per project | Low (requirement changes) | ## What Does NOT Live in `.ci/` @@ -137,6 +155,15 @@ interface ArchitectureMd { } ``` +## Research and .ci/ File Updates + +Research is intermediate work product. Conclusions from research update `.ci/` static files: +- Key findings go in the commit body +- Decisions go in `---ci---` blocks +- Conclusions that change project structure update the appropriate `.ci//` files (ARCHITECTURE.md, PROJECT.md, etc.) + +Research commits are not final artifacts — they feed into planning and roadmap updates. + ## Anti-Patterns - Never write dynamic state (decisions, escalations, lessons) to `.ci/` files @@ -145,5 +172,6 @@ interface ArchitectureMd { - Never commit `.ci/` changes without a `---ci---` block - Never create new files in `.ci/` without updating this reference document - Never store counters, timestamps, or session state in `.ci/` files +- Never store research conclusions only in commits — update `.ci//` static files with findings \ No newline at end of file diff --git a/opencode/ci/references/commit-schema.md b/opencode/ci/references/commit-schema.md index f66c326..3a89517 100644 --- a/opencode/ci/references/commit-schema.md +++ b/opencode/ci/references/commit-schema.md @@ -10,6 +10,7 @@ Canonical `---ci---` YAML block schema for CI commits. Every CI-generated commit (): ---ci--- +project: # required in multi-project mode phase: milestone: plan: # optional @@ -38,6 +39,23 @@ compound: # optional ---/ci--- ``` +The `project` field is required when in multi-project mode (`.ci/config.json` has `projects[]` with length > 0). In single-project mode, it is optional. + +Example with project field: + +``` +feat(P01-01-01): implement JWT auth + +---ci--- +project: auth-service +phase: 1 +milestone: v0.2 +plan: 01-01 +task: 01-01-01 +status: execute +---/ci--- +``` + ## Commit Types | Type | Purpose | Scope | diff --git a/opencode/ci/references/decision-engine.md b/opencode/ci/references/decision-engine.md index 3049db6..90a2798 100644 --- a/opencode/ci/references/decision-engine.md +++ b/opencode/ci/references/decision-engine.md @@ -93,6 +93,10 @@ const phaseDecisions = gitContext.getDecisions(3); // Phase 3 only const commitDecisions = gitContext.getDecisionsFromCommits(commits, 3); ``` +## Project-Scoped Decisions + +Decisions can be project-scoped via the `project` field in `---ci---` blocks. When in multi-project mode, include the project slug so that `GitContext.getDecisions()` can filter decisions by project. Project-scoped decisions only apply to the specified project and do not affect other projects in the same repository. + ## Anti-Patterns - Never write decisions to a `.ci/audit/` file — commit them diff --git a/opencode/ci/references/git-context-loading.md b/opencode/ci/references/git-context-loading.md index 8232931..0ec9d26 100644 --- a/opencode/ci/references/git-context-loading.md +++ b/opencode/ci/references/git-context-loading.md @@ -76,6 +76,34 @@ interface ParsedCiCommit { Commits without `---ci---` blocks have `ci: null` — these are treated as non-CI commits (e.g., manual edits by the developer). +## Phase Context Reset + +Between phases, all state is committed to git, then the next phase starts with fresh context from git log — not accumulated conversation history. + +**On opencode (subagent support):** spawn a fresh agent for the next phase. The new agent loads context from git log and `.ci/` files only. + +**On platforms without subagents:** simulated reset — re-read git context from scratch, ignore prior conversation history. Treat the phase boundary as a hard context boundary. + +**Checkpoint sequence:** +1. Commit all work from the current phase +2. Update `.ci/` files (ROADMAP.md phase status, REQUIREMENTS.md requirement statuses) +3. Verify `GitContext.reconstructState()` matches expected state +4. Reset context — next phase begins fresh + +The phase context reset ensures that each phase operates on verified git state, preventing context drift across long-running projects. + +## Multi-Project Context + +GitContext supports multi-project mode with optional project scoping: + +| Method | Returns | Purpose | +|--------|---------|---------| +| `GitContext(projectPath, projectSlug?)` | GitContext | Optional project slug for scoping | +| `detectProjectFromCommit()` | string \| null | Detect project from latest commit's `project` field | +| `isNfrMilestone()` | boolean | Check if current milestone is NFR-only (no feat phases) | + +In multi-project mode, `detectProjectFromCommit()` reads the `project` field from the latest `---ci---` block to determine which project context to load. `isNfrMilestone()` inspects phase commit types to determine versioning behavior. + ## Context Budget Strategy When context is limited: diff --git a/opencode/ci/workflows/audit.md b/opencode/ci/workflows/audit.md index 4b37f2d..e28d554 100644 --- a/opencode/ci/workflows/audit.md +++ b/opencode/ci/workflows/audit.md @@ -8,6 +8,18 @@ Audit the CI project for health issues. Verifies that git log state matches .ci/ **Usage:** `ci-audit` +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this audit +- If not, set it with `ci setActiveProject()` +- Scope audit queries to the active project +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + ## Step 1: Reconstruction Test Attempt to reconstruct the full project state from commit messages only: diff --git a/opencode/ci/workflows/clarify.md b/opencode/ci/workflows/clarify.md index d7b3a5f..438650f 100644 --- a/opencode/ci/workflows/clarify.md +++ b/opencode/ci/workflows/clarify.md @@ -8,6 +8,17 @@ Run the clarification phase for the current CI project. Generate questions about **Usage:** `ci-clarify [phase_number]` +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this clarification +- If not, set it with `ci setActiveProject()` +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + ## Step 1: Load Git Context ```bash diff --git a/opencode/ci/workflows/debug.md b/opencode/ci/workflows/debug.md index c861a4a..9414656 100644 --- a/opencode/ci/workflows/debug.md +++ b/opencode/ci/workflows/debug.md @@ -8,6 +8,18 @@ Systematic debugging workflow: triage → root cause diagnosis → auto-fix or e **Usage:** `ci-debug [description]` +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this debug session +- If not, set it with `ci setActiveProject()` +- Scope debugging to the active project +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + ## Step 1: Load Git Context ```bash diff --git a/opencode/ci/workflows/init.md b/opencode/ci/workflows/init.md index 1f61bba..92d0f8b 100644 --- a/opencode/ci/workflows/init.md +++ b/opencode/ci/workflows/init.md @@ -8,6 +8,18 @@ Initialize a new CI project with specification parsing, clarification, and .ci/ **Usage:** `ci-init [description]` +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this initialization +- If not, set it with `ci setActiveProject()` +- All subsequent operations use `.ci//` subdirectories +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + ## Step 1: Check Prerequisites Verify git is initialized: @@ -51,11 +63,13 @@ Record decisions in the `---ci---` block of the init commit. Use CiFiles to create the project structure: -1. `.ci/config.json` — default CI configuration with autonomy level -2. `.ci/PROJECT.md` — vision, requirements, constraints, key decisions -3. `.ci/ARCHITECTURE.md` — system architecture (initial, may be incomplete) -4. `.ci/ROADMAP.md` — phase breakdown (to be refined by roadmapper) -5. `.ci/REQUIREMENTS.md` — formal requirements with REQ-IDs +1. `.ci/config.json` — registry with `projects[]` and `active_project` +2. `.ci//PROJECT.md` — vision, requirements, constraints, key decisions (or `.ci/PROJECT.md` in single-project mode) +3. `.ci//ARCHITECTURE.md` — system architecture (initial, may be incomplete) +4. `.ci//ROADMAP.md` — phase breakdown (to be refined by roadmapper) +5. `.ci//REQUIREMENTS.md` — formal requirements with REQ-IDs + +`initCI()` accepts `projectSlug` and `projectName` parameters for multi-project initialization. ## Step 5: Create Initial Branches @@ -69,6 +83,7 @@ git checkout -b milestone/v1.0-initial docs(init): initialize [project-name] ([N] phases) ---ci--- +project: phase: 0 milestone: v1.0 status: specify @@ -86,6 +101,8 @@ Constraints: [constraint1, ...] Out of scope: [item1, ...] ``` +Include `project: ` in the `---ci---` block when in multi-project mode. + ## Step 7: Done Report project initialized, .ci/ files created, initial branch created. diff --git a/opencode/ci/workflows/quick.md b/opencode/ci/workflows/quick.md index cdc91ba..8a110a1 100644 --- a/opencode/ci/workflows/quick.md +++ b/opencode/ci/workflows/quick.md @@ -13,6 +13,17 @@ Execute small, ad-hoc tasks with CI guarantees: git context loading, `---ci---` - `--verify` — verify results after execution - `--full` — research + verify +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct +- If not, set it with `ci setActiveProject()` +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + ## Step 1: Get Task Description If provided as argument, use it. Otherwise ask: "What do you want to do?" diff --git a/opencode/ci/workflows/review.md b/opencode/ci/workflows/review.md index d7d569c..b806c00 100644 --- a/opencode/ci/workflows/review.md +++ b/opencode/ci/workflows/review.md @@ -8,6 +8,17 @@ Multi-persona code review workflow. Reviews changes in the current phase, auto-a **Usage:** `ci-review [phase_number]` +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this review +- If not, set it with `ci setActiveProject()` +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + ## Step 1: Load Changes ```bash diff --git a/opencode/ci/workflows/rollback.md b/opencode/ci/workflows/rollback.md index 08c3db2..b3e5590 100644 --- a/opencode/ci/workflows/rollback.md +++ b/opencode/ci/workflows/rollback.md @@ -10,6 +10,18 @@ Rollback a CI phase by reverting to the state before the phase started. Uses git If no phase specified, rolls back the current (most recent) phase. +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this rollback +- If not, set it with `ci setActiveProject()` +- Identify project-scoped branches (prefixed with `/`) +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions (branches without slug prefix). + ## Step 1: Load Git Context ```bash diff --git a/opencode/ci/workflows/run.md b/opencode/ci/workflows/run.md index d865499..e36d677 100644 --- a/opencode/ci/workflows/run.md +++ b/opencode/ci/workflows/run.md @@ -10,6 +10,17 @@ Execute the full CI pipeline from the current stage to completion. The orchestra If no phase number specified, continues from the current phase (detected from git log). +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this run +- If not, set it with `ci setActiveProject()` +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + ## Step 1: Load Git Context ```bash @@ -76,6 +87,23 @@ For each stage in order (starting from current or from `specify`): Versioning: Major = project-level refactor/schema change, Minor = milestone completion, Patch = every phase. +## Phase Boundary Checkpoint + +Between phases, perform a context reset: + +1. Commit all work from the current phase +2. Update `.ci/` files (phase status, requirement statuses) +3. Verify `GitContext.reconstructState()` matches expected state +4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents) +5. Next phase begins with fresh context from git log only + +## NFR Versioning Logic + +Before tagging a phase completion, check `isNfrMilestone()`: + +- **NFR milestone** (all phases are fix/chore/docs/perf/refactor/test): apply progressive patch versions (v0.1.1, v0.1.2, v0.1.3). No separate milestone tag. +- **Feature milestone** (any feat phase): apply progressive patch versions per phase, then tag minor milestone version on completion (e.g., v0.2.0). + ## Step 4: Error Recovery On stage failure: diff --git a/opencode/ci/workflows/ship.md b/opencode/ci/workflows/ship.md index 8a9d338..a0adeab 100644 --- a/opencode/ci/workflows/ship.md +++ b/opencode/ci/workflows/ship.md @@ -8,11 +8,27 @@ Ship a CI phase or milestone. Every ship creates a release — no exceptions. **Versioning rule:** - **Major** (X.0.0): Project-level refactor or schema changes -- **Minor** (0.X.0): Every milestone completion +- **Minor** (0.X.0): Feature milestone completion only - **Patch** (0.0.X): Every phase completion +**NFR versioning:** +- NFR milestones (all phases are fix/chore/docs/perf/refactor/test): progressive patch versions only (v0.1.1, v0.1.2, v0.1.3). No minor milestone tag. +- Feature milestones (any feat phase): progressive patch versions per phase + minor milestone tag on completion (e.g., v0.2.0). + **Usage:** `ci-ship [phase_number|milestone]` +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this ship +- If not, set it with `ci setActiveProject()` +- All commit messages must include `project: ` in `---ci---` block +- Branch names are prefixed with `/` in multi-project mode + +If single-project mode: proceed with existing conventions. + ## Step 1: Pre-Flight ```bash @@ -41,15 +57,17 @@ If any fail: iterate autonomously until tests pass. Do NOT ask the user for guid ## Step 3: Compute Version -Determine the release version from what is being shipped: +Determine the release version from what is being shipped. Check `isNfrMilestone()` for versioning behavior: -| What's shipping | Version bump | Tag format | Example | -|----------------|-------------|------------|---------| -| Single phase | Patch | `vX.Y.Z` | `v0.2.3` (3rd phase in milestone v0.2) | -| Milestone completion | Minor | `vX.Y.0` | `v0.3.0` (milestone v0.3 complete) | -| Project refactor/schema change | Major | `vX.0.0` | `v1.0.0` (breaking schema) | +| What's shipping | Milestone Type | Version bump | Tag format | Example | +|----------------|---------------|-------------|------------|---------| +| Single phase | NFR | Patch | `vX.Y.Z` | `v0.1.3` (3rd NFR phase in milestone v0.1) | +| Single phase | Feature | Patch | `vX.Y.Z` | `v0.2.3` (3rd phase in feature milestone v0.2) | +| Milestone completion | NFR | Patch (last phase) | `vX.Y.Z` | `v0.1.3` (no minor tag) | +| Milestone completion | Feature | Minor | `vX.Y.0` | `v0.3.0` (feature milestone v0.3 complete) | +| Project refactor/schema change | Any | Major | `vX.0.0` | `v1.0.0` (breaking schema) | -Count completed phases in the current milestone to determine the patch number. If this is the last phase in the milestone, the version bumps to minor instead of patch. +Count completed phases in the current milestone to determine the patch number. ## Step 4: Merge Branch diff --git a/opencode/ci/workflows/status.md b/opencode/ci/workflows/status.md index d34df6a..962f983 100644 --- a/opencode/ci/workflows/status.md +++ b/opencode/ci/workflows/status.md @@ -8,6 +8,18 @@ Display the current CI project status derived entirely from the git log and .ci/ **Usage:** `ci-status` +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Show project list with active project indicator +- Confirm `active_project` is the project to show status for +- If not, set it with `ci setActiveProject()` +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + ## Step 1: Load Git Context ```bash @@ -44,8 +56,9 @@ Read: CI ► STATUS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Project: [name] -Milestone: [current] +Project: [name] [If multi-project: (active)] +[If multi-project: Other projects: [name1], [name2]] +Milestone: [current] [NFR|Feature] Phase: [N] — [name] Stage: [current_stage] Autonomy: [level] diff --git a/opencode/ci/workflows/verify.md b/opencode/ci/workflows/verify.md index 60e12d0..7684d4f 100644 --- a/opencode/ci/workflows/verify.md +++ b/opencode/ci/workflows/verify.md @@ -10,6 +10,20 @@ Run the CI verification pipeline against the current or specified phase. Four la If no phase specified, verifies the current phase. +## Step 0: Confirm Active Project + +Check `ci listProjects()` or read `.ci/config.json` to determine if multi-project mode is active. + +If `.ci/config.json` has `projects[]` with length > 0: +- Confirm `active_project` is correct for this verification +- If not, set it with `ci setActiveProject()` +- Scope verification to the active project +- All commit messages must include `project: ` in `---ci---` block + +If single-project mode: proceed with existing conventions. + +**Phase Boundary Checkpoint:** Between phases, all state is committed to git, context is reset, and the next phase begins with fresh git log context. Verify that the current verification aligns with the reconstructed state. + ## Step 1: Load Git Context ```bash diff --git a/package.json b/package.json index fa62175..fe34f66 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,30 @@ { "name": "@continuous-intelligence/ci", - "version": "0.2.0", + "version": "0.3.0", "description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence", "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { "ci": "./dist/cli/index.js" }, + "files": [ + "dist/", + "opencode/", + "scripts/", + "templates/", + "LICENSE", + "README.md" + ], "scripts": { "build": "tsc", "dev": "ts-node src/cli.ts", "typecheck": "tsc --noEmit", "test": "jest", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "postinstall": "node scripts/postinstall.js", + "install": "bash scripts/install.sh" }, - "keywords": ["ci", "autonomous", "ai", "software-engineering", "agent"], + "keywords": ["ci", "autonomous", "ai", "software-engineering", "agent", "multi-project"], "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..7b605e6 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +OPENCODE_DIR="${HOME}/.config/opencode" +CI_DIR="$(cd "$(dirname "$0")/.." && pwd)/opencode" + +UNINSTALL=false +FORCE=false + +for arg in "$@"; do + case "$arg" in + --uninstall) UNINSTALL=true ;; + --force) FORCE=true ;; + --help|-h) + echo "Usage: $(basename "$0") [--uninstall] [--force]" + echo "" + echo "Install CI opencode integration files to ~/.config/opencode/" + echo "" + echo " --uninstall Remove CI integration files" + echo " --force Overwrite existing files without prompting" + echo " --help Show this help" + exit 0 + ;; + esac +done + +if [ "$UNINSTALL" = true ]; then + echo "Uninstalling CI opencode integration..." + + rm -f "${OPENCODE_DIR}/agents/ci-"*.md 2>/dev/null || true + rm -f "${OPENCODE_DIR}/command/ci-"*.md 2>/dev/null || true + rm -rf "${OPENCODE_DIR}/ci/" 2>/dev/null || true + + echo "CI integration files removed." + echo "Note: opencode.json permissions entry preserved (edit manually if needed)." + exit 0 +fi + +if [ ! -d "$CI_DIR" ]; then + echo "Error: opencode/ directory not found at ${CI_DIR}" + echo "Ensure you're running from the CI repository root." + exit 1 +fi + +echo "Installing CI opencode integration..." +echo " Source: ${CI_DIR}" +echo " Target: ${OPENCODE_DIR}" +echo "" + +mkdir -p "${OPENCODE_DIR}/agents" +mkdir -p "${OPENCODE_DIR}/command" +mkdir -p "${OPENCODE_DIR}/ci/contexts" +mkdir -p "${OPENCODE_DIR}/ci/references" +mkdir -p "${OPENCODE_DIR}/ci/workflows" + +COPIED=0 +SKIPPED=0 + +copy_file() { + local src="$1" + local dest="$2" + + if [ -f "$dest" ] && [ "$FORCE" = false ]; then + if cmp -s "$src" "$dest" 2>/dev/null; then + SKIPPED=$((SKIPPED + 1)) + return + fi + echo " Conflict: $(basename "$src") already exists. Use --force to overwrite." + SKIPPED=$((SKIPPED + 1)) + return + fi + + cp "$src" "$dest" + COPIED=$((COPIED + 1)) +} + +echo "Installing agents..." +for f in "${CI_DIR}/agents/ci-"*.md; do + [ -f "$f" ] && copy_file "$f" "${OPENCODE_DIR}/agents/$(basename "$f")" +done + +echo "Installing commands..." +for f in "${CI_DIR}/command/ci-"*.md; do + [ -f "$f" ] && copy_file "$f" "${OPENCODE_DIR}/command/$(basename "$f")" +done + +echo "Installing contexts..." +for f in "${CI_DIR}/ci/contexts/"*.md; do + [ -f "$f" ] && copy_file "$f" "${OPENCODE_DIR}/ci/contexts/$(basename "$f")" +done + +echo "Installing references..." +for f in "${CI_DIR}/ci/references/"*.md; do + [ -f "$f" ] && copy_file "$f" "${OPENCODE_DIR}/ci/references/$(basename "$f")" +done + +echo "Installing workflows..." +for f in "${CI_DIR}/ci/workflows/"*.md; do + [ -f "$f" ] && copy_file "$f" "${OPENCODE_DIR}/ci/workflows/$(basename "$f")" +done + +echo "Installing VERSION..." +[ -f "${CI_DIR}/ci/VERSION" ] && copy_file "${CI_DIR}/ci/VERSION" "${OPENCODE_DIR}/ci/VERSION" + +echo "" +echo "Merging opencode.json permissions..." +OPENCODE_JSON="${OPENCODE_DIR}/opencode.json" +CI_JSON="${CI_DIR}/opencode.json" + +if [ -f "$CI_JSON" ]; then + if [ ! -f "$OPENCODE_JSON" ]; then + cp "$CI_JSON" "$OPENCODE_JSON" + echo " Created opencode.json" + else + if command -v node &>/dev/null; then + node -e " + const fs = require('fs'); + const existing = JSON.parse(fs.readFileSync('${OPENCODE_JSON}', 'utf8')); + const ci = JSON.parse(fs.readFileSync('${CI_JSON}', 'utf8')); + const merged = { ...existing }; + merged.permission = merged.permission || {}; + merged.permission.read = merged.permission.read || {}; + merged.permission.external_directory = merged.permission.external_directory || {}; + for (const [k, v] of Object.entries(ci.permission?.read || {})) { + if (!merged.permission.read[k]) merged.permission.read[k] = v; + } + for (const [k, v] of Object.entries(ci.permission?.external_directory || {})) { + if (!merged.permission.external_directory[k]) merged.permission.external_directory[k] = v; + } + fs.writeFileSync('${OPENCODE_JSON}', JSON.stringify(merged, null, 2)); + console.log(' Merged permissions (preserved existing entries)'); + " + else + echo " Warning: node not found. Manually merge opencode.json permissions." + echo " Add to opencode.json:" + echo ' "~/.config/opencode/ci/*": "allow" (in permission.read and permission.external_directory)' + fi + fi +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " CI ► INSTALL COMPLETE" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Copied: ${COPIED} files" +echo " Skipped: ${SKIPPED} files" +echo "" +echo " Commands available: ci-init, ci-run, ci-quick, ci-status," +echo " ci-audit, ci-verify, ci-debug, ci-review, ci-ship," +echo " ci-rollback, ci-clarify" +echo "" +echo " Run --uninstall to remove." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" \ No newline at end of file diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 0000000..516188d --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +const OPENCODE_DIR = path.join(process.env.HOME || "/root", ".config", "opencode"); + +function getPackageDir() { + try { + return path.resolve(__dirname, ".."); + } catch { + return null; + } +} + +function isGlobalInstall() { + if (process.env.npm_config_global === "true") return true; + if (process.env.npm_config_global === "1") return true; + return false; +} + +function copyFile(src, dest, force) { + if (!fs.existsSync(src)) return { copied: 0, skipped: 0 }; + + const dir = path.dirname(dest); + fs.mkdirSync(dir, { recursive: true }); + + if (fs.existsSync(dest) && !force) { + try { + const srcContent = fs.readFileSync(src, "utf8"); + const destContent = fs.readFileSync(dest, "utf8"); + if (srcContent === destContent) return { copied: 0, skipped: 1 }; + } catch {} + return { copied: 0, skipped: 1 }; + } + + fs.copyFileSync(src, dest); + return { copied: 1, skipped: 0 }; +} + +function install() { + const pkgDir = getPackageDir(); + if (!pkgDir) { + console.log("CI postinstall: Could not determine package directory. Skipping."); + return; + } + + const opencodeDir = path.join(pkgDir, "opencode"); + if (!fs.existsSync(opencodeDir)) { + console.log("CI postinstall: opencode/ directory not found. Skipping."); + return; + } + + if (!isGlobalInstall()) { + console.log("CI postinstall: Not a global install. Skipping opencode integration."); + console.log(" Run `npx ci-install` or `./scripts/install.sh` to install manually."); + return; + } + + let copied = 0; + let skipped = 0; + + function copyGlob(srcDir, destDir, pattern) { + if (!fs.existsSync(srcDir)) return; + const entries = fs.readdirSync(srcDir).filter((f) => { + if (pattern instanceof RegExp) return pattern.test(f); + return f.startsWith(pattern); + }); + for (const entry of entries) { + const result = copyFile(path.join(srcDir, entry), path.join(destDir, entry), false); + copied += result.copied; + skipped += result.skipped; + } + } + + fs.mkdirSync(path.join(OPENCODE_DIR, "agents"), { recursive: true }); + fs.mkdirSync(path.join(OPENCODE_DIR, "command"), { recursive: true }); + fs.mkdirSync(path.join(OPENCODE_DIR, "ci", "contexts"), { recursive: true }); + fs.mkdirSync(path.join(OPENCODE_DIR, "ci", "references"), { recursive: true }); + fs.mkdirSync(path.join(OPENCODE_DIR, "ci", "workflows"), { recursive: true }); + + copyGlob(path.join(opencodeDir, "agents"), path.join(OPENCODE_DIR, "agents"), /^ci-/); + copyGlob(path.join(opencodeDir, "command"), path.join(OPENCODE_DIR, "command"), /^ci-/); + copyGlob(path.join(opencodeDir, "ci", "contexts"), path.join(OPENCODE_DIR, "ci", "contexts"), /\.md$/); + copyGlob(path.join(opencodeDir, "ci", "references"), path.join(OPENCODE_DIR, "ci", "references"), /\.md$/); + copyGlob(path.join(opencodeDir, "ci", "workflows"), path.join(OPENCODE_DIR, "ci", "workflows"), /\.md$/); + + const versionFile = path.join(opencodeDir, "ci", "VERSION"); + if (fs.existsSync(versionFile)) { + const result = copyFile(versionFile, path.join(OPENCODE_DIR, "ci", "VERSION"), false); + copied += result.copied; + skipped += result.skipped; + } + + const ciJsonPath = path.join(opencodeDir, "opencode.json"); + const targetJsonPath = path.join(OPENCODE_DIR, "opencode.json"); + + if (fs.existsSync(ciJsonPath)) { + if (!fs.existsSync(targetJsonPath)) { + fs.copyFileSync(ciJsonPath, targetJsonPath); + } else { + try { + const existing = JSON.parse(fs.readFileSync(targetJsonPath, "utf8")); + const ciJson = JSON.parse(fs.readFileSync(ciJsonPath, "utf8")); + existing.permission = existing.permission || {}; + existing.permission.read = existing.permission.read || {}; + existing.permission.external_directory = existing.permission.external_directory || {}; + for (const [k, v] of Object.entries(ciJson.permission?.read || {})) { + if (!existing.permission.read[k]) existing.permission.read[k] = v; + } + for (const [k, v] of Object.entries(ciJson.permission?.external_directory || {})) { + if (!existing.permission.external_directory[k]) existing.permission.external_directory[k] = v; + } + fs.writeFileSync(targetJsonPath, JSON.stringify(existing, null, 2)); + } catch {} + } + } + + console.log(`CI postinstall: ${copied} files installed, ${skipped} skipped.`); +} + +try { + install(); +} catch (err) { + console.log("CI postinstall: Non-fatal error:", err.message); +} \ No newline at end of file diff --git a/src/core/ci-files.test.ts b/src/core/ci-files.test.ts index ceb8a22..dd5a92d 100644 --- a/src/core/ci-files.test.ts +++ b/src/core/ci-files.test.ts @@ -44,6 +44,294 @@ describe("CiFiles", () => { }); }); + describe("projectSlug", () => { + it("defaults to empty string", () => { + const ciFiles = new CiFiles(dir); + expect(ciFiles.getProjectSlug()).toBe(""); + }); + + it("uses provided project slug", () => { + const ciFiles = new CiFiles(dir, "task-api"); + expect(ciFiles.getProjectSlug()).toBe("task-api"); + }); + + it("setProjectSlug updates slug", () => { + const ciFiles = new CiFiles(dir); + ciFiles.setProjectSlug("auth-svc"); + expect(ciFiles.getProjectSlug()).toBe("auth-svc"); + }); + }); + + describe("multi-project support", () => { + it("isMultiProject returns false when not initialized", () => { + const ciFiles = new CiFiles(dir); + expect(ciFiles.isMultiProject()).toBe(false); + }); + + it("isMultiProject returns false for single-project config", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "default", name: "Default" }], + active_project: "default", + })); + expect(ciFiles.isMultiProject()).toBe(true); + }); + + it("isMultiProject returns false for config without projects array", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); + expect(ciFiles.isMultiProject()).toBe(false); + }); + + it("addProject adds a project to config", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [], + active_project: "", + })); + + ciFiles.addProject("task-api", "Task API", true); + + const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); + expect(config.projects).toHaveLength(1); + expect(config.projects[0].slug).toBe("task-api"); + expect(config.active_project).toBe("task-api"); + }); + + it("addProject does not duplicate existing project", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "task-api", name: "Task API" }], + active_project: "task-api", + })); + + ciFiles.addProject("task-api", "Task API V2"); + + const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); + expect(config.projects).toHaveLength(1); + }); + + it("addProject creates project subdirectory", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [], + active_project: "", + })); + + ciFiles.addProject("task-api", "Task API", true); + + expect(fs.existsSync(path.join(dir, ".ci", "task-api"))).toBe(true); + }); + + it("getActiveProject returns from config", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "task-api", name: "Task API", default: true }], + active_project: "task-api", + })); + + expect(ciFiles.getActiveProject()).toBe("task-api"); + }); + + it("setActiveProject updates config", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [ + { slug: "task-api", name: "Task API" }, + { slug: "auth-svc", name: "Auth Service" }, + ], + active_project: "task-api", + })); + + ciFiles.setActiveProject("auth-svc"); + + const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); + expect(config.active_project).toBe("auth-svc"); + }); + + it("listProjects returns projects from config", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [ + { slug: "task-api", name: "Task API", default: true }, + { slug: "auth-svc", name: "Auth Service" }, + ], + active_project: "task-api", + })); + + const projects = ciFiles.listProjects(); + expect(projects).toHaveLength(2); + expect(projects[0].slug).toBe("task-api"); + expect(projects[1].slug).toBe("auth-svc"); + }); + }); + + describe("needsMigration", () => { + it("returns false when not initialized", () => { + const ciFiles = new CiFiles(dir); + expect(ciFiles.needsMigration()).toBe(false); + }); + + it("returns false when already multi-project", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "default", name: "Default" }], + })); + fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test"); + expect(ciFiles.needsMigration()).toBe(false); + }); + + it("returns true when flat files exist without subdirs or multi-project config", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); + fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test"); + expect(ciFiles.needsMigration()).toBe(true); + }); + + it("returns false when flat files exist but subdirs also exist", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); + fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test"); + fs.mkdirSync(path.join(dir, ".ci", "task-api")); + fs.writeFileSync(path.join(dir, ".ci", "task-api", "PROJECT.md"), "# Task API"); + expect(ciFiles.needsMigration()).toBe(false); + }); + }); + + describe("migrateFlatToProject", () => { + it("moves flat files to project subdirectory", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); + fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test Project"); + fs.writeFileSync(path.join(dir, ".ci", "ARCHITECTURE.md"), "# Architecture"); + fs.writeFileSync(path.join(dir, ".ci", "ROADMAP.md"), "# Roadmap"); + fs.writeFileSync(path.join(dir, ".ci", "REQUIREMENTS.md"), "# Requirements"); + + ciFiles.migrateFlatToProject("my-app"); + + expect(fs.existsSync(path.join(dir, ".ci", "my-app", "PROJECT.md"))).toBe(true); + expect(fs.existsSync(path.join(dir, ".ci", "my-app", "ARCHITECTURE.md"))).toBe(true); + expect(fs.existsSync(path.join(dir, ".ci", "my-app", "ROADMAP.md"))).toBe(true); + expect(fs.existsSync(path.join(dir, ".ci", "my-app", "REQUIREMENTS.md"))).toBe(true); + + const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); + expect(config.projects).toHaveLength(1); + expect(config.active_project).toBe("my-app"); + }); + + it("does not migrate when not needed", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "existing", name: "Existing" }], + })); + + ciFiles.migrateFlatToProject("new-proj"); + + const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); + expect(config.projects).toHaveLength(1); + expect(config.projects[0].slug).toBe("existing"); + }); + }); + + describe("isNfrMilestone", () => { + it("returns true when no roadmap exists", () => { + const ciFiles = new CiFiles(dir); + expect(ciFiles.isNfrMilestone()).toBe(true); + }); + + it("returns true when phases are all NFR types", () => { + const ciFiles = new CiFiles(dir, "nfr-proj"); + ciFiles.ensureProjectDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "nfr-proj", name: "NFR Project", default: true }], + active_project: "nfr-proj", + })); + const roadmap: RoadmapMd = { + overview: "NFR-only", + phases: [ + { number: 1, name: "test-coverage", description: "Add tests", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, + { number: 2, name: "refactor-api", description: "Refactor", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] }, + ], + }; + ciFiles.writeRoadmapMd(roadmap); + expect(ciFiles.isNfrMilestone()).toBe(true); + }); + + it("returns false when phases include feature work", () => { + const ciFiles = new CiFiles(dir, "feat-proj"); + ciFiles.ensureProjectDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "feat-proj", name: "Feature Project", default: true }], + active_project: "feat-proj", + })); + const roadmap: RoadmapMd = { + overview: "mixed", + phases: [ + { number: 1, name: "authentication", description: "Auth feature", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, + { number: 2, name: "test-coverage", description: "Add tests", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] }, + ], + }; + ciFiles.writeRoadmapMd(roadmap); + expect(ciFiles.isNfrMilestone()).toBe(false); + }); + }); + + describe("multi-project file paths", () => { + it("writes PROJECT.md to project subdirectory when slug is set", () => { + const ciFiles = new CiFiles(dir, "my-app"); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ + projects: [{ slug: "my-app", name: "My App", default: true }], + active_project: "my-app", + })); + + const project: ProjectMd = { + name: "My App", + coreValue: "Build something cool", + requirements: { validated: [], active: [], outOfScope: [] }, + constraints: [], + context: "Test context", + keyDecisions: [], + }; + + ciFiles.writeProjectMd(project, "initial"); + + expect(fs.existsSync(path.join(dir, ".ci", "my-app", "PROJECT.md"))).toBe(true); + }); + + it("writes PROJECT.md to .ci root when no slug is set", () => { + const ciFiles = new CiFiles(dir); + ciFiles.ensureCIDir(); + fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); + + const project: ProjectMd = { + name: "Default App", + coreValue: "Build something", + requirements: { validated: [], active: [], outOfScope: [] }, + constraints: [], + context: "Test context", + keyDecisions: [], + }; + + ciFiles.writeProjectMd(project, "initial"); + + expect(fs.existsSync(path.join(dir, ".ci", "PROJECT.md"))).toBe(true); + }); + }); + describe("PROJECT.md", () => { const project: ProjectMd = { name: "Task API", diff --git a/src/core/ci-files.ts b/src/core/ci-files.ts index 7c7e49a..70baca6 100644 --- a/src/core/ci-files.ts +++ b/src/core/ci-files.ts @@ -64,33 +64,204 @@ export interface ArchitectureMd { buildOrder: string[]; } +export interface ProjectEntry { + slug: string; + name: string; + default?: boolean; +} + export class CiFiles { private projectPath: string; + private projectSlug: string; - constructor(projectPath: string) { + constructor(projectPath: string, projectSlug?: string) { this.projectPath = projectPath; + this.projectSlug = projectSlug || ""; } private get ciDir(): string { return path.join(this.projectPath, CI_DIR); } + private get projectDir(): string { + if (this.projectSlug) { + return path.join(this.ciDir, this.projectSlug); + } + return this.ciDir; + } + + setProjectSlug(slug: string): void { + this.projectSlug = slug; + } + + getProjectSlug(): string { + return this.projectSlug; + } + ensureCIDir(): void { ensureDir(this.ciDir); } + ensureProjectDir(): void { + this.ensureCIDir(); + if (this.projectSlug) { + ensureDir(this.projectDir); + } + } + isInitialized(): boolean { return fileExists(path.join(this.ciDir, "config.json")); } + isMultiProject(): boolean { + if (!this.isInitialized()) return false; + const config = this.readConfigJson(); + const projects = config?.projects; + return Array.isArray(projects) && (projects as unknown[]).length > 0; + } + + listProjects(): ProjectEntry[] { + if (!this.isInitialized()) return []; + + const config = this.readConfigJson(); + if (Array.isArray(config?.projects) && config.projects.length > 0) { + return config.projects; + } + + const subdirs = this.getProjectSubdirectories(); + if (subdirs.length > 0) { + return subdirs.map((slug) => { + const projMd = this.readProjectMdForSlug(slug); + return { + slug, + name: projMd?.name || slug, + default: subdirs.length === 1, + }; + }); + } + + return [{ slug: "default", name: "Default Project", default: true }]; + } + + getActiveProject(): string { + if (!this.isInitialized()) return ""; + + const config = this.readConfigJson(); + if (config && typeof config.active_project === "string") return config.active_project; + + const projects = this.listProjects(); + const defaultProject = projects.find((p) => p.default); + if (defaultProject) return defaultProject.slug; + + return projects.length > 0 ? projects[0].slug : ""; + } + + setActiveProject(slug: string): void { + this.ensureCIDir(); + const config = this.readConfigJson() || {}; + config.active_project = slug; + this.writeConfigJson(config); + } + + addProject(slug: string, name: string, isDefault: boolean = false): void { + this.ensureCIDir(); + const config = this.readConfigJson() || {}; + + if (!Array.isArray(config.projects)) { + config.projects = []; + } + + if ((config.projects as unknown[]).some((p: unknown) => (p as ProjectEntry).slug === slug)) return; + + (config.projects as ProjectEntry[]).push({ slug, name, default: isDefault }); + + if (isDefault || (config.projects as unknown[]).length === 1) { + config.active_project = slug; + } + + this.writeConfigJson(config); + ensureDir(path.join(this.ciDir, slug)); + } + + needsMigration(): boolean { + if (!this.isInitialized()) return false; + if (this.isMultiProject()) return false; + + const hasFlatFiles = fileExists(path.join(this.ciDir, "PROJECT.md")); + const hasSubdirs = this.getProjectSubdirectories().length > 0; + + return hasFlatFiles && !hasSubdirs; + } + + migrateFlatToProject(slug: string): void { + if (!this.needsMigration()) return; + + this.ensureCIDir(); + const projectDir = path.join(this.ciDir, slug); + ensureDir(projectDir); + + const filesToMove = ["PROJECT.md", "ARCHITECTURE.md", "ROADMAP.md", "REQUIREMENTS.md"]; + for (const file of filesToMove) { + const src = path.join(this.ciDir, file); + const dest = path.join(projectDir, file); + if (fileExists(src) && !fileExists(dest)) { + const content = readFile(src); + if (content) { + writeFile(dest, content); + } + } + } + + const config = this.readConfigJson() || {}; + config.projects = [{ slug, name: slug, default: true }]; + config.active_project = slug; + this.writeConfigJson(config); + } + + private getProjectSubdirectories(): string[] { + if (!fs.existsSync(this.ciDir)) return []; + + try { + return fs.readdirSync(this.ciDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .filter((d) => { + const projectFile = path.join(this.ciDir, d.name, "PROJECT.md"); + return fileExists(projectFile); + }) + .map((d) => d.name); + } catch { + return []; + } + } + + private readConfigJson(): Record | null { + const content = readFile(path.join(this.ciDir, "config.json")); + if (!content) return null; + try { + return JSON.parse(content) as Record; + } catch { + return null; + } + } + + private writeConfigJson(config: Record): void { + writeFile(path.join(this.ciDir, "config.json"), JSON.stringify(config, null, 2)); + } + + private readProjectMdForSlug(slug: string): ProjectMd | null { + const content = readFile(path.join(this.ciDir, slug, "PROJECT.md")); + if (!content) return null; + return this.parseProjectMd(content); + } + readProjectMd(): ProjectMd | null { - const content = readFile(path.join(this.ciDir, "PROJECT.md")); + const content = readFile(path.join(this.projectDir, "PROJECT.md")); if (!content) return null; return this.parseProjectMd(content); } writeProjectMd(project: ProjectMd, reason: string): void { - this.ensureCIDir(); + this.ensureProjectDir(); const lines: string[] = [ `# ${project.name}`, "", @@ -130,17 +301,17 @@ export class CiFiles { "", ]; - writeFile(path.join(this.ciDir, "PROJECT.md"), lines.join("\n")); + writeFile(path.join(this.projectDir, "PROJECT.md"), lines.join("\n")); } readRoadmapMd(): RoadmapMd | null { - const content = readFile(path.join(this.ciDir, "ROADMAP.md")); + const content = readFile(path.join(this.projectDir, "ROADMAP.md")); if (!content) return null; return this.parseRoadmapMd(content); } writeRoadmapMd(roadmap: RoadmapMd): void { - this.ensureCIDir(); + this.ensureProjectDir(); const lines: string[] = [ "# Roadmap", "", @@ -160,7 +331,7 @@ export class CiFiles { for (const phase of roadmap.phases) { lines.push(`### Phase ${phase.number}: ${phase.name}`); - lines.push(`**Goal**: ${phase.description}`); + lines.push(`**Goal**.: ${phase.description}`); lines.push(`**Depends on**: ${phase.dependsOn.length > 0 ? phase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "Nothing"}`); lines.push(`**Requirements**: ${phase.requirements.length > 0 ? phase.requirements.join(", ") : "None"}`); lines.push("**Success Criteria**:"); @@ -171,17 +342,17 @@ export class CiFiles { lines.push(""); } - writeFile(path.join(this.ciDir, "ROADMAP.md"), lines.join("\n")); + writeFile(path.join(this.projectDir, "ROADMAP.md"), lines.join("\n")); } readRequirementsMd(): RequirementsMd | null { - const content = readFile(path.join(this.ciDir, "REQUIREMENTS.md")); + const content = readFile(path.join(this.projectDir, "REQUIREMENTS.md")); if (!content) return null; return this.parseRequirementsMd(content); } writeRequirementsMd(requirements: RequirementsMd): void { - this.ensureCIDir(); + this.ensureProjectDir(); const lines: string[] = [ "# Requirements", "", @@ -226,17 +397,17 @@ export class CiFiles { lines.push(`| ${t.requirement} | Phase ${t.phase} | ${t.status} |`); } - writeFile(path.join(this.ciDir, "REQUIREMENTS.md"), lines.join("\n")); + writeFile(path.join(this.projectDir, "REQUIREMENTS.md"), lines.join("\n")); } readArchitectureMd(): ArchitectureMd | null { - const content = readFile(path.join(this.ciDir, "ARCHITECTURE.md")); + const content = readFile(path.join(this.projectDir, "ARCHITECTURE.md")); if (!content) return null; return this.parseArchitectureMd(content); } writeArchitectureMd(architecture: ArchitectureMd): void { - this.ensureCIDir(); + this.ensureProjectDir(); const lines: string[] = [ "# Architecture", "", @@ -267,7 +438,7 @@ export class CiFiles { lines.push(`1. ${step}`); } - writeFile(path.join(this.ciDir, "ARCHITECTURE.md"), lines.join("\n")); + writeFile(path.join(this.projectDir, "ARCHITECTURE.md"), lines.join("\n")); } updateRequirementStatus(reqId: string, status: "pending" | "in_progress" | "complete" | "blocked"): void { @@ -296,6 +467,21 @@ export class CiFiles { this.writeRoadmapMd(roadmap); } + isNfrMilestone(): boolean { + const roadmap = this.readRoadmapMd(); + if (!roadmap) return true; + + const nfrTypes: string[] = ["fix", "chore", "docs", "perf", "refactor", "test"]; + for (const phase of roadmap.phases) { + if (phase.status === "in_progress" || phase.status === "not_started") { + const phaseName = phase.name.toLowerCase(); + const hasFeature = !nfrTypes.some((t) => phaseName.includes(t)) && !phaseName.includes("bug") && !phaseName.includes("tune") && !phaseName.includes("refresh"); + if (hasFeature) return false; + } + } + return true; + } + private parseProjectMd(content: string): ProjectMd { return { name: this.extractSection(content, "# ") || "Unknown", @@ -312,10 +498,50 @@ export class CiFiles { } private parseRoadmapMd(content: string): RoadmapMd { - return { - overview: this.extractSection(content, "## Overview") || "", - phases: [], - }; + const overview = this.extractSection(content, "## Overview") || ""; + + const phases: RoadmapMd["phases"] = []; + const phaseRegex = /### Phase (\d+): (.+)/g; + let match; + + while ((match = phaseRegex.exec(content)) !== null) { + const number = parseInt(match[1], 10); + const name = match[2].trim(); + const sectionStart = match.index + match[0].length; + const nextPhase = content.indexOf("\n### Phase ", sectionStart); + const nextH2 = content.indexOf("\n## ", sectionStart); + const sectionEnd = Math.min( + nextPhase >= 0 ? nextPhase : content.length, + nextH2 >= 0 ? nextH2 : content.length + ); + const section = content.slice(sectionStart, sectionEnd); + + const goalMatch = section.match(/\*\*Goal\.?\*\*:\s*(.+)/); + const statusMatch = section.match(/\*\*Status\*\*:\s*(.+)/); + const reqMatch = section.match(/\*\*Requirements\*\*:\s*(.+)/); + const depsMatch = section.match(/\*\*Depends on\*\*:\s*(.+)/); + + const statusVal = statusMatch ? statusMatch[1].trim() : "not_started"; + const validStatuses = ["not_started", "in_progress", "complete", "deferred"] as const; + + phases.push({ + number, + name, + description: goalMatch ? goalMatch[1].trim() : "", + status: validStatuses.includes(statusVal as typeof validStatuses[number]) + ? (statusVal as RoadmapMd["phases"][number]["status"]) + : "not_started", + dependsOn: depsMatch && depsMatch[1].trim() !== "Nothing" + ? depsMatch[1].split(",").map((d: string) => parseInt(d.trim().replace(/Phase /g, ""), 10)).filter((n: number) => !isNaN(n)) + : [], + requirements: reqMatch && reqMatch[1].trim() !== "None" + ? reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean) + : [], + successCriteria: [], + }); + } + + return { overview, phases }; } private parseRequirementsMd(content: string): RequirementsMd { diff --git a/src/core/commit-builder.test.ts b/src/core/commit-builder.test.ts index de43e7c..19978b1 100644 --- a/src/core/commit-builder.test.ts +++ b/src/core/commit-builder.test.ts @@ -13,6 +13,18 @@ describe("CommitBuilder", () => { expect(block).toContain("status: execute"); }); + it("builds ci block with project", () => { + const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" }; + const block = CommitBuilder.buildCiBlock(ci); + expect(block).toContain("project: task-api"); + }); + + it("builds ci block without project when not set", () => { + const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute" }; + const block = CommitBuilder.buildCiBlock(ci); + expect(block).not.toContain("project:"); + }); + it("builds ci block with decisions", () => { const ci: CiMetadata = { phase: 1, @@ -172,6 +184,16 @@ describe("CommitBuilder", () => { expect(parsed.compound!.problem).toBe("Token replay attacks"); expect(parsed.lessons).toHaveLength(1); }); + + it("round-trips project field", () => { + const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" }; + const block = CommitBuilder.buildCiBlock(ci); + const fullMessage = `feat(task-api/P01): test\n\n---ci---\n${block}\n---/ci---`; + const extracted = extractCiBlock(fullMessage)!; + const parsed = parseCiBlock(extracted)!; + + expect(parsed.project).toBe("task-api"); + }); }); describe("buildInitCommit", () => { @@ -193,6 +215,19 @@ describe("CommitBuilder", () => { expect(msg).toContain("Build a REST API for task management"); expect(msg).toContain("AUTH-01"); }); + + it("builds an init commit message with project", () => { + const msg = CommitBuilder.buildInitCommit({ + projectName: "task-api", + phaseCount: 4, + milestone: "v1.0", + project: "task-api", + specification: "Build a REST API", + requirements: ["AUTH-01"], + }); + + expect(msg).toContain("project: task-api"); + }); }); describe("buildTaskCommit", () => { @@ -223,6 +258,22 @@ describe("CommitBuilder", () => { expect(msg).toContain("D-003"); expect(msg).toContain("AUTH-01"); }); + + it("builds a task commit with project prefix", () => { + const msg = CommitBuilder.buildTaskCommit({ + type: "feat", + phase: 1, + milestone: "v1.0", + project: "task-api", + plan: "01-01", + task: "01-01-02", + subject: "registration endpoint", + status: "execute", + }); + + expect(msg).toContain("feat(task-api/P01-01-02):"); + expect(msg).toContain("project: task-api"); + }); }); describe("buildPhaseCompletionCommit", () => { diff --git a/src/core/commit-builder.ts b/src/core/commit-builder.ts index 698ca2d..77e326e 100644 --- a/src/core/commit-builder.ts +++ b/src/core/commit-builder.ts @@ -25,6 +25,7 @@ export interface InitCommitInput { projectName: string; phaseCount: number; milestone: string; + project?: string; specification: string; requirements?: string[]; constraints?: string[]; @@ -36,6 +37,7 @@ export interface TaskCommitInput { type: CommitType; phase: number; milestone: string; + project?: string; plan: string; task: string; subject: string; @@ -95,6 +97,7 @@ export class CommitBuilder { lines.push(`phase: ${ci.phase}`); lines.push(`milestone: ${ci.milestone}`); + if (ci.project) lines.push(`project: ${ci.project}`); if (ci.plan) lines.push(`plan: ${ci.plan}`); if (ci.task) lines.push(`task: ${ci.task}`); @@ -162,6 +165,7 @@ export class CommitBuilder { const ci: CiMetadata = { phase: 0, milestone: input.milestone, + project: input.project, status: "specify", decisions: input.decisions, }; @@ -193,6 +197,7 @@ export class CommitBuilder { const ci: CiMetadata = { phase: input.phase, milestone: input.milestone, + project: input.project, plan: input.plan, task: input.task, status: input.status, @@ -204,6 +209,7 @@ export class CommitBuilder { phase: input.phase, plan: input.plan, task: input.task, + project: input.project, isInit: false, isMilestone: false, }; diff --git a/src/core/commit-parser.test.ts b/src/core/commit-parser.test.ts index 8a9d085..91382dc 100644 --- a/src/core/commit-parser.test.ts +++ b/src/core/commit-parser.test.ts @@ -4,6 +4,9 @@ import { CommitEscalation, CommitRequirements, CommitCompoundMeta, + parseCommitScope, + formatCommitScope, + CommitScope, } from "../types/commit-meta.js"; import { extractCiBlock, @@ -112,6 +115,19 @@ escalations: All tests pass. Awaiting deploy approval.`; +const SAMPLE_PROJECT_COMMIT = `feat(task-api/P01-01-02): create registration endpoint + +---ci--- +phase: 1 +milestone: v1.0 +project: task-api +plan: 01-01 +task: 01-01-02 +status: execute +---/ci--- + +Registration endpoint for task-api project.`; + describe("extractCiBlock", () => { it("extracts ---ci--- block from commit message", () => { const block = extractCiBlock(SAMPLE_INIT_COMMIT); @@ -192,6 +208,14 @@ describe("parseCiBlock", () => { expect(meta.escalations![0].resolution).toBe("pending"); }); + it("parses project field", () => { + const block = extractCiBlock(SAMPLE_PROJECT_COMMIT)!; + const meta = parseCiBlock(block)!; + expect(meta.project).toBe("task-api"); + expect(meta.phase).toBe(1); + expect(meta.plan).toBe("01-01"); + }); + it("returns null for empty block", () => { const meta = parseCiBlock(""); expect(meta).toBeNull(); @@ -249,4 +273,85 @@ describe("parseCommitMessage", () => { const parsed = parseCommitMessage("pqr678", SAMPLE_TASK_COMMIT); expect(parsed.body).toContain("POST /auth/register validates email and password"); }); + + it("parses commit with project-prefixed scope", () => { + const parsed = parseCommitMessage("stu901", SAMPLE_PROJECT_COMMIT); + expect(parsed.type).toBe("feat"); + expect(parsed.scope).toBe("task-api/P01-01-02"); + expect(parsed.ci!.project).toBe("task-api"); + }); +}); + +describe("parseCommitScope", () => { + it("parses init scope", () => { + const scope = parseCommitScope("init"); + expect(scope.isInit).toBe(true); + expect(scope.phase).toBe(0); + }); + + it("parses milestone scope", () => { + const scope = parseCommitScope("milestone"); + expect(scope.isMilestone).toBe(true); + expect(scope.phase).toBe(0); + }); + + it("parses simple phase scope", () => { + const scope = parseCommitScope("P01"); + expect(scope.phase).toBe(1); + expect(scope.isInit).toBe(false); + expect(scope.isMilestone).toBe(false); + }); + + it("parses task scope with plan and task", () => { + const scope = parseCommitScope("P01-01-02"); + expect(scope.phase).toBe(1); + expect(scope.plan).toBe("01-01"); + expect(scope.task).toBe("01-01-02"); + }); + + it("parses project-prefixed scope", () => { + const scope = parseCommitScope("task-api/P01-01-02"); + expect(scope.project).toBe("task-api"); + expect(scope.phase).toBe(1); + expect(scope.plan).toBe("01-01"); + expect(scope.task).toBe("01-01-02"); + }); + + it("does not treat P-prefixed scope as project-prefixed", () => { + const scope = parseCommitScope("P01-auth"); + expect(scope.project).toBeUndefined(); + expect(scope.phase).toBe(1); + }); +}); + +describe("formatCommitScope", () => { + it("formats init scope", () => { + const scope: CommitScope = { phase: 0, isInit: true, isMilestone: false }; + expect(formatCommitScope(scope)).toBe("init"); + }); + + it("formats milestone scope", () => { + const scope: CommitScope = { phase: 0, isInit: false, isMilestone: true }; + expect(formatCommitScope(scope)).toBe("milestone"); + }); + + it("formats simple phase scope", () => { + const scope: CommitScope = { phase: 1, isInit: false, isMilestone: false }; + expect(formatCommitScope(scope)).toBe("P01"); + }); + + it("formats task scope", () => { + const scope: CommitScope = { phase: 1, plan: "01-01", task: "01-01-02", isInit: false, isMilestone: false }; + expect(formatCommitScope(scope)).toBe("P01-01-02"); + }); + + it("formats project-prefixed scope", () => { + const scope: CommitScope = { phase: 1, project: "task-api", plan: "01-01", task: "01-01-02", isInit: false, isMilestone: false }; + expect(formatCommitScope(scope)).toBe("task-api/P01-01-02"); + }); + + it("formats project-prefixed phase scope without plan/task", () => { + const scope: CommitScope = { phase: 2, project: "auth-svc", isInit: false, isMilestone: false }; + expect(formatCommitScope(scope)).toBe("auth-svc/P02"); + }); }); \ No newline at end of file diff --git a/src/core/commit-parser.ts b/src/core/commit-parser.ts index 8a76cf0..35a792d 100644 --- a/src/core/commit-parser.ts +++ b/src/core/commit-parser.ts @@ -40,6 +40,9 @@ export function parseCiBlock(yaml: string): CiMetadata | null { const statusMatch = yaml.match(/^status:\s*(.+)$/m); if (statusMatch) result.status = statusMatch[1].trim() as CiMetadata["status"]; + const projectMatch = yaml.match(/^project:\s*(.+)$/m); + if (projectMatch) result.project = projectMatch[1].trim(); + result.decisions = parseDecisionsFromYaml(yaml); result.escalations = parseEscalationsFromYaml(yaml); result.requirements = parseRequirementsFromYaml(yaml); diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 61308fb..b11c8d3 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -45,6 +45,37 @@ describe("CI Config", () => { expect(config.autonomy.max_revision_iterations).toBe(3); expect(config.autonomy.escalation_hooks).toEqual(["deploy", "delete_data", "merge_to_main"]); }); + + it("initializes with project slug", () => { + const config = initCI(tempDir, undefined, "task-api", "Task API"); + expect(config.projects).toHaveLength(1); + expect(config.projects[0].slug).toBe("task-api"); + expect(config.projects[0].name).toBe("Task API"); + expect(config.projects[0].default).toBe(true); + expect(config.active_project).toBe("task-api"); + }); + + it("does not re-add existing project slug", () => { + initCI(tempDir, undefined, "task-api", "Task API"); + const config = initCI(tempDir, undefined, "task-api", "Task API V2"); + expect(config.projects).toHaveLength(1); + }); + + it("defaults projects and active_project when no slug provided", () => { + const config = initCI(tempDir); + expect(config.projects).toEqual([]); + expect(config.active_project).toBe(""); + }); + + it("preserves existing projects when adding new one", () => { + const config1 = initCI(tempDir, undefined, "task-api", "Task API"); + const config2 = initCI(tempDir, { + ...config1, + projects: [...config1.projects, { slug: "auth-svc", name: "Auth Service" }], + }, "auth-svc", "Auth Service"); + expect(config2.projects).toHaveLength(2); + expect(config2.active_project).toBe("auth-svc"); + }); }); describe("loadConfig", () => { @@ -68,6 +99,13 @@ describe("CI Config", () => { expect(config.git.auto_commit).toBe(true); expect(config.git.branching_strategy).toBe("phase"); }); + + it("loads projects array from config", () => { + initCI(tempDir, undefined, "task-api", "Task API"); + const config = loadConfig(tempDir); + expect(config.projects).toHaveLength(1); + expect(config.active_project).toBe("task-api"); + }); }); describe("saveConfig", () => { @@ -81,6 +119,19 @@ describe("CI Config", () => { const loaded = loadConfig(tempDir); expect(loaded.autonomy.level).toBe("guided"); }); + + it("saves and reloads config with projects", () => { + ensureCIDir(tempDir); + const config = { + ...DEFAULT_CI_CONFIG, + projects: [{ slug: "my-app", name: "My App", default: true }], + active_project: "my-app", + }; + saveConfig(tempDir, config); + const loaded = loadConfig(tempDir); + expect(loaded.projects).toHaveLength(1); + expect(loaded.active_project).toBe("my-app"); + }); }); describe("isCIInitialized", () => { diff --git a/src/core/config.ts b/src/core/config.ts index 11afdfd..ad52641 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -62,11 +62,24 @@ export function isCIInitialized(projectPath: string): boolean { return fs.existsSync(ciDir) && fs.existsSync(configPath); } -export function initCI(projectPath: string, config?: Partial): CIConfig { +export function initCI(projectPath: string, config?: Partial, projectSlug?: string, projectName?: string): CIConfig { ensureCIDir(projectPath); + + let projects = config?.projects || DEFAULT_CI_CONFIG.projects; + let activeProject = config?.active_project || DEFAULT_CI_CONFIG.active_project; + + if (projectSlug) { + if (!projects.some((p) => p.slug === projectSlug)) { + projects = [...projects, { slug: projectSlug, name: projectName || projectSlug, default: projects.length === 0 }]; + } + activeProject = projectSlug; + } + const fullConfig: CIConfig = { ...DEFAULT_CI_CONFIG, ...config, + projects, + active_project: activeProject, autonomy: { ...DEFAULT_CI_CONFIG.autonomy, ...config?.autonomy }, parallelization: { ...DEFAULT_CI_CONFIG.parallelization, diff --git a/src/core/git-branch.test.ts b/src/core/git-branch.test.ts index 9184ba4..9ab87a2 100644 --- a/src/core/git-branch.test.ts +++ b/src/core/git-branch.test.ts @@ -53,6 +53,22 @@ describe("GitBranch", () => { expect(result.name).toBe("phase/03-real-time-notifications"); }); + + it("creates project-prefixed phase branch when projectSlug is set", () => { + const gitBranch = new GitBranch(repoDir, "task-api"); + const result = gitBranch.createPhaseBranch(1, "authentication"); + + expect(result.created).toBe(true); + expect(result.name).toBe("task-api/phase/01-authentication"); + }); + + it("updates project prefix after setProjectSlug", () => { + const gitBranch = new GitBranch(repoDir); + gitBranch.setProjectSlug("auth-svc"); + const result = gitBranch.createPhaseBranch(2, "token-rotation"); + + expect(result.name).toBe("auth-svc/phase/02-token-rotation"); + }); }); describe("createMilestoneBranch", () => { @@ -71,6 +87,14 @@ describe("GitBranch", () => { const result = gitBranch.createMilestoneBranch("v1.0", "mvp"); expect(result.alreadyExisted).toBe(true); }); + + it("creates project-prefixed milestone branch when projectSlug is set", () => { + const gitBranch = new GitBranch(repoDir, "task-api"); + const result = gitBranch.createMilestoneBranch("v1.0", "mvp"); + + expect(result.created).toBe(true); + expect(result.name).toBe("task-api/milestone/v1.0-mvp"); + }); }); describe("listPhases", () => { diff --git a/src/core/git-branch.ts b/src/core/git-branch.ts index 81f786a..9786f68 100644 --- a/src/core/git-branch.ts +++ b/src/core/git-branch.ts @@ -30,10 +30,21 @@ export interface MilestoneBranchInfo { export class GitBranch { private projectPath: string; private gitContext: GitContext; + private projectSlug?: string; - constructor(projectPath: string) { + constructor(projectPath: string, projectSlug?: string) { this.projectPath = projectPath; - this.gitContext = new GitContext(projectPath); + this.projectSlug = projectSlug; + this.gitContext = new GitContext(projectPath, projectSlug); + } + + setProjectSlug(slug: string | undefined): void { + this.projectSlug = slug; + this.gitContext.setProjectSlug(slug); + } + + private prefix(name: string): string { + return this.projectSlug ? `${this.projectSlug}/${name}` : name; } private git(args: string): string { @@ -58,7 +69,8 @@ export class GitBranch { createPhaseBranch(phaseNumber: number, phaseName: string): BranchCreateResult { const padded = String(phaseNumber).padStart(2, "0"); const slug = this.slugify(phaseName); - const branchName = `phase/${padded}-${slug}`; + const baseName = `phase/${padded}-${slug}`; + const branchName = this.prefix(baseName); const existing = this.gitContext.getBranches().find((b) => b.name === branchName); if (existing) { @@ -75,7 +87,8 @@ export class GitBranch { createMilestoneBranch(version: string, milestoneName: string): BranchCreateResult { const slug = this.slugify(milestoneName); - const branchName = `milestone/${version}-${slug}`; + const baseName = `milestone/${version}-${slug}`; + const branchName = this.prefix(baseName); const existing = this.gitContext.getBranches().find((b) => b.name === branchName); if (existing) { diff --git a/src/core/git-context.test.ts b/src/core/git-context.test.ts index b1f411e..5fafc5a 100644 --- a/src/core/git-context.test.ts +++ b/src/core/git-context.test.ts @@ -56,6 +56,24 @@ describe("GitContext", () => { }); }); + describe("projectSlug", () => { + it("defaults to undefined", () => { + const ctx = new GitContext(repoDir); + expect(ctx.getProjectSlug()).toBeUndefined(); + }); + + it("accepts project slug in constructor", () => { + const ctx = new GitContext(repoDir, "task-api"); + expect(ctx.getProjectSlug()).toBe("task-api"); + }); + + it("setProjectSlug updates slug", () => { + const ctx = new GitContext(repoDir); + ctx.setProjectSlug("auth-svc"); + expect(ctx.getProjectSlug()).toBe("auth-svc"); + }); + }); + describe("getRecentCommits", () => { it("returns parsed commits with ci blocks", () => { commit(repoDir, `docs(init): initialize project @@ -187,5 +205,78 @@ lessons: expect(milestoneBranches.length).toBeGreaterThanOrEqual(1); expect(phaseBranches[0].phaseNumber).toBe(1); }); + + it("strips project prefix when projectSlug is set", () => { + commit(repoDir, "initial"); + execSync("git checkout -b task-api/phase/01-auth", { cwd: repoDir, stdio: "pipe" }); + commit(repoDir, "feat: auth work"); + + const ctx = new GitContext(repoDir, "task-api"); + const branches = ctx.getBranches(); + + const phaseBranches = branches.filter((b) => b.type === "phase"); + expect(phaseBranches.length).toBeGreaterThanOrEqual(1); + expect(phaseBranches[0].phaseNumber).toBe(1); + expect(phaseBranches[0].name).toBe("task-api/phase/01-auth"); + }); + }); + + describe("detectProjectFromCommit", () => { + it("detects project from ci block project field", () => { + commit(repoDir, `feat(P01): task work + +---ci--- +phase: 1 +milestone: v1.0 +project: task-api +status: execute +---/ci---`); + + const ctx = new GitContext(repoDir); + expect(ctx.detectProjectFromCommit()).toBe("task-api"); + }); + + it("detects project from branch prefix", () => { + commit(repoDir, "initial"); + execSync("git checkout -b auth-svc/phase/01-auth", { cwd: repoDir, stdio: "pipe" }); + commit(repoDir, "feat: auth work"); + + const ctx = new GitContext(repoDir); + expect(ctx.detectProjectFromCommit()).toBe("auth-svc"); + }); + + it("returns null when no project detected", () => { + commit(repoDir, "feat: some work"); + const ctx = new GitContext(repoDir); + expect(ctx.detectProjectFromCommit()).toBeNull(); + }); + }); + + describe("isNfrMilestone", () => { + it("returns true when no feat commits exist", () => { + commit(repoDir, `chore(P01): cleanup + +---ci--- +phase: 1 +milestone: v0.1.1 +status: execute +---/ci---`); + + const ctx = new GitContext(repoDir); + expect(ctx.isNfrMilestone()).toBe(true); + }); + + it("returns false when feat commits exist", () => { + commit(repoDir, `feat(P01): add feature + +---ci--- +phase: 1 +milestone: v1.0 +status: execute +---/ci---`); + + const ctx = new GitContext(repoDir); + expect(ctx.isNfrMilestone()).toBe(false); + }); }); }); \ No newline at end of file diff --git a/src/core/git-context.ts b/src/core/git-context.ts index 75a2910..5dfe19c 100644 --- a/src/core/git-context.ts +++ b/src/core/git-context.ts @@ -27,9 +27,19 @@ export interface BranchInfo { export class GitContext { private projectPath: string; + private projectSlug?: string; - constructor(projectPath: string) { + constructor(projectPath: string, projectSlug?: string) { this.projectPath = projectPath; + this.projectSlug = projectSlug; + } + + setProjectSlug(slug: string | undefined): void { + this.projectSlug = slug; + } + + getProjectSlug(): string | undefined { + return this.projectSlug; } private git(args: string): string { @@ -98,14 +108,21 @@ export class GitContext { merged: mergedBranches.has(cleanName), }; - const phaseMatch = cleanName.match(/^phase\/(\d+)-(.+)/); + let branchName = cleanName; + + const projectPrefix = this.projectSlug ? `${this.projectSlug}/` : ""; + if (projectPrefix && cleanName.startsWith(projectPrefix)) { + branchName = cleanName.slice(projectPrefix.length); + } + + const phaseMatch = branchName.match(/^phase\/(\d+)-(.+)/); if (phaseMatch) { info.type = "phase"; info.phaseNumber = parseInt(phaseMatch[1], 10); return info; } - const milestoneMatch = cleanName.match(/^milestone\/(.+)/); + const milestoneMatch = branchName.match(/^milestone\/(.+)/); if (milestoneMatch) { info.type = "milestone"; info.milestone = milestoneMatch[1]; @@ -311,4 +328,27 @@ export class GitContext { return commits; } + + detectProjectFromCommit(): string | null { + const commit = this.getLatestCiCommit(); + if (commit?.ci?.project) return commit.ci.project; + + const branches = this.getBranches(); + for (const branch of branches) { + const projectMatch = branch.name.match(/^([a-z0-9-]+)\/(?:phase|milestone)\//); + if (projectMatch) return projectMatch[1]; + } + + return null; + } + + isNfrMilestone(): boolean { + const commits = this.getRecentCommits(100); + for (const commit of commits) { + if (commit.type === "feat" && commit.ci) { + return false; + } + } + return true; + } } \ No newline at end of file diff --git a/src/types/commit-meta.ts b/src/types/commit-meta.ts index 0c599a5..039fa7c 100644 --- a/src/types/commit-meta.ts +++ b/src/types/commit-meta.ts @@ -20,6 +20,7 @@ export interface CommitScope { phase: number; plan?: string; task?: string; + project?: string; isInit: boolean; isMilestone: boolean; } @@ -53,6 +54,7 @@ export interface CommitCompoundMeta { export interface CiMetadata { phase: number; milestone: string; + project?: string; plan?: string; task?: string; status: PipelineStage; @@ -88,10 +90,19 @@ export function parseCommitScope(scope: string): CommitScope { return { phase: 0, isInit: false, isMilestone: true }; } - const phaseMatch = scope.match(/^P(\d+)/); + let project: string | undefined; + let cleanScope = scope; + + const projectMatch = scope.match(/^([a-z0-9-]+)\/(.+)$/); + if (projectMatch && !scope.startsWith("P")) { + project = projectMatch[1]; + cleanScope = projectMatch[2]; + } + + const phaseMatch = cleanScope.match(/^P(\d+)/); const phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 0; - const parts = scope.split("-"); + const parts = cleanScope.split("-"); let plan: string | undefined; let task: string | undefined; @@ -102,7 +113,7 @@ export function parseCommitScope(scope: string): CommitScope { task = `${plan}-${parts[2]}`; } - return { phase, plan, task, isInit: false, isMilestone: false }; + return { phase, plan, task, project, isInit: false, isMilestone: false }; } export function formatCommitScope(scope: CommitScope): string { @@ -110,7 +121,11 @@ export function formatCommitScope(scope: CommitScope): string { if (scope.isMilestone) return "milestone"; const phaseStr = `P${String(scope.phase).padStart(2, "0")}`; - if (scope.task) return `${phaseStr}-${scope.task.split("-").slice(1).join("-")}`; - if (scope.plan) return `${phaseStr}-${scope.plan.split("-").slice(1).join("-")}`; - return phaseStr; + let suffix: string; + if (scope.task) suffix = `${phaseStr}-${scope.task.split("-").slice(1).join("-")}`; + else if (scope.plan) suffix = `${phaseStr}-${scope.plan.split("-").slice(1).join("-")}`; + else suffix = phaseStr; + + if (scope.project) return `${scope.project}/${suffix}`; + return suffix; } \ No newline at end of file diff --git a/src/types/config.test.ts b/src/types/config.test.ts index 24e8891..ec09ce3 100644 --- a/src/types/config.test.ts +++ b/src/types/config.test.ts @@ -1,4 +1,4 @@ -import { CIConfig, DEFAULT_CI_CONFIG, AutonomyLevel, ModelProfile } from "../types/config.js"; +import { CIConfig, DEFAULT_CI_CONFIG, AutonomyLevel, ModelProfile, ProjectEntry } from "../types/config.js"; describe("CIConfig", () => { it("DEFAULT_CI_CONFIG has all required fields", () => { @@ -17,6 +17,11 @@ describe("CIConfig", () => { expect(DEFAULT_CI_CONFIG.git.auto_push).toBe(false); }); + it("DEFAULT_CI_CONFIG has multi-project fields", () => { + expect(DEFAULT_CI_CONFIG.projects).toEqual([]); + expect(DEFAULT_CI_CONFIG.active_project).toBe(""); + }); + it("AutonomyLevel accepts all valid levels", () => { const levels: AutonomyLevel[] = ["full", "supervised", "guided"]; for (const level of levels) { @@ -46,4 +51,49 @@ describe("CIConfig", () => { "merge_to_main", ]); }); + + describe("ProjectEntry", () => { + it("accepts valid project entries", () => { + const entry: ProjectEntry = { slug: "task-api", name: "Task API", default: true }; + expect(entry.slug).toBe("task-api"); + expect(entry.name).toBe("Task API"); + expect(entry.default).toBe(true); + }); + + it("default field is optional", () => { + const entry: ProjectEntry = { slug: "task-api", name: "Task API" }; + expect(entry.default).toBeUndefined(); + }); + }); + + describe("CIConfig with projects", () => { + it("supports multiple projects", () => { + const config: CIConfig = { + ...DEFAULT_CI_CONFIG, + projects: [ + { slug: "task-api", name: "Task API", default: true }, + { slug: "auth-svc", name: "Auth Service" }, + ], + active_project: "task-api", + }; + expect(config.projects).toHaveLength(2); + expect(config.active_project).toBe("task-api"); + expect(config.projects[0].default).toBe(true); + }); + + it("supports single project", () => { + const config: CIConfig = { + ...DEFAULT_CI_CONFIG, + projects: [{ slug: "my-app", name: "My App", default: true }], + active_project: "my-app", + }; + expect(config.projects).toHaveLength(1); + expect(config.active_project).toBe("my-app"); + }); + + it("defaults to empty projects array and empty active_project", () => { + expect(DEFAULT_CI_CONFIG.projects).toEqual([]); + expect(DEFAULT_CI_CONFIG.active_project).toBe(""); + }); + }); }); \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts index 866a1d7..59afa37 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -61,7 +61,15 @@ export interface GitConfig { auto_push: boolean; } +export interface ProjectEntry { + slug: string; + name: string; + default?: boolean; +} + export interface CIConfig { + projects: ProjectEntry[]; + active_project: string; autonomy: AutonomyConfig; model_profile: ModelProfile; parallelization: ParallelizationConfig; @@ -71,6 +79,8 @@ export interface CIConfig { } export const DEFAULT_CI_CONFIG: CIConfig = { + projects: [], + active_project: "", autonomy: { level: "full", escalation_hooks: ["deploy", "delete_data", "merge_to_main"], diff --git a/src/version.ts b/src/version.ts index 14a582b..cd46e43 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.2.0"; \ No newline at end of file +export const VERSION = "0.3.0"; \ No newline at end of file