feat(P03): multi-project support, NFR milestone versioning, phase context reset, install scripts (v0.3.0)
This commit is contained in:
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before challenging, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=30` for recent decisions and project history
|
1. Run `git log --max-count=30` for recent decisions and project history
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before reviewing, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=10` for recent changes
|
1. Run `git log --max-count=10` for recent changes
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before debugging, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=20` for recent changes that may have caused the bug
|
1. Run `git log --max-count=20` for recent changes that may have caused the bug
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before verifying, load context from git first:
|
||||||
|
|
||||||
1. Run `git diff HEAD~10` to see recent code changes
|
1. Run `git diff HEAD~10` to see recent code changes
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before writing, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=20` for recent changes that affect docs
|
1. Run `git log --max-count=20` for recent changes that affect docs
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before executing, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=20` for recent project history
|
1. Run `git log --max-count=20` for recent project history
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before ideating, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=50` for full project history
|
1. Run `git log --max-count=50` for full project history
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before any operation, load project context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=20` and `git branch -a` to discover project structure
|
1. Run `git log --max-count=20` and `git branch -a` to discover project structure
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before researching, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=50` for full project history
|
1. Run `git log --max-count=50` for full project history
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before checking, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=20` for recent decisions affecting this phase
|
1. Run `git log --max-count=20` for recent decisions affecting this phase
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before planning, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=50` to see recent decisions and project history
|
1. Run `git log --max-count=50` to see recent decisions and project history
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before researching, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=20` for any prior project history
|
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>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before synthesizing, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --grep="research" --max-count=20` for prior research commits
|
1. Run `git log --grep="research" --max-count=20` for prior research commits
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before researching, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=50` for project history and prior research
|
1. Run `git log --max-count=50` for project history and prior research
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before roadmapping, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=30` for project history
|
1. Run `git log --max-count=30` for project history
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before auditing, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --grep="security" --max-count=20` for prior security decisions
|
1. Run `git log --grep="security" --max-count=20` for prior security decisions
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before analyzing, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --max-count=20` for recent problem-solving history
|
1. Run `git log --max-count=20` for recent problem-solving history
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ If the prompt contains a `<files_to_read>` block, you MUST use the Read tool to
|
|||||||
</role>
|
</role>
|
||||||
|
|
||||||
<project_context>
|
<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:
|
Before verifying, load context from git first:
|
||||||
|
|
||||||
1. Run `git log --grep="P##" --max-count=50` for all phase commits
|
1. Run `git log --grep="P##" --max-count=50` for all phase commits
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.2.0
|
0.3.0
|
||||||
@@ -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
|
## Output Style
|
||||||
|
|
||||||
- Concise, action-oriented responses
|
- Concise, action-oriented responses
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ Agent output guidance for CI research mode. Loaded when the orchestrator operate
|
|||||||
|
|
||||||
## Research Output
|
## 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
|
- Key findings in the commit body
|
||||||
- Decisions in the `---ci---` block
|
- Decisions in the `---ci---` block
|
||||||
- Confidence levels for each recommendation
|
- Confidence levels for each recommendation
|
||||||
|
|
||||||
|
In multi-project mode, research conclusions update files in `.ci/<slug>/` subdirectories, not the root `.ci/` directory.
|
||||||
|
|
||||||
## Verbosity
|
## Verbosity
|
||||||
|
|
||||||
High. Explain reasoning, show evidence, and document assumptions.
|
High. Explain reasoning, show evidence, and document assumptions.
|
||||||
|
|||||||
@@ -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
|
## Output Style
|
||||||
|
|
||||||
- Critical, detail-focused responses that prioritize correctness
|
- Critical, detail-focused responses that prioritize correctness
|
||||||
|
|||||||
@@ -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.
|
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
|
## Phase Discovery
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -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/`
|
## What Lives in `.ci/`
|
||||||
|
|
||||||
| File | Purpose | Update Frequency |
|
| File | Purpose | Update Frequency |
|
||||||
|------|---------|-------------------|
|
|------|---------|-------------------|
|
||||||
| `config.json` | Project-level CI configuration | Rare (initialization, setting changes) |
|
| `config.json` | Project registry with `projects[]` and `active_project` | Rare (initialization, project changes) |
|
||||||
| `PROJECT.md` | Vision, core value, requirements, constraints, key decisions | Low (phase boundaries) |
|
| `<slug>/PROJECT.md` | Vision, core value, requirements, constraints, key decisions per project | Low (phase boundaries) |
|
||||||
| `ARCHITECTURE.md` | System architecture, component boundaries, data flow | Low (major refactors) |
|
| `<slug>/ARCHITECTURE.md` | System architecture, component boundaries, data flow per project | Low (major refactors) |
|
||||||
| `ROADMAP.md` | Phase breakdown, milestone mapping, success criteria | Low (phase transitions) |
|
| `<slug>/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 | Low (requirement changes) |
|
| `<slug>/REQUIREMENTS.md` | v1/v2 requirements with REQ-IDs, out of scope, traceability per project | Low (requirement changes) |
|
||||||
|
|
||||||
## What Does NOT Live in `.ci/`
|
## 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
|
## Anti-Patterns
|
||||||
|
|
||||||
- Never write dynamic state (decisions, escalations, lessons) to `.ci/` files
|
- 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 commit `.ci/` changes without a `---ci---` block
|
||||||
- Never create new files in `.ci/` without updating this reference document
|
- Never create new files in `.ci/` without updating this reference document
|
||||||
- Never store counters, timestamps, or session state in `.ci/` files
|
- 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>
|
</ci_files_discipline>
|
||||||
@@ -10,6 +10,7 @@ Canonical `---ci---` YAML block schema for CI commits. Every CI-generated commit
|
|||||||
<type>(<scope>): <subject>
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
---ci---
|
---ci---
|
||||||
|
project: <slug> # required in multi-project mode
|
||||||
phase: <number>
|
phase: <number>
|
||||||
milestone: <string>
|
milestone: <string>
|
||||||
plan: <string> # optional
|
plan: <string> # optional
|
||||||
@@ -38,6 +39,23 @@ compound: # optional
|
|||||||
---/ci---
|
---/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
|
## Commit Types
|
||||||
|
|
||||||
| Type | Purpose | Scope |
|
| Type | Purpose | Scope |
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ const phaseDecisions = gitContext.getDecisions(3); // Phase 3 only
|
|||||||
const commitDecisions = gitContext.getDecisionsFromCommits(commits, 3);
|
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
|
## Anti-Patterns
|
||||||
|
|
||||||
- Never write decisions to a `.ci/audit/` file — commit them
|
- 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).
|
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
|
## Context Budget Strategy
|
||||||
|
|
||||||
When context is limited:
|
When context is limited:
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ Audit the CI project for health issues. Verifies that git log state matches .ci/
|
|||||||
|
|
||||||
**Usage:** `ci-audit`
|
**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
|
## Step 1: Reconstruction Test
|
||||||
|
|
||||||
Attempt to reconstruct the full project state from commit messages only:
|
Attempt to reconstruct the full project state from commit messages only:
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ Run the clarification phase for the current CI project. Generate questions about
|
|||||||
|
|
||||||
**Usage:** `ci-clarify [phase_number]`
|
**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
|
## Step 1: Load Git Context
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ Systematic debugging workflow: triage → root cause diagnosis → auto-fix or e
|
|||||||
|
|
||||||
**Usage:** `ci-debug [description]`
|
**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
|
## Step 1: Load Git Context
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ Initialize a new CI project with specification parsing, clarification, and .ci/
|
|||||||
|
|
||||||
**Usage:** `ci-init [description]`
|
**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
|
## Step 1: Check Prerequisites
|
||||||
|
|
||||||
Verify git is initialized:
|
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:
|
Use CiFiles to create the project structure:
|
||||||
|
|
||||||
1. `.ci/config.json` — default CI configuration with autonomy level
|
1. `.ci/config.json` — registry with `projects[]` and `active_project`
|
||||||
2. `.ci/PROJECT.md` — vision, requirements, constraints, key decisions
|
2. `.ci/<slug>/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)
|
3. `.ci/<slug>/ARCHITECTURE.md` — system architecture (initial, may be incomplete)
|
||||||
4. `.ci/ROADMAP.md` — phase breakdown (to be refined by roadmapper)
|
4. `.ci/<slug>/ROADMAP.md` — phase breakdown (to be refined by roadmapper)
|
||||||
5. `.ci/REQUIREMENTS.md` — formal requirements with REQ-IDs
|
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
|
## Step 5: Create Initial Branches
|
||||||
|
|
||||||
@@ -69,6 +83,7 @@ git checkout -b milestone/v1.0-initial
|
|||||||
docs(init): initialize [project-name] ([N] phases)
|
docs(init): initialize [project-name] ([N] phases)
|
||||||
|
|
||||||
---ci---
|
---ci---
|
||||||
|
project: <slug>
|
||||||
phase: 0
|
phase: 0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
status: specify
|
status: specify
|
||||||
@@ -86,6 +101,8 @@ Constraints: [constraint1, ...]
|
|||||||
Out of scope: [item1, ...]
|
Out of scope: [item1, ...]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Include `project: <slug>` in the `---ci---` block when in multi-project mode.
|
||||||
|
|
||||||
## Step 7: Done
|
## Step 7: Done
|
||||||
|
|
||||||
Report project initialized, .ci/ files created, initial branch created.
|
Report project initialized, .ci/ files created, initial branch created.
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ Execute small, ad-hoc tasks with CI guarantees: git context loading, `---ci---`
|
|||||||
- `--verify` — verify results after execution
|
- `--verify` — verify results after execution
|
||||||
- `--full` — research + verify
|
- `--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
|
## Step 1: Get Task Description
|
||||||
|
|
||||||
If provided as argument, use it. Otherwise ask: "What do you want to do?"
|
If provided as argument, use it. Otherwise ask: "What do you want to do?"
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ Multi-persona code review workflow. Reviews changes in the current phase, auto-a
|
|||||||
|
|
||||||
**Usage:** `ci-review [phase_number]`
|
**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
|
## Step 1: Load Changes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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.
|
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
|
## Step 1: Load Git Context
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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).
|
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
|
## Step 1: Load Git Context
|
||||||
|
|
||||||
```bash
|
```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.
|
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
|
## Step 4: Error Recovery
|
||||||
|
|
||||||
On stage failure:
|
On stage failure:
|
||||||
|
|||||||
@@ -8,11 +8,27 @@ Ship a CI phase or milestone. Every ship creates a release — no exceptions.
|
|||||||
|
|
||||||
**Versioning rule:**
|
**Versioning rule:**
|
||||||
- **Major** (X.0.0): Project-level refactor or schema changes
|
- **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
|
- **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]`
|
**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
|
## Step 1: Pre-Flight
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -41,15 +57,17 @@ If any fail: iterate autonomously until tests pass. Do NOT ask the user for guid
|
|||||||
|
|
||||||
## Step 3: Compute Version
|
## 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 |
|
| What's shipping | Milestone Type | Version bump | Tag format | Example |
|
||||||
|----------------|-------------|------------|---------|
|
|----------------|---------------|-------------|------------|---------|
|
||||||
| Single phase | Patch | `vX.Y.Z` | `v0.2.3` (3rd phase in milestone v0.2) |
|
| Single phase | NFR | Patch | `vX.Y.Z` | `v0.1.3` (3rd NFR phase in milestone v0.1) |
|
||||||
| Milestone completion | Minor | `vX.Y.0` | `v0.3.0` (milestone v0.3 complete) |
|
| Single phase | Feature | Patch | `vX.Y.Z` | `v0.2.3` (3rd phase in feature milestone v0.2) |
|
||||||
| Project refactor/schema change | Major | `vX.0.0` | `v1.0.0` (breaking schema) |
|
| 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
|
## Step 4: Merge Branch
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ Display the current CI project status derived entirely from the git log and .ci/
|
|||||||
|
|
||||||
**Usage:** `ci-status`
|
**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
|
## Step 1: Load Git Context
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -44,8 +56,9 @@ Read:
|
|||||||
CI ► STATUS
|
CI ► STATUS
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
Project: [name]
|
Project: [name] [If multi-project: (active)]
|
||||||
Milestone: [current]
|
[If multi-project: Other projects: [name1], [name2]]
|
||||||
|
Milestone: [current] [NFR|Feature]
|
||||||
Phase: [N] — [name]
|
Phase: [N] — [name]
|
||||||
Stage: [current_stage]
|
Stage: [current_stage]
|
||||||
Autonomy: [level]
|
Autonomy: [level]
|
||||||
|
|||||||
@@ -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.
|
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
|
## Step 1: Load Git Context
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+13
-3
@@ -1,20 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "@continuous-intelligence/ci",
|
"name": "@continuous-intelligence/ci",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ci": "./dist/cli/index.js"
|
"ci": "./dist/cli/index.js"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"opencode/",
|
||||||
|
"scripts/",
|
||||||
|
"templates/",
|
||||||
|
"LICENSE",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "ts-node src/cli.ts",
|
"dev": "ts-node src/cli.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"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",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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", () => {
|
describe("PROJECT.md", () => {
|
||||||
const project: ProjectMd = {
|
const project: ProjectMd = {
|
||||||
name: "Task API",
|
name: "Task API",
|
||||||
|
|||||||
+244
-18
@@ -64,33 +64,204 @@ export interface ArchitectureMd {
|
|||||||
buildOrder: string[];
|
buildOrder: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectEntry {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class CiFiles {
|
export class CiFiles {
|
||||||
private projectPath: string;
|
private projectPath: string;
|
||||||
|
private projectSlug: string;
|
||||||
|
|
||||||
constructor(projectPath: string) {
|
constructor(projectPath: string, projectSlug?: string) {
|
||||||
this.projectPath = projectPath;
|
this.projectPath = projectPath;
|
||||||
|
this.projectSlug = projectSlug || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private get ciDir(): string {
|
private get ciDir(): string {
|
||||||
return path.join(this.projectPath, CI_DIR);
|
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 {
|
ensureCIDir(): void {
|
||||||
ensureDir(this.ciDir);
|
ensureDir(this.ciDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureProjectDir(): void {
|
||||||
|
this.ensureCIDir();
|
||||||
|
if (this.projectSlug) {
|
||||||
|
ensureDir(this.projectDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isInitialized(): boolean {
|
isInitialized(): boolean {
|
||||||
return fileExists(path.join(this.ciDir, "config.json"));
|
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 {
|
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;
|
if (!content) return null;
|
||||||
return this.parseProjectMd(content);
|
return this.parseProjectMd(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeProjectMd(project: ProjectMd, reason: string): void {
|
writeProjectMd(project: ProjectMd, reason: string): void {
|
||||||
this.ensureCIDir();
|
this.ensureProjectDir();
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`# ${project.name}`,
|
`# ${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 {
|
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;
|
if (!content) return null;
|
||||||
return this.parseRoadmapMd(content);
|
return this.parseRoadmapMd(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeRoadmapMd(roadmap: RoadmapMd): void {
|
writeRoadmapMd(roadmap: RoadmapMd): void {
|
||||||
this.ensureCIDir();
|
this.ensureProjectDir();
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
"# Roadmap",
|
"# Roadmap",
|
||||||
"",
|
"",
|
||||||
@@ -160,7 +331,7 @@ export class CiFiles {
|
|||||||
|
|
||||||
for (const phase of roadmap.phases) {
|
for (const phase of roadmap.phases) {
|
||||||
lines.push(`### Phase ${phase.number}: ${phase.name}`);
|
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(`**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(`**Requirements**: ${phase.requirements.length > 0 ? phase.requirements.join(", ") : "None"}`);
|
||||||
lines.push("**Success Criteria**:");
|
lines.push("**Success Criteria**:");
|
||||||
@@ -171,17 +342,17 @@ export class CiFiles {
|
|||||||
lines.push("");
|
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 {
|
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;
|
if (!content) return null;
|
||||||
return this.parseRequirementsMd(content);
|
return this.parseRequirementsMd(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeRequirementsMd(requirements: RequirementsMd): void {
|
writeRequirementsMd(requirements: RequirementsMd): void {
|
||||||
this.ensureCIDir();
|
this.ensureProjectDir();
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
"# Requirements",
|
"# Requirements",
|
||||||
"",
|
"",
|
||||||
@@ -226,17 +397,17 @@ export class CiFiles {
|
|||||||
lines.push(`| ${t.requirement} | Phase ${t.phase} | ${t.status} |`);
|
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 {
|
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;
|
if (!content) return null;
|
||||||
return this.parseArchitectureMd(content);
|
return this.parseArchitectureMd(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeArchitectureMd(architecture: ArchitectureMd): void {
|
writeArchitectureMd(architecture: ArchitectureMd): void {
|
||||||
this.ensureCIDir();
|
this.ensureProjectDir();
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
"# Architecture",
|
"# Architecture",
|
||||||
"",
|
"",
|
||||||
@@ -267,7 +438,7 @@ export class CiFiles {
|
|||||||
lines.push(`1. ${step}`);
|
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 {
|
updateRequirementStatus(reqId: string, status: "pending" | "in_progress" | "complete" | "blocked"): void {
|
||||||
@@ -296,6 +467,21 @@ export class CiFiles {
|
|||||||
this.writeRoadmapMd(roadmap);
|
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 {
|
private parseProjectMd(content: string): ProjectMd {
|
||||||
return {
|
return {
|
||||||
name: this.extractSection(content, "# ") || "Unknown",
|
name: this.extractSection(content, "# ") || "Unknown",
|
||||||
@@ -312,10 +498,50 @@ export class CiFiles {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseRoadmapMd(content: string): RoadmapMd {
|
private parseRoadmapMd(content: string): RoadmapMd {
|
||||||
return {
|
const overview = this.extractSection(content, "## Overview") || "";
|
||||||
overview: this.extractSection(content, "## Overview") || "",
|
|
||||||
phases: [],
|
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 {
|
private parseRequirementsMd(content: string): RequirementsMd {
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ describe("CommitBuilder", () => {
|
|||||||
expect(block).toContain("status: execute");
|
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", () => {
|
it("builds ci block with decisions", () => {
|
||||||
const ci: CiMetadata = {
|
const ci: CiMetadata = {
|
||||||
phase: 1,
|
phase: 1,
|
||||||
@@ -172,6 +184,16 @@ describe("CommitBuilder", () => {
|
|||||||
expect(parsed.compound!.problem).toBe("Token replay attacks");
|
expect(parsed.compound!.problem).toBe("Token replay attacks");
|
||||||
expect(parsed.lessons).toHaveLength(1);
|
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", () => {
|
describe("buildInitCommit", () => {
|
||||||
@@ -193,6 +215,19 @@ describe("CommitBuilder", () => {
|
|||||||
expect(msg).toContain("Build a REST API for task management");
|
expect(msg).toContain("Build a REST API for task management");
|
||||||
expect(msg).toContain("AUTH-01");
|
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", () => {
|
describe("buildTaskCommit", () => {
|
||||||
@@ -223,6 +258,22 @@ describe("CommitBuilder", () => {
|
|||||||
expect(msg).toContain("D-003");
|
expect(msg).toContain("D-003");
|
||||||
expect(msg).toContain("AUTH-01");
|
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", () => {
|
describe("buildPhaseCompletionCommit", () => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface InitCommitInput {
|
|||||||
projectName: string;
|
projectName: string;
|
||||||
phaseCount: number;
|
phaseCount: number;
|
||||||
milestone: string;
|
milestone: string;
|
||||||
|
project?: string;
|
||||||
specification: string;
|
specification: string;
|
||||||
requirements?: string[];
|
requirements?: string[];
|
||||||
constraints?: string[];
|
constraints?: string[];
|
||||||
@@ -36,6 +37,7 @@ export interface TaskCommitInput {
|
|||||||
type: CommitType;
|
type: CommitType;
|
||||||
phase: number;
|
phase: number;
|
||||||
milestone: string;
|
milestone: string;
|
||||||
|
project?: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
task: string;
|
task: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -95,6 +97,7 @@ export class CommitBuilder {
|
|||||||
lines.push(`phase: ${ci.phase}`);
|
lines.push(`phase: ${ci.phase}`);
|
||||||
lines.push(`milestone: ${ci.milestone}`);
|
lines.push(`milestone: ${ci.milestone}`);
|
||||||
|
|
||||||
|
if (ci.project) lines.push(`project: ${ci.project}`);
|
||||||
if (ci.plan) lines.push(`plan: ${ci.plan}`);
|
if (ci.plan) lines.push(`plan: ${ci.plan}`);
|
||||||
if (ci.task) lines.push(`task: ${ci.task}`);
|
if (ci.task) lines.push(`task: ${ci.task}`);
|
||||||
|
|
||||||
@@ -162,6 +165,7 @@ export class CommitBuilder {
|
|||||||
const ci: CiMetadata = {
|
const ci: CiMetadata = {
|
||||||
phase: 0,
|
phase: 0,
|
||||||
milestone: input.milestone,
|
milestone: input.milestone,
|
||||||
|
project: input.project,
|
||||||
status: "specify",
|
status: "specify",
|
||||||
decisions: input.decisions,
|
decisions: input.decisions,
|
||||||
};
|
};
|
||||||
@@ -193,6 +197,7 @@ export class CommitBuilder {
|
|||||||
const ci: CiMetadata = {
|
const ci: CiMetadata = {
|
||||||
phase: input.phase,
|
phase: input.phase,
|
||||||
milestone: input.milestone,
|
milestone: input.milestone,
|
||||||
|
project: input.project,
|
||||||
plan: input.plan,
|
plan: input.plan,
|
||||||
task: input.task,
|
task: input.task,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
@@ -204,6 +209,7 @@ export class CommitBuilder {
|
|||||||
phase: input.phase,
|
phase: input.phase,
|
||||||
plan: input.plan,
|
plan: input.plan,
|
||||||
task: input.task,
|
task: input.task,
|
||||||
|
project: input.project,
|
||||||
isInit: false,
|
isInit: false,
|
||||||
isMilestone: false,
|
isMilestone: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
CommitEscalation,
|
CommitEscalation,
|
||||||
CommitRequirements,
|
CommitRequirements,
|
||||||
CommitCompoundMeta,
|
CommitCompoundMeta,
|
||||||
|
parseCommitScope,
|
||||||
|
formatCommitScope,
|
||||||
|
CommitScope,
|
||||||
} from "../types/commit-meta.js";
|
} from "../types/commit-meta.js";
|
||||||
import {
|
import {
|
||||||
extractCiBlock,
|
extractCiBlock,
|
||||||
@@ -112,6 +115,19 @@ escalations:
|
|||||||
|
|
||||||
All tests pass. Awaiting deploy approval.`;
|
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", () => {
|
describe("extractCiBlock", () => {
|
||||||
it("extracts ---ci--- block from commit message", () => {
|
it("extracts ---ci--- block from commit message", () => {
|
||||||
const block = extractCiBlock(SAMPLE_INIT_COMMIT);
|
const block = extractCiBlock(SAMPLE_INIT_COMMIT);
|
||||||
@@ -192,6 +208,14 @@ describe("parseCiBlock", () => {
|
|||||||
expect(meta.escalations![0].resolution).toBe("pending");
|
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", () => {
|
it("returns null for empty block", () => {
|
||||||
const meta = parseCiBlock("");
|
const meta = parseCiBlock("");
|
||||||
expect(meta).toBeNull();
|
expect(meta).toBeNull();
|
||||||
@@ -249,4 +273,85 @@ describe("parseCommitMessage", () => {
|
|||||||
const parsed = parseCommitMessage("pqr678", SAMPLE_TASK_COMMIT);
|
const parsed = parseCommitMessage("pqr678", SAMPLE_TASK_COMMIT);
|
||||||
expect(parsed.body).toContain("POST /auth/register validates email and password");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -40,6 +40,9 @@ export function parseCiBlock(yaml: string): CiMetadata | null {
|
|||||||
const statusMatch = yaml.match(/^status:\s*(.+)$/m);
|
const statusMatch = yaml.match(/^status:\s*(.+)$/m);
|
||||||
if (statusMatch) result.status = statusMatch[1].trim() as CiMetadata["status"];
|
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.decisions = parseDecisionsFromYaml(yaml);
|
||||||
result.escalations = parseEscalationsFromYaml(yaml);
|
result.escalations = parseEscalationsFromYaml(yaml);
|
||||||
result.requirements = parseRequirementsFromYaml(yaml);
|
result.requirements = parseRequirementsFromYaml(yaml);
|
||||||
|
|||||||
@@ -45,6 +45,37 @@ describe("CI Config", () => {
|
|||||||
expect(config.autonomy.max_revision_iterations).toBe(3);
|
expect(config.autonomy.max_revision_iterations).toBe(3);
|
||||||
expect(config.autonomy.escalation_hooks).toEqual(["deploy", "delete_data", "merge_to_main"]);
|
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", () => {
|
describe("loadConfig", () => {
|
||||||
@@ -68,6 +99,13 @@ describe("CI Config", () => {
|
|||||||
expect(config.git.auto_commit).toBe(true);
|
expect(config.git.auto_commit).toBe(true);
|
||||||
expect(config.git.branching_strategy).toBe("phase");
|
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", () => {
|
describe("saveConfig", () => {
|
||||||
@@ -81,6 +119,19 @@ describe("CI Config", () => {
|
|||||||
const loaded = loadConfig(tempDir);
|
const loaded = loadConfig(tempDir);
|
||||||
expect(loaded.autonomy.level).toBe("guided");
|
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", () => {
|
describe("isCIInitialized", () => {
|
||||||
|
|||||||
+14
-1
@@ -62,11 +62,24 @@ export function isCIInitialized(projectPath: string): boolean {
|
|||||||
return fs.existsSync(ciDir) && fs.existsSync(configPath);
|
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);
|
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 = {
|
const fullConfig: CIConfig = {
|
||||||
...DEFAULT_CI_CONFIG,
|
...DEFAULT_CI_CONFIG,
|
||||||
...config,
|
...config,
|
||||||
|
projects,
|
||||||
|
active_project: activeProject,
|
||||||
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, ...config?.autonomy },
|
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, ...config?.autonomy },
|
||||||
parallelization: {
|
parallelization: {
|
||||||
...DEFAULT_CI_CONFIG.parallelization,
|
...DEFAULT_CI_CONFIG.parallelization,
|
||||||
|
|||||||
@@ -53,6 +53,22 @@ describe("GitBranch", () => {
|
|||||||
|
|
||||||
expect(result.name).toBe("phase/03-real-time-notifications");
|
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", () => {
|
describe("createMilestoneBranch", () => {
|
||||||
@@ -71,6 +87,14 @@ describe("GitBranch", () => {
|
|||||||
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
|
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
|
||||||
expect(result.alreadyExisted).toBe(true);
|
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", () => {
|
describe("listPhases", () => {
|
||||||
|
|||||||
+17
-4
@@ -30,10 +30,21 @@ export interface MilestoneBranchInfo {
|
|||||||
export class GitBranch {
|
export class GitBranch {
|
||||||
private projectPath: string;
|
private projectPath: string;
|
||||||
private gitContext: GitContext;
|
private gitContext: GitContext;
|
||||||
|
private projectSlug?: string;
|
||||||
|
|
||||||
constructor(projectPath: string) {
|
constructor(projectPath: string, projectSlug?: string) {
|
||||||
this.projectPath = projectPath;
|
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 {
|
private git(args: string): string {
|
||||||
@@ -58,7 +69,8 @@ export class GitBranch {
|
|||||||
createPhaseBranch(phaseNumber: number, phaseName: string): BranchCreateResult {
|
createPhaseBranch(phaseNumber: number, phaseName: string): BranchCreateResult {
|
||||||
const padded = String(phaseNumber).padStart(2, "0");
|
const padded = String(phaseNumber).padStart(2, "0");
|
||||||
const slug = this.slugify(phaseName);
|
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);
|
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -75,7 +87,8 @@ export class GitBranch {
|
|||||||
|
|
||||||
createMilestoneBranch(version: string, milestoneName: string): BranchCreateResult {
|
createMilestoneBranch(version: string, milestoneName: string): BranchCreateResult {
|
||||||
const slug = this.slugify(milestoneName);
|
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);
|
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -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", () => {
|
describe("getRecentCommits", () => {
|
||||||
it("returns parsed commits with ci blocks", () => {
|
it("returns parsed commits with ci blocks", () => {
|
||||||
commit(repoDir, `docs(init): initialize project
|
commit(repoDir, `docs(init): initialize project
|
||||||
@@ -187,5 +205,78 @@ lessons:
|
|||||||
expect(milestoneBranches.length).toBeGreaterThanOrEqual(1);
|
expect(milestoneBranches.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(phaseBranches[0].phaseNumber).toBe(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
@@ -27,9 +27,19 @@ export interface BranchInfo {
|
|||||||
|
|
||||||
export class GitContext {
|
export class GitContext {
|
||||||
private projectPath: string;
|
private projectPath: string;
|
||||||
|
private projectSlug?: string;
|
||||||
|
|
||||||
constructor(projectPath: string) {
|
constructor(projectPath: string, projectSlug?: string) {
|
||||||
this.projectPath = projectPath;
|
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 {
|
private git(args: string): string {
|
||||||
@@ -98,14 +108,21 @@ export class GitContext {
|
|||||||
merged: mergedBranches.has(cleanName),
|
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) {
|
if (phaseMatch) {
|
||||||
info.type = "phase";
|
info.type = "phase";
|
||||||
info.phaseNumber = parseInt(phaseMatch[1], 10);
|
info.phaseNumber = parseInt(phaseMatch[1], 10);
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
const milestoneMatch = cleanName.match(/^milestone\/(.+)/);
|
const milestoneMatch = branchName.match(/^milestone\/(.+)/);
|
||||||
if (milestoneMatch) {
|
if (milestoneMatch) {
|
||||||
info.type = "milestone";
|
info.type = "milestone";
|
||||||
info.milestone = milestoneMatch[1];
|
info.milestone = milestoneMatch[1];
|
||||||
@@ -311,4 +328,27 @@ export class GitContext {
|
|||||||
|
|
||||||
return commits;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ export interface CommitScope {
|
|||||||
phase: number;
|
phase: number;
|
||||||
plan?: string;
|
plan?: string;
|
||||||
task?: string;
|
task?: string;
|
||||||
|
project?: string;
|
||||||
isInit: boolean;
|
isInit: boolean;
|
||||||
isMilestone: boolean;
|
isMilestone: boolean;
|
||||||
}
|
}
|
||||||
@@ -53,6 +54,7 @@ export interface CommitCompoundMeta {
|
|||||||
export interface CiMetadata {
|
export interface CiMetadata {
|
||||||
phase: number;
|
phase: number;
|
||||||
milestone: string;
|
milestone: string;
|
||||||
|
project?: string;
|
||||||
plan?: string;
|
plan?: string;
|
||||||
task?: string;
|
task?: string;
|
||||||
status: PipelineStage;
|
status: PipelineStage;
|
||||||
@@ -88,10 +90,19 @@ export function parseCommitScope(scope: string): CommitScope {
|
|||||||
return { phase: 0, isInit: false, isMilestone: true };
|
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 phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 0;
|
||||||
|
|
||||||
const parts = scope.split("-");
|
const parts = cleanScope.split("-");
|
||||||
let plan: string | undefined;
|
let plan: string | undefined;
|
||||||
let task: string | undefined;
|
let task: string | undefined;
|
||||||
|
|
||||||
@@ -102,7 +113,7 @@ export function parseCommitScope(scope: string): CommitScope {
|
|||||||
task = `${plan}-${parts[2]}`;
|
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 {
|
export function formatCommitScope(scope: CommitScope): string {
|
||||||
@@ -110,7 +121,11 @@ export function formatCommitScope(scope: CommitScope): string {
|
|||||||
if (scope.isMilestone) return "milestone";
|
if (scope.isMilestone) return "milestone";
|
||||||
|
|
||||||
const phaseStr = `P${String(scope.phase).padStart(2, "0")}`;
|
const phaseStr = `P${String(scope.phase).padStart(2, "0")}`;
|
||||||
if (scope.task) return `${phaseStr}-${scope.task.split("-").slice(1).join("-")}`;
|
let suffix: string;
|
||||||
if (scope.plan) return `${phaseStr}-${scope.plan.split("-").slice(1).join("-")}`;
|
if (scope.task) suffix = `${phaseStr}-${scope.task.split("-").slice(1).join("-")}`;
|
||||||
return phaseStr;
|
else if (scope.plan) suffix = `${phaseStr}-${scope.plan.split("-").slice(1).join("-")}`;
|
||||||
|
else suffix = phaseStr;
|
||||||
|
|
||||||
|
if (scope.project) return `${scope.project}/${suffix}`;
|
||||||
|
return suffix;
|
||||||
}
|
}
|
||||||
@@ -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", () => {
|
describe("CIConfig", () => {
|
||||||
it("DEFAULT_CI_CONFIG has all required fields", () => {
|
it("DEFAULT_CI_CONFIG has all required fields", () => {
|
||||||
@@ -17,6 +17,11 @@ describe("CIConfig", () => {
|
|||||||
expect(DEFAULT_CI_CONFIG.git.auto_push).toBe(false);
|
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", () => {
|
it("AutonomyLevel accepts all valid levels", () => {
|
||||||
const levels: AutonomyLevel[] = ["full", "supervised", "guided"];
|
const levels: AutonomyLevel[] = ["full", "supervised", "guided"];
|
||||||
for (const level of levels) {
|
for (const level of levels) {
|
||||||
@@ -46,4 +51,49 @@ describe("CIConfig", () => {
|
|||||||
"merge_to_main",
|
"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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -61,7 +61,15 @@ export interface GitConfig {
|
|||||||
auto_push: boolean;
|
auto_push: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectEntry {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CIConfig {
|
export interface CIConfig {
|
||||||
|
projects: ProjectEntry[];
|
||||||
|
active_project: string;
|
||||||
autonomy: AutonomyConfig;
|
autonomy: AutonomyConfig;
|
||||||
model_profile: ModelProfile;
|
model_profile: ModelProfile;
|
||||||
parallelization: ParallelizationConfig;
|
parallelization: ParallelizationConfig;
|
||||||
@@ -71,6 +79,8 @@ export interface CIConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CI_CONFIG: CIConfig = {
|
export const DEFAULT_CI_CONFIG: CIConfig = {
|
||||||
|
projects: [],
|
||||||
|
active_project: "",
|
||||||
autonomy: {
|
autonomy: {
|
||||||
level: "full",
|
level: "full",
|
||||||
escalation_hooks: ["deploy", "delete_data", "merge_to_main"],
|
escalation_hooks: ["deploy", "delete_data", "merge_to_main"],
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = "0.2.0";
|
export const VERSION = "0.3.0";
|
||||||
Reference in New Issue
Block a user