feat(P03): multi-project support, NFR milestone versioning, phase context reset, install scripts (v0.3.0)

This commit is contained in:
CI
2026-05-29 15:13:45 +00:00
parent e4bb3a9970
commit ddf04792c7
57 changed files with 1748 additions and 59 deletions
+7
View File
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -19,6 +19,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -20,6 +20,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -20,6 +20,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -20,6 +20,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -22,6 +22,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -19,6 +19,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -19,6 +19,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -20,6 +20,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -19,6 +19,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+7
View File
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
</role>
<project_context>
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: <active_project>` in ---ci--- block
- Branch names are prefixed with <slug>/ in multi-project mode
- .ci/ files are in .ci/<slug>/ 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
+1 -1
View File
@@ -1 +1 @@
0.2.0
0.3.0
+12
View File
@@ -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: <slug>` in `---ci---` block
- Branch names are prefixed with `<slug>/`
- `.ci/` files are in `.ci/<slug>/` 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
+3 -1
View File
@@ -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/<slug>/` 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/<slug>/` subdirectories, not the root `.ci/` directory.
## Verbosity
High. Explain reasoning, show evidence, and document assumptions.
+8
View File
@@ -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: <slug>` in `---ci---` blocks
- Branch names are prefixed with `<slug>/`
- Review findings reference project-scoped paths
## Output Style
- Critical, detail-focused responses that prioritize correctness
+27
View File
@@ -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 | `<slug>/phase/NN-slug` | `auth/phase/01-jwt-setup` |
| Milestone | `<slug>/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
+33 -5
View File
@@ -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-slug>/
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 `<default-slug>/` 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) |
| `<slug>/PROJECT.md` | Vision, core value, requirements, constraints, key decisions per project | Low (phase boundaries) |
| `<slug>/ARCHITECTURE.md` | System architecture, component boundaries, data flow per project | Low (major refactors) |
| `<slug>/ROADMAP.md` | Phase breakdown, milestone mapping, success criteria per project | Low (phase transitions) |
| `<slug>/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/<slug>/` 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/<slug>/` static files with findings
</ci_files_discipline>
+18
View File
@@ -10,6 +10,7 @@ Canonical `---ci---` YAML block schema for CI commits. Every CI-generated commit
<type>(<scope>): <subject>
---ci---
project: <slug> # required in multi-project mode
phase: <number>
milestone: <string>
plan: <string> # 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 |
@@ -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
@@ -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:
+12
View File
@@ -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(<slug>)`
- Scope audit queries to the active project
- All commit messages must include `project: <slug>` 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:
+11
View File
@@ -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(<slug>)`
- All commit messages must include `project: <slug>` in `---ci---` block
If single-project mode: proceed with existing conventions.
## Step 1: Load Git Context
```bash
+12
View File
@@ -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(<slug>)`
- Scope debugging to the active project
- All commit messages must include `project: <slug>` in `---ci---` block
If single-project mode: proceed with existing conventions.
## Step 1: Load Git Context
```bash
+22 -5
View File
@@ -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(<slug>)`
- All subsequent operations use `.ci/<slug>/` subdirectories
- All commit messages must include `project: <slug>` 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/<slug>/PROJECT.md` — vision, requirements, constraints, key decisions (or `.ci/PROJECT.md` in single-project mode)
3. `.ci/<slug>/ARCHITECTURE.md` — system architecture (initial, may be incomplete)
4. `.ci/<slug>/ROADMAP.md` — phase breakdown (to be refined by roadmapper)
5. `.ci/<slug>/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: <slug>
phase: 0
milestone: v1.0
status: specify
@@ -86,6 +101,8 @@ Constraints: [constraint1, ...]
Out of scope: [item1, ...]
```
Include `project: <slug>` in the `---ci---` block when in multi-project mode.
## Step 7: Done
Report project initialized, .ci/ files created, initial branch created.
+11
View File
@@ -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(<slug>)`
- All commit messages must include `project: <slug>` 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?"
+11
View File
@@ -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(<slug>)`
- All commit messages must include `project: <slug>` in `---ci---` block
If single-project mode: proceed with existing conventions.
## Step 1: Load Changes
```bash
+12
View File
@@ -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(<slug>)`
- Identify project-scoped branches (prefixed with `<slug>/`)
- All commit messages must include `project: <slug>` in `---ci---` block
If single-project mode: proceed with existing conventions (branches without slug prefix).
## Step 1: Load Git Context
```bash
+28
View File
@@ -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(<slug>)`
- All commit messages must include `project: <slug>` 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:
+26 -8
View File
@@ -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(<slug>)`
- All commit messages must include `project: <slug>` in `---ci---` block
- Branch names are prefixed with `<slug>/` 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
+15 -2
View File
@@ -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(<slug>)`
- All commit messages must include `project: <slug>` 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]
+14
View File
@@ -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(<slug>)`
- Scope verification to the active project
- All commit messages must include `project: <slug>` 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
+13 -3
View File
@@ -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"
+154
View File
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+127
View File
@@ -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);
}
+288
View File
@@ -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",
+244 -18
View File
@@ -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<string, unknown> | null {
const content = readFile(path.join(this.ciDir, "config.json"));
if (!content) return null;
try {
return JSON.parse(content) as Record<string, unknown>;
} catch {
return null;
}
}
private writeConfigJson(config: Record<string, unknown>): 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 {
+51
View File
@@ -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", () => {
+6
View File
@@ -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,
};
+105
View File
@@ -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");
});
});
+3
View File
@@ -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);
+51
View File
@@ -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", () => {
+14 -1
View File
@@ -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>): CIConfig {
export function initCI(projectPath: string, config?: Partial<CIConfig>, 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,
+24
View File
@@ -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", () => {
+17 -4
View File
@@ -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) {
+91
View File
@@ -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);
});
});
});
+43 -3
View File
@@ -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;
}
}
+21 -6
View File
@@ -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;
}
+51 -1
View File
@@ -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("");
});
});
});
+10
View File
@@ -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"],
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "0.2.0";
export const VERSION = "0.3.0";