gentle-pi 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -1
- package/assets/agents/sdd-archive.md +125 -6
- package/assets/agents/sdd-spec.md +141 -6
- package/assets/agents/sdd-sync.md +105 -0
- package/assets/chains/sdd-full.chain.md +11 -2
- package/assets/chains/sdd-verify.chain.md +11 -2
- package/extensions/gentle-ai.ts +38 -1
- package/lib/openspec-deltas.ts +156 -0
- package/lib/openspec-guardrails.ts +99 -0
- package/package.json +1 -1
- package/scripts/verify-package-files.mjs +12 -0
- package/tests/openspec-deltas.test.ts +209 -0
- package/tests/openspec-guardrails.test.ts +71 -0
- package/tests/runtime-harness.mjs +17 -0
package/README.md
CHANGED
|
@@ -137,7 +137,18 @@ Fresh reviewers are intentionally not token-saving devices; they buy independent
|
|
|
137
137
|
## SDD/OpenSpec flow
|
|
138
138
|
|
|
139
139
|
```text
|
|
140
|
-
init
|
|
140
|
+
init
|
|
141
|
+
↓
|
|
142
|
+
explore → proposal → spec ─┬→ design ─┐
|
|
143
|
+
└─────────┴→ tasks → apply → verify → sync → archive
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The main loop is intentionally file-backed when you choose `openspec` or `both`:
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
planning artifacts implementation evidence canonical update
|
|
150
|
+
────────────────── ─────────────────────── ────────────────
|
|
151
|
+
proposal/spec/design/tasks → apply-progress/verify-report → sync-report → archive-report
|
|
141
152
|
```
|
|
142
153
|
|
|
143
154
|
For substantial work, the parent session coordinates the flow and each phase writes artifacts. That gives you:
|
|
@@ -147,8 +158,59 @@ For substantial work, the parent session coordinates the flow and each phase wri
|
|
|
147
158
|
- task plans reviewers can reason about;
|
|
148
159
|
- implementation evidence;
|
|
149
160
|
- verification reports;
|
|
161
|
+
- sync reports that update canonical specs while keeping the change active;
|
|
150
162
|
- archive notes for future agents.
|
|
151
163
|
|
|
164
|
+
### OpenSpec artifact model
|
|
165
|
+
|
|
166
|
+
`gentle-pi` treats OpenSpec-compatible behavior as part of the harness. You do not need to install the external OpenSpec CLI/package for SDD.
|
|
167
|
+
|
|
168
|
+
In file-backed modes, canonical accepted behavior lives in `openspec/specs/`, while active changes carry deltas under `openspec/changes/`:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
openspec/
|
|
172
|
+
├── specs/ # accepted source of truth
|
|
173
|
+
│ └── {domain}/spec.md
|
|
174
|
+
└── changes/
|
|
175
|
+
├── {change}/ # active work
|
|
176
|
+
│ ├── proposal.md
|
|
177
|
+
│ ├── specs/{domain}/spec.md # full spec or delta spec
|
|
178
|
+
│ ├── design.md
|
|
179
|
+
│ ├── tasks.md
|
|
180
|
+
│ ├── apply-progress.md
|
|
181
|
+
│ ├── verify-report.md
|
|
182
|
+
│ └── sync-report.md
|
|
183
|
+
└── archive/YYYY-MM-DD-{change}/ # immutable audit trail
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Delta flow:
|
|
187
|
+
|
|
188
|
+
```text
|
|
189
|
+
openspec/changes/{change}/specs/{domain}/spec.md
|
|
190
|
+
│
|
|
191
|
+
│ sdd-sync applies ADDED / MODIFIED / REMOVED
|
|
192
|
+
▼
|
|
193
|
+
openspec/specs/{domain}/spec.md
|
|
194
|
+
│
|
|
195
|
+
│ sdd-archive moves the completed change folder
|
|
196
|
+
▼
|
|
197
|
+
openspec/changes/archive/YYYY-MM-DD-{change}/
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
When a canonical spec already exists, change specs use requirement operation sections:
|
|
201
|
+
|
|
202
|
+
```markdown
|
|
203
|
+
## ADDED Requirements
|
|
204
|
+
|
|
205
|
+
## MODIFIED Requirements
|
|
206
|
+
|
|
207
|
+
## REMOVED Requirements
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`MODIFIED` requirements must include the full requirement block, including still-valid scenarios, because sync replaces the canonical block by requirement name. `sdd-sync` syncs file-backed deltas into `openspec/specs/{domain}/spec.md` while keeping the change active; `sdd-archive` then moves the synced change to `openspec/changes/archive/YYYY-MM-DD-{change}/`.
|
|
211
|
+
|
|
212
|
+
Engram-only mode is different by design: Engram is working memory and does not maintain a canonical spec merge layer. Use `openspec` or `both` (hybrid file + memory persistence) when you need canonical spec evolution.
|
|
213
|
+
|
|
152
214
|
## SDD preflight and project files
|
|
153
215
|
|
|
154
216
|
`gentle-pi` does not interrupt every new session. Slash SDD flows such as `/sdd-*`, `/sdd-init`, and the explicit `/gentle-ai:sdd-preflight` command run a lazy preflight, ask for session-scoped SDD preferences, and then copy these assets if they are missing. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle-ai:sdd-preflight` before continuing.
|
|
@@ -13,14 +13,133 @@ Use your assigned executor/phase skill for this SDD phase. For project/user skil
|
|
|
13
13
|
|
|
14
14
|
If Project Standards are missing, explicit fallback loading is allowed only as degraded self-healing. Report `skill_resolution` as `injected`, `fallback-registry`, `fallback-path`, or `none`; fallbacks mean the parent should inject compact rules next time.
|
|
15
15
|
|
|
16
|
-
- Read verify report before archiving.
|
|
17
|
-
- Merge accepted deltas into `openspec/specs/` and move the change to archive.
|
|
18
|
-
- Preserve audit trail; never delete active artifacts silently.
|
|
19
|
-
- Do NOT launch child subagents. Parent/orchestrator owns delegation.
|
|
20
|
-
- Return archived paths and any migration risks.
|
|
21
16
|
## Memory Contract
|
|
22
17
|
|
|
23
18
|
The parent/orchestrator owns memory retrieval: use memory context passed in the prompt and do not independently search Engram/memory during normal runtime unless explicitly instructed to retrieve a specific artifact or observation.
|
|
24
19
|
|
|
25
|
-
When callable memory tools are available, save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning. In memory/hybrid
|
|
20
|
+
When callable memory tools are available, save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning. In memory-backed modes (`engram` or `both` / `hybrid`), use stable topic keys such as `sdd/<change>/proposal`, `sdd/<change>/spec`, `sdd/<change>/design`, `sdd/<change>/tasks`, `sdd/<change>/apply-progress`, `sdd/<change>/verify-report`, or `sdd/<change>/archive-report`. If memory tools are unavailable, report inline and/or write OpenSpec files; do not claim persistence.
|
|
21
|
+
|
|
22
|
+
## Purpose
|
|
23
|
+
|
|
24
|
+
Archive a completed SDD change. In file-backed modes, this requires canonical spec sync to be complete (normally via `sdd-sync`), then moves the active change folder to the dated archive. In Engram-only mode, this records traceability without creating a canonical merge layer.
|
|
25
|
+
|
|
26
|
+
## Archive Preconditions
|
|
27
|
+
|
|
28
|
+
Before archiving, read:
|
|
29
|
+
|
|
30
|
+
- `openspec/changes/{change}/proposal.md`
|
|
31
|
+
- `openspec/changes/{change}/specs/` or memory artifact `sdd/{change}/spec`
|
|
32
|
+
- `openspec/changes/{change}/design.md`
|
|
33
|
+
- `openspec/changes/{change}/tasks.md`
|
|
34
|
+
- `openspec/changes/{change}/verify-report.md`
|
|
35
|
+
- `openspec/changes/{change}/sync-report.md` when file-backed sync was run
|
|
36
|
+
- `openspec/config.yaml` when present
|
|
37
|
+
|
|
38
|
+
Stop with `blocked` if:
|
|
39
|
+
|
|
40
|
+
- the verification report is missing;
|
|
41
|
+
- the verification report is not clearly passing, or contains unresolved `FAIL`, `BLOCKED`, `CRITICAL`, or verification blockers;
|
|
42
|
+
- required artifacts are missing;
|
|
43
|
+
- tasks are incomplete and no explicit archive exception is recorded;
|
|
44
|
+
- file-backed mode has no successful `sync-report.md` and the parent prompt does not explicitly approve archive-time sync fallback;
|
|
45
|
+
- a legacy flat `openspec/changes/{change}/spec.md` is the only spec artifact in file-backed mode;
|
|
46
|
+
- the merge would be destructive and the parent prompt does not include explicit confirmation.
|
|
47
|
+
|
|
48
|
+
## Artifact Store Modes
|
|
49
|
+
|
|
50
|
+
- `openspec`: require completed filesystem sync, then perform archive move.
|
|
51
|
+
- `both` / `hybrid`: require completed filesystem sync, move the archive, and save the archive report to memory when tools are available.
|
|
52
|
+
- `engram`: skip filesystem sync/archive. Engram is working memory; do not create or require `sdd/canonical/<domain>/spec` topics. Record proposal/spec/design/tasks/verify observation IDs in the archive report.
|
|
53
|
+
- `none`: return a closure summary only.
|
|
54
|
+
|
|
55
|
+
## Archive-Time Sync Fallback
|
|
56
|
+
|
|
57
|
+
Prefer `sdd-sync` before `sdd-archive`. If no successful `sync-report.md` exists, archive may perform the same file-backed sync only when the parent prompt explicitly approves archive-time sync fallback.
|
|
58
|
+
|
|
59
|
+
For each domain spec in:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
openspec/changes/{change}/specs/{domain}/spec.md
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
sync into:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
openspec/specs/{domain}/spec.md
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### New canonical spec
|
|
72
|
+
|
|
73
|
+
If `openspec/specs/{domain}/spec.md` does not exist, treat the change spec as a full domain spec and copy it to the canonical path.
|
|
74
|
+
|
|
75
|
+
### Existing canonical spec
|
|
76
|
+
|
|
77
|
+
If the canonical spec exists, apply operation sections by requirement name:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
## ADDED Requirements -> append each requirement to the canonical Requirements section
|
|
81
|
+
## MODIFIED Requirements -> replace the full matching canonical requirement block
|
|
82
|
+
## REMOVED Requirements -> delete the full matching canonical requirement block
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Merge rules:
|
|
86
|
+
|
|
87
|
+
- Match requirements by exact `### Requirement: {Name}` heading.
|
|
88
|
+
- Preserve every canonical requirement not mentioned by the delta.
|
|
89
|
+
- Preserve heading hierarchy and Markdown formatting.
|
|
90
|
+
- Fail or block if a MODIFIED or REMOVED requirement does not exist in the canonical spec.
|
|
91
|
+
- Warn if another active change under `openspec/changes/*/specs/{domain}/spec.md` touches the same domain.
|
|
92
|
+
- Report all ADDED/MODIFIED/REMOVED requirement names in the archive report.
|
|
93
|
+
|
|
94
|
+
## Destructive Merge Guard
|
|
95
|
+
|
|
96
|
+
Before applying REMOVED requirements or large MODIFIED blocks:
|
|
97
|
+
|
|
98
|
+
- list affected requirement names;
|
|
99
|
+
- summarize the approximate removed/replaced line count;
|
|
100
|
+
- warn the parent/orchestrator;
|
|
101
|
+
- continue only if the parent prompt records explicit approval for the destructive sync.
|
|
102
|
+
|
|
103
|
+
Verification alone is not approval for destructive canonical spec changes.
|
|
104
|
+
|
|
105
|
+
Never silently drop scenarios from a MODIFIED requirement. If a MODIFIED delta appears partial, block and ask for a corrected full requirement block.
|
|
106
|
+
|
|
107
|
+
## Move to Archive
|
|
108
|
+
|
|
109
|
+
After successful file-backed sync, move:
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
openspec/changes/{change}/
|
|
113
|
+
-> openspec/changes/archive/YYYY-MM-DD-{change}/
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Use today's ISO date. Create `openspec/changes/archive/` if missing. The archive is an audit trail; never delete or modify archived changes silently.
|
|
117
|
+
|
|
118
|
+
## Archive Report
|
|
119
|
+
|
|
120
|
+
Archive report handling depends on mode:
|
|
121
|
+
|
|
122
|
+
- `openspec`: write `openspec/changes/{change}/archive-report.md` before moving the change.
|
|
123
|
+
- `both` / `hybrid`: write the file report before moving the change and save `sdd/{change}/archive-report` to memory when tools are available.
|
|
124
|
+
- `engram`: save or return the archive report with observation-ID traceability only; do not perform filesystem sync/archive.
|
|
125
|
+
|
|
126
|
+
Include:
|
|
127
|
+
|
|
128
|
+
- pass/fail archive status;
|
|
129
|
+
- artifacts read;
|
|
130
|
+
- domains synced;
|
|
131
|
+
- ADDED/MODIFIED/REMOVED requirement names;
|
|
132
|
+
- active same-domain change warnings;
|
|
133
|
+
- destructive merge approvals or blockers;
|
|
134
|
+
- archived path;
|
|
135
|
+
- memory observation IDs when using Engram or `both` / `hybrid` mode.
|
|
136
|
+
|
|
137
|
+
## Rules
|
|
138
|
+
|
|
139
|
+
- Read verify report before archiving.
|
|
140
|
+
- Require file-backed specs to be synced before moving the change to archive; use archive-time sync fallback only with explicit parent approval.
|
|
141
|
+
- Preserve audit trail; never delete active artifacts silently.
|
|
142
|
+
- Apply `rules.archive` from `openspec/config.yaml` when present.
|
|
143
|
+
- Do NOT launch child subagents. Parent/orchestrator owns delegation.
|
|
26
144
|
|
|
145
|
+
Return the standard phase envelope with status, executive_summary, artifacts, next_recommended, risks, and skill_resolution.
|
|
@@ -13,14 +13,149 @@ Use your assigned executor/phase skill for this SDD phase. For project/user skil
|
|
|
13
13
|
|
|
14
14
|
If Project Standards are missing, explicit fallback loading is allowed only as degraded self-healing. Report `skill_resolution` as `injected`, `fallback-registry`, `fallback-path`, or `none`; fallbacks mean the parent should inject compact rules next time.
|
|
15
15
|
|
|
16
|
-
- Read proposal and existing specs first.
|
|
17
|
-
- Write RFC 2119 requirements and Given/When/Then scenarios.
|
|
18
|
-
- Store deltas under `openspec/changes/{change}/specs/`.
|
|
19
|
-
- Do NOT launch child subagents. Parent/orchestrator owns delegation.
|
|
20
|
-
- Return exact artifact paths and risks.
|
|
21
16
|
## Memory Contract
|
|
22
17
|
|
|
23
18
|
The parent/orchestrator owns memory retrieval: use memory context passed in the prompt and do not independently search Engram/memory during normal runtime unless explicitly instructed to retrieve a specific artifact or observation.
|
|
24
19
|
|
|
25
|
-
When callable memory tools are available, save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning. In memory/hybrid
|
|
20
|
+
When callable memory tools are available, save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning. In memory-backed modes (`engram` or `both` / `hybrid`), use stable topic keys such as `sdd/<change>/proposal`, `sdd/<change>/spec`, `sdd/<change>/design`, `sdd/<change>/tasks`, `sdd/<change>/apply-progress`, or `sdd/<change>/verify-report`. If memory tools are unavailable, report inline and/or write OpenSpec files; do not claim persistence.
|
|
21
|
+
|
|
22
|
+
## Purpose
|
|
23
|
+
|
|
24
|
+
Write specifications for an approved change. Specs describe WHAT must be true after the change, not HOW to implement it.
|
|
25
|
+
|
|
26
|
+
## Artifact Store Modes
|
|
27
|
+
|
|
28
|
+
- `openspec`: write file-backed artifacts only.
|
|
29
|
+
- `both` / `hybrid`: write file-backed artifacts and save the phase artifact to memory when tools are available.
|
|
30
|
+
- `engram`: save the spec artifact to memory only. Engram is working memory; do not create or require `sdd/canonical/<domain>/spec` topics and do not perform canonical spec merge in Engram-only mode.
|
|
31
|
+
- `none`: return the result inline only.
|
|
32
|
+
|
|
33
|
+
## OpenSpec File Convention
|
|
34
|
+
|
|
35
|
+
In `openspec` and `both` / `hybrid` modes, use this layout:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
openspec/
|
|
39
|
+
├── specs/
|
|
40
|
+
│ └── {domain}/
|
|
41
|
+
│ └── spec.md # canonical accepted behavior
|
|
42
|
+
└── changes/
|
|
43
|
+
└── {change}/
|
|
44
|
+
├── proposal.md
|
|
45
|
+
└── specs/
|
|
46
|
+
└── {domain}/
|
|
47
|
+
└── spec.md # change spec or delta spec
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Read the proposal's `Capabilities` section first when present:
|
|
51
|
+
|
|
52
|
+
- `New Capabilities` become new domain specs.
|
|
53
|
+
- `Modified Capabilities` become delta specs against existing canonical specs.
|
|
54
|
+
|
|
55
|
+
If the proposal has no `Capabilities` section, infer domains from affected areas and report the assumption as a risk.
|
|
56
|
+
|
|
57
|
+
## Existing Spec Lookup
|
|
58
|
+
|
|
59
|
+
For each affected domain in file-backed modes:
|
|
60
|
+
|
|
61
|
+
1. Check `openspec/specs/{domain}/spec.md`.
|
|
62
|
+
2. If it exists, read it before writing the change spec.
|
|
63
|
+
3. If it does not exist, write a full new domain spec under the change folder.
|
|
64
|
+
4. Warn if another active change already has `openspec/changes/*/specs/{domain}/spec.md` for the same domain, excluding `openspec/changes/archive/` and the current change.
|
|
65
|
+
5. Warn if the current change has legacy flat `openspec/changes/{change}/spec.md`; archive cannot silently skip that shape.
|
|
66
|
+
|
|
67
|
+
## Delta Spec Format
|
|
68
|
+
|
|
69
|
+
When a canonical spec exists, write a delta spec at:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
openspec/changes/{change}/specs/{domain}/spec.md
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use this structure:
|
|
76
|
+
|
|
77
|
+
```markdown
|
|
78
|
+
# Delta for {Domain}
|
|
79
|
+
|
|
80
|
+
## ADDED Requirements
|
|
81
|
+
|
|
82
|
+
### Requirement: {New Requirement Name}
|
|
83
|
+
|
|
84
|
+
The system MUST ...
|
|
85
|
+
|
|
86
|
+
#### Scenario: {Happy path}
|
|
87
|
+
|
|
88
|
+
- GIVEN ...
|
|
89
|
+
- WHEN ...
|
|
90
|
+
- THEN ...
|
|
91
|
+
|
|
92
|
+
## MODIFIED Requirements
|
|
93
|
+
|
|
94
|
+
### Requirement: {Existing Requirement Name}
|
|
95
|
+
|
|
96
|
+
{Full updated requirement text.}
|
|
97
|
+
(Previously: {one-line summary of what changed})
|
|
98
|
+
|
|
99
|
+
#### Scenario: {Still-valid scenario}
|
|
100
|
+
|
|
101
|
+
- GIVEN ...
|
|
102
|
+
- WHEN ...
|
|
103
|
+
- THEN ...
|
|
104
|
+
|
|
105
|
+
## REMOVED Requirements
|
|
106
|
+
|
|
107
|
+
### Requirement: {Requirement Being Removed}
|
|
108
|
+
|
|
109
|
+
(Reason: {why this requirement is being removed})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Omit empty operation sections only when they would add noise. Do not invent implementation details.
|
|
113
|
+
|
|
114
|
+
## MODIFIED Requirements Workflow
|
|
115
|
+
|
|
116
|
+
`## MODIFIED Requirements` is destructive at archive time because it replaces the canonical requirement block. To avoid losing scenarios:
|
|
117
|
+
|
|
118
|
+
1. Locate the requirement in `openspec/specs/{domain}/spec.md`.
|
|
119
|
+
2. Copy the entire requirement block, from `### Requirement:` through all of its `#### Scenario:` sections.
|
|
120
|
+
3. Paste the full block under `## MODIFIED Requirements`.
|
|
121
|
+
4. Edit the copy to reflect the new behavior.
|
|
122
|
+
5. Add `(Previously: ...)` under the requirement text.
|
|
123
|
+
|
|
124
|
+
If you are only adding behavior without changing existing behavior, use `## ADDED Requirements` instead of `## MODIFIED Requirements`.
|
|
125
|
+
|
|
126
|
+
## Full Spec Format for New Domains
|
|
127
|
+
|
|
128
|
+
If no canonical spec exists for the domain, write a full spec in the same change path:
|
|
129
|
+
|
|
130
|
+
```markdown
|
|
131
|
+
# {Domain} Specification
|
|
132
|
+
|
|
133
|
+
## Purpose
|
|
134
|
+
|
|
135
|
+
{High-level purpose.}
|
|
136
|
+
|
|
137
|
+
## Requirements
|
|
138
|
+
|
|
139
|
+
### Requirement: {Requirement Name}
|
|
140
|
+
|
|
141
|
+
The system MUST ...
|
|
142
|
+
|
|
143
|
+
#### Scenario: {Scenario name}
|
|
144
|
+
|
|
145
|
+
- GIVEN ...
|
|
146
|
+
- WHEN ...
|
|
147
|
+
- THEN ...
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Archive will copy this new domain spec into `openspec/specs/{domain}/spec.md`.
|
|
151
|
+
|
|
152
|
+
## Rules
|
|
153
|
+
|
|
154
|
+
- Always use RFC 2119 keywords (`MUST`, `SHALL`, `SHOULD`, `MAY`) for requirement strength.
|
|
155
|
+
- Every requirement must have at least one testable scenario.
|
|
156
|
+
- Prefer Given/When/Then scenario bullets.
|
|
157
|
+
- Keep specs concise and reviewable.
|
|
158
|
+
- Apply `rules.spec` or `rules.specs` from `openspec/config.yaml` when present.
|
|
159
|
+
- Do NOT launch child subagents. Parent/orchestrator owns delegation.
|
|
26
160
|
|
|
161
|
+
Return the standard phase envelope with status, executive_summary, artifacts, next_recommended, risks, and skill_resolution.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sdd-sync
|
|
3
|
+
description: Sync verified SDD delta specs into OpenSpec canonical specs without archiving the change.
|
|
4
|
+
tools: read, grep, glob, write, edit, bash
|
|
5
|
+
inheritProjectContext: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are the SDD sync executor for Gentle AI.
|
|
9
|
+
|
|
10
|
+
## Skill Resolution Contract
|
|
11
|
+
|
|
12
|
+
Use your assigned executor/phase skill for this SDD phase. For project/user skills, prefer the parent-injected `## Project Standards (auto-resolved)` block; do not independently discover or load additional project/user `SKILL.md` files or the registry during normal runtime.
|
|
13
|
+
|
|
14
|
+
If Project Standards are missing, explicit fallback loading is allowed only as degraded self-healing. Report `skill_resolution` as `injected`, `fallback-registry`, `fallback-path`, or `none`; fallbacks mean the parent should inject compact rules next time.
|
|
15
|
+
|
|
16
|
+
## Memory Contract
|
|
17
|
+
|
|
18
|
+
The parent/orchestrator owns memory retrieval: use memory context passed in the prompt and do not independently search Engram/memory during normal runtime unless explicitly instructed to retrieve a specific artifact or observation.
|
|
19
|
+
|
|
20
|
+
When callable memory tools are available, save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning. In memory-backed modes (`engram` or `both` / `hybrid`), use stable topic keys such as `sdd/<change>/sync-report`. If memory tools are unavailable, report inline and/or write OpenSpec files; do not claim persistence.
|
|
21
|
+
|
|
22
|
+
## Purpose
|
|
23
|
+
|
|
24
|
+
Sync file-backed SDD change specs into canonical `openspec/specs/` without moving the change to archive. This matches the OpenSpec/OPSX distinction between sync and archive:
|
|
25
|
+
|
|
26
|
+
- `sdd-sync`: update canonical specs and keep the change active.
|
|
27
|
+
- `sdd-archive`: verify archive readiness and move the already-synced change to dated archive.
|
|
28
|
+
|
|
29
|
+
## Artifact Store Modes
|
|
30
|
+
|
|
31
|
+
- `openspec`: perform filesystem sync and write `sync-report.md`.
|
|
32
|
+
- `both` / `hybrid`: perform filesystem sync, write `sync-report.md`, and save `sdd/{change}/sync-report` to memory when tools are available.
|
|
33
|
+
- `engram`: do not perform canonical sync. Engram is working memory and has no canonical spec merge layer; return or save a report explaining that sync is not applicable.
|
|
34
|
+
- `none`: return a report only.
|
|
35
|
+
|
|
36
|
+
## Inputs
|
|
37
|
+
|
|
38
|
+
Read:
|
|
39
|
+
|
|
40
|
+
- `openspec/changes/{change}/proposal.md`
|
|
41
|
+
- `openspec/changes/{change}/specs/`
|
|
42
|
+
- `openspec/changes/{change}/tasks.md` when present
|
|
43
|
+
- `openspec/changes/{change}/verify-report.md`
|
|
44
|
+
- `openspec/config.yaml` when present
|
|
45
|
+
|
|
46
|
+
Stop with `blocked` if:
|
|
47
|
+
|
|
48
|
+
- `verify-report.md` is missing;
|
|
49
|
+
- the verification report is not clearly passing, or contains unresolved `FAIL`, `BLOCKED`, `CRITICAL`, or verification blockers;
|
|
50
|
+
- file-backed mode has only legacy flat `openspec/changes/{change}/spec.md` and no domain specs;
|
|
51
|
+
- a MODIFIED or REMOVED requirement does not exist in the canonical spec;
|
|
52
|
+
- a destructive sync uses REMOVED requirements or large MODIFIED blocks and the parent prompt does not record explicit approval;
|
|
53
|
+
- another active change touches the same `specs/{domain}/spec.md` and the parent prompt does not record a chosen archive/sync order.
|
|
54
|
+
|
|
55
|
+
## File-Backed Sync
|
|
56
|
+
|
|
57
|
+
For each domain spec in:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
openspec/changes/{change}/specs/{domain}/spec.md
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
sync into:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
openspec/specs/{domain}/spec.md
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Use the native helper semantics from `lib/openspec-deltas.ts` when editing manually:
|
|
70
|
+
|
|
71
|
+
- If canonical spec does not exist, copy the change spec as the new canonical spec.
|
|
72
|
+
- `## ADDED Requirements` appends requirements.
|
|
73
|
+
- `## MODIFIED Requirements` replaces full matching requirement blocks by exact name.
|
|
74
|
+
- `## REMOVED Requirements` deletes full matching requirement blocks by exact name.
|
|
75
|
+
- Preserve unrelated canonical requirements and document sections.
|
|
76
|
+
|
|
77
|
+
Use guardrail semantics from `lib/openspec-guardrails.ts`:
|
|
78
|
+
|
|
79
|
+
- warn on active same-domain collisions;
|
|
80
|
+
- detect legacy flat specs;
|
|
81
|
+
- report destructive REMOVED / large MODIFIED deltas and require approval.
|
|
82
|
+
|
|
83
|
+
## Sync Report
|
|
84
|
+
|
|
85
|
+
Write `openspec/changes/{change}/sync-report.md` in file-backed modes.
|
|
86
|
+
|
|
87
|
+
Include:
|
|
88
|
+
|
|
89
|
+
- status: synced / blocked / not-applicable;
|
|
90
|
+
- domains synced;
|
|
91
|
+
- canonical files updated;
|
|
92
|
+
- ADDED/MODIFIED/REMOVED requirement names;
|
|
93
|
+
- active same-domain collisions;
|
|
94
|
+
- destructive sync approvals or blockers;
|
|
95
|
+
- validation commands or checks performed;
|
|
96
|
+
- next recommended phase: `sdd-archive` when clean.
|
|
97
|
+
|
|
98
|
+
## Rules
|
|
99
|
+
|
|
100
|
+
- Do not move the change folder to archive.
|
|
101
|
+
- Do not commit.
|
|
102
|
+
- Do not launch child subagents. Parent/orchestrator owns delegation.
|
|
103
|
+
- Apply `rules.sync` from `openspec/config.yaml` when present.
|
|
104
|
+
|
|
105
|
+
Return the standard phase envelope with status, executive_summary, artifacts, next_recommended, risks, and skill_resolution.
|
|
@@ -74,11 +74,20 @@ progress: true
|
|
|
74
74
|
|
|
75
75
|
Verify {task} against specs, design, tasks, implementation, apply-progress, strict TDD evidence, assertion quality, and review workload boundaries.
|
|
76
76
|
|
|
77
|
+
## sdd-sync
|
|
78
|
+
|
|
79
|
+
reads: proposal.md+spec.md+design.md+tasks.md+apply-progress.md+verify-report.md
|
|
80
|
+
output: sync-report.md
|
|
81
|
+
outputMode: file-only
|
|
82
|
+
progress: true
|
|
83
|
+
|
|
84
|
+
Sync verified file-backed delta specs for {task} into `openspec/specs/` without archiving. In Engram-only mode, report that canonical sync is not applicable.
|
|
85
|
+
|
|
77
86
|
## sdd-archive
|
|
78
87
|
|
|
79
|
-
reads: verify-report.md
|
|
88
|
+
reads: verify-report.md+sync-report.md
|
|
80
89
|
output: archive-report.md
|
|
81
90
|
outputMode: file-only
|
|
82
91
|
progress: true
|
|
83
92
|
|
|
84
|
-
Archive {task} only when the verification report passes; otherwise report that archive is blocked and preserve active artifacts.
|
|
93
|
+
Archive {task} only when the verification report passes and file-backed sync is complete or not applicable; otherwise report that archive is blocked and preserve active artifacts.
|
|
@@ -29,11 +29,20 @@ progress: true
|
|
|
29
29
|
|
|
30
30
|
Run focused and full verification for {task} using the apply-progress and project artifacts. Include review/judgment blockers.
|
|
31
31
|
|
|
32
|
+
## sdd-sync
|
|
33
|
+
|
|
34
|
+
reads: init.md+apply-progress.md+verify-report.md
|
|
35
|
+
output: sync-report.md
|
|
36
|
+
outputMode: file-only
|
|
37
|
+
progress: true
|
|
38
|
+
|
|
39
|
+
Sync verified file-backed delta specs for {task} into `openspec/specs/` without archiving. In Engram-only mode, report that canonical sync is not applicable.
|
|
40
|
+
|
|
32
41
|
## sdd-archive
|
|
33
42
|
|
|
34
|
-
reads: verify-report.md
|
|
43
|
+
reads: verify-report.md+sync-report.md
|
|
35
44
|
output: archive-report.md
|
|
36
45
|
outputMode: file-only
|
|
37
46
|
progress: true
|
|
38
47
|
|
|
39
|
-
Archive {task} only when verification succeeds. If verification fails, leave artifacts active and report the blocker.
|
|
48
|
+
Archive {task} only when verification succeeds and file-backed sync is complete or not applicable. If verification or sync fails, leave artifacts active and report the blocker.
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -33,6 +33,36 @@ import {
|
|
|
33
33
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
34
34
|
const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
|
|
35
35
|
|
|
36
|
+
function sddAssetDriftCount(cwd: string): number {
|
|
37
|
+
let stale = 0;
|
|
38
|
+
for (const [assetSubdir, installedSubdir] of [
|
|
39
|
+
["agents", join(".pi", "agents")],
|
|
40
|
+
["chains", join(".pi", "chains")],
|
|
41
|
+
] as const) {
|
|
42
|
+
const assetDir = join(ASSETS_DIR, assetSubdir);
|
|
43
|
+
if (!existsSync(assetDir)) continue;
|
|
44
|
+
for (const entry of readdirSync(assetDir, { withFileTypes: true })) {
|
|
45
|
+
if (!entry.isFile()) continue;
|
|
46
|
+
const installedPath = join(cwd, installedSubdir, entry.name);
|
|
47
|
+
try {
|
|
48
|
+
if (!existsSync(installedPath)) {
|
|
49
|
+
stale += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
readFileSync(join(assetDir, entry.name), "utf8") !==
|
|
54
|
+
readFileSync(installedPath, "utf8")
|
|
55
|
+
) {
|
|
56
|
+
stale += 1;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
stale += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return stale;
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
let orchestratorPromptCache: string | null = null;
|
|
37
67
|
function getOrchestratorPrompt(): string {
|
|
38
68
|
if (orchestratorPromptCache === null) {
|
|
@@ -128,6 +158,7 @@ const SDD_AGENT_NAMES = [
|
|
|
128
158
|
"sdd-tasks",
|
|
129
159
|
"sdd-apply",
|
|
130
160
|
"sdd-verify",
|
|
161
|
+
"sdd-sync",
|
|
131
162
|
"sdd-archive",
|
|
132
163
|
] as const;
|
|
133
164
|
const SDD_AGENT_NAME_SET = new Set<string>(SDD_AGENT_NAMES);
|
|
@@ -1338,6 +1369,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1338
1369
|
const openspecConfigured = existsSync(
|
|
1339
1370
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
1340
1371
|
);
|
|
1372
|
+
const staleSddAssets = sddAssetDriftCount(ctx.cwd);
|
|
1341
1373
|
const modelConfig = await readModelConfigAsync(ctx.cwd);
|
|
1342
1374
|
ctx.ui.notify(
|
|
1343
1375
|
[
|
|
@@ -1345,11 +1377,16 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1345
1377
|
`Persona: ${readPersonaMode(ctx.cwd)}`,
|
|
1346
1378
|
`SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
|
|
1347
1379
|
`SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
1380
|
+
`SDD assets stale: ${staleSddAssets} file(s)${
|
|
1381
|
+
staleSddAssets > 0
|
|
1382
|
+
? " — run /gentle-ai:install-sdd --force to refresh intentionally"
|
|
1383
|
+
: ""
|
|
1384
|
+
}`,
|
|
1348
1385
|
`OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
|
|
1349
1386
|
`Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
|
|
1350
1387
|
...describeModelConfig(ctx.cwd, modelConfig),
|
|
1351
1388
|
].join("\n"),
|
|
1352
|
-
"info",
|
|
1389
|
+
staleSddAssets > 0 ? "warning" : "info",
|
|
1353
1390
|
);
|
|
1354
1391
|
},
|
|
1355
1392
|
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export interface RequirementBlock {
|
|
2
|
+
name: string;
|
|
3
|
+
content: string;
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DeltaSpec {
|
|
9
|
+
added: RequirementBlock[];
|
|
10
|
+
modified: RequirementBlock[];
|
|
11
|
+
removed: RequirementBlock[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type DeltaOperation = keyof DeltaSpec;
|
|
15
|
+
|
|
16
|
+
const REQUIREMENT_HEADING = /^### Requirement:\s*(.+?)\s*$/gm;
|
|
17
|
+
const DELTA_SECTION_HEADING = /^##\s+(ADDED|MODIFIED|REMOVED)\s+Requirements\s*$/gim;
|
|
18
|
+
|
|
19
|
+
function normalizeMarkdown(markdown: string): string {
|
|
20
|
+
return markdown.replace(/\r\n/g, "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nextTopLevelSection(markdown: string, from: number): number {
|
|
24
|
+
const match = /^##\s+/gm;
|
|
25
|
+
match.lastIndex = from;
|
|
26
|
+
const next = match.exec(markdown);
|
|
27
|
+
return next?.index ?? markdown.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cleanRequirementContent(content: string): string {
|
|
31
|
+
return content.trimEnd().replace(/\n\s*---\s*$/m, "").trimEnd();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function operationKey(label: string): DeltaOperation {
|
|
35
|
+
switch (label.toUpperCase()) {
|
|
36
|
+
case "ADDED":
|
|
37
|
+
return "added";
|
|
38
|
+
case "MODIFIED":
|
|
39
|
+
return "modified";
|
|
40
|
+
case "REMOVED":
|
|
41
|
+
return "removed";
|
|
42
|
+
default:
|
|
43
|
+
throw new Error(`Unsupported delta operation: ${label}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseRequirementBlocks(markdown: string): RequirementBlock[] {
|
|
48
|
+
const source = normalizeMarkdown(markdown);
|
|
49
|
+
const matches = [...source.matchAll(REQUIREMENT_HEADING)];
|
|
50
|
+
return matches.map((match, index) => {
|
|
51
|
+
const start = match.index ?? 0;
|
|
52
|
+
const end = matches[index + 1]?.index ?? nextTopLevelSection(source, start + match[0].length);
|
|
53
|
+
return {
|
|
54
|
+
name: match[1].trim(),
|
|
55
|
+
content: cleanRequirementContent(source.slice(start, end)),
|
|
56
|
+
start,
|
|
57
|
+
end,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseDeltaSpec(markdown: string): DeltaSpec {
|
|
63
|
+
const source = normalizeMarkdown(markdown);
|
|
64
|
+
const sectionMatches = [...source.matchAll(DELTA_SECTION_HEADING)];
|
|
65
|
+
const delta: DeltaSpec = { added: [], modified: [], removed: [] };
|
|
66
|
+
const seen = new Map<string, string>();
|
|
67
|
+
|
|
68
|
+
for (const [index, match] of sectionMatches.entries()) {
|
|
69
|
+
const sectionStart = (match.index ?? 0) + match[0].length;
|
|
70
|
+
const sectionEnd = sectionMatches[index + 1]?.index ?? source.length;
|
|
71
|
+
const key = operationKey(match[1]);
|
|
72
|
+
const section = source.slice(sectionStart, sectionEnd);
|
|
73
|
+
const blocks = parseRequirementBlocks(section);
|
|
74
|
+
delta[key].push(...blocks);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const [operation, blocks] of Object.entries(delta) as [DeltaOperation, RequirementBlock[]][]) {
|
|
78
|
+
for (const block of blocks) {
|
|
79
|
+
const previous = seen.get(block.name);
|
|
80
|
+
if (previous) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Duplicate delta operation for requirement "${block.name}" (${previous} and ${operation})`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
seen.set(block.name, operation);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return delta;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requirementMap(blocks: RequirementBlock[]): Map<string, RequirementBlock> {
|
|
93
|
+
const out = new Map<string, RequirementBlock>();
|
|
94
|
+
for (const block of blocks) {
|
|
95
|
+
if (out.has(block.name)) throw new Error(`Duplicate canonical requirement "${block.name}"`);
|
|
96
|
+
out.set(block.name, block);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requireCanonicalBlock(
|
|
102
|
+
canonical: Map<string, RequirementBlock>,
|
|
103
|
+
name: string,
|
|
104
|
+
operation: string,
|
|
105
|
+
): RequirementBlock {
|
|
106
|
+
const block = canonical.get(name);
|
|
107
|
+
if (!block) throw new Error(`Missing canonical requirement "${name}" for ${operation}`);
|
|
108
|
+
return block;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function appendAddedRequirements(markdown: string, added: RequirementBlock[]): string {
|
|
112
|
+
if (added.length === 0) return markdown;
|
|
113
|
+
const addition = added.map((block) => block.content.trim()).join("\n\n---\n\n");
|
|
114
|
+
const requirementsHeading = /^## Requirements\s*$/m.exec(markdown);
|
|
115
|
+
if (!requirementsHeading) return `${markdown.trimEnd()}\n\n## Requirements\n\n${addition}\n`;
|
|
116
|
+
|
|
117
|
+
const sectionStart = requirementsHeading.index + requirementsHeading[0].length;
|
|
118
|
+
const sectionEnd = nextTopLevelSection(markdown, sectionStart);
|
|
119
|
+
const before = markdown.slice(0, sectionEnd).trimEnd();
|
|
120
|
+
const after = markdown.slice(sectionEnd).replace(/^\n+/, "");
|
|
121
|
+
return after
|
|
122
|
+
? `${before}\n\n---\n\n${addition}\n\n${after}`
|
|
123
|
+
: `${before}\n\n---\n\n${addition}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function applyDeltaSpec(canonicalMarkdown: string, deltaMarkdown: string): string {
|
|
127
|
+
let result = normalizeMarkdown(canonicalMarkdown);
|
|
128
|
+
const delta = parseDeltaSpec(deltaMarkdown);
|
|
129
|
+
const canonical = requirementMap(parseRequirementBlocks(result));
|
|
130
|
+
|
|
131
|
+
for (const block of delta.added) {
|
|
132
|
+
if (canonical.has(block.name)) {
|
|
133
|
+
throw new Error(`Cannot add existing canonical requirement "${block.name}"`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const replacements: Array<{ start: number; end: number; content: string }> = [];
|
|
138
|
+
for (const block of delta.modified) {
|
|
139
|
+
const target = requireCanonicalBlock(canonical, block.name, "MODIFIED");
|
|
140
|
+
replacements.push({ start: target.start, end: target.end, content: block.content.trimEnd() });
|
|
141
|
+
}
|
|
142
|
+
for (const block of delta.removed) {
|
|
143
|
+
const target = requireCanonicalBlock(canonical, block.name, "REMOVED");
|
|
144
|
+
replacements.push({ start: target.start, end: target.end, content: "" });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
|
148
|
+
const prefix = result.slice(0, replacement.start).trimEnd();
|
|
149
|
+
const suffix = result.slice(replacement.end).replace(/^\n+/, "");
|
|
150
|
+
result = replacement.content
|
|
151
|
+
? `${prefix}\n\n${replacement.content}\n\n${suffix}`.trimEnd() + "\n"
|
|
152
|
+
: `${prefix}\n\n${suffix}`.trimEnd() + "\n";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return appendAddedRequirements(result, delta.added).trimEnd() + "\n";
|
|
156
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseDeltaSpec } from "./openspec-deltas.ts";
|
|
4
|
+
|
|
5
|
+
export interface DomainCollision {
|
|
6
|
+
change: string;
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LegacyFlatSpecWarning {
|
|
11
|
+
change: string;
|
|
12
|
+
path: string;
|
|
13
|
+
hasDomainSpecs: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LargeModifiedRequirement {
|
|
17
|
+
name: string;
|
|
18
|
+
lineCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DestructiveDeltaReport {
|
|
22
|
+
destructive: boolean;
|
|
23
|
+
removedRequirements: string[];
|
|
24
|
+
largeModifiedRequirements: LargeModifiedRequirement[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DestructiveDeltaOptions {
|
|
28
|
+
largeModifiedLineThreshold?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeDirectories(path: string): string[] {
|
|
32
|
+
try {
|
|
33
|
+
return readdirSync(path).filter((entry) => {
|
|
34
|
+
try {
|
|
35
|
+
return statSync(join(path, entry)).isDirectory();
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasAnyDomainSpec(specsDir: string): boolean {
|
|
46
|
+
for (const domain of safeDirectories(specsDir)) {
|
|
47
|
+
if (existsSync(join(specsDir, domain, "spec.md"))) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function detectActiveDomainCollisions(
|
|
53
|
+
cwd: string,
|
|
54
|
+
changeName: string,
|
|
55
|
+
domain: string,
|
|
56
|
+
): DomainCollision[] {
|
|
57
|
+
const changesDir = join(cwd, "openspec", "changes");
|
|
58
|
+
const collisions: DomainCollision[] = [];
|
|
59
|
+
for (const change of safeDirectories(changesDir)) {
|
|
60
|
+
if (change === "archive" || change === changeName) continue;
|
|
61
|
+
const path = join(changesDir, change, "specs", domain, "spec.md");
|
|
62
|
+
if (existsSync(path)) collisions.push({ change, path });
|
|
63
|
+
}
|
|
64
|
+
return collisions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function detectLegacyFlatSpec(
|
|
68
|
+
cwd: string,
|
|
69
|
+
changeName: string,
|
|
70
|
+
): LegacyFlatSpecWarning | undefined {
|
|
71
|
+
const changeDir = join(cwd, "openspec", "changes", changeName);
|
|
72
|
+
const path = join(changeDir, "spec.md");
|
|
73
|
+
if (!existsSync(path)) return undefined;
|
|
74
|
+
return {
|
|
75
|
+
change: changeName,
|
|
76
|
+
path,
|
|
77
|
+
hasDomainSpecs: hasAnyDomainSpec(join(changeDir, "specs")),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function analyzeDeltaDestructiveness(
|
|
82
|
+
deltaMarkdown: string,
|
|
83
|
+
options: DestructiveDeltaOptions = {},
|
|
84
|
+
): DestructiveDeltaReport {
|
|
85
|
+
const threshold = options.largeModifiedLineThreshold ?? 40;
|
|
86
|
+
const delta = parseDeltaSpec(deltaMarkdown);
|
|
87
|
+
const removedRequirements = delta.removed.map((block) => block.name);
|
|
88
|
+
const largeModifiedRequirements = delta.modified
|
|
89
|
+
.map((block) => ({
|
|
90
|
+
name: block.name,
|
|
91
|
+
lineCount: block.content.split("\n").length,
|
|
92
|
+
}))
|
|
93
|
+
.filter((block) => block.lineCount >= threshold);
|
|
94
|
+
return {
|
|
95
|
+
destructive: removedRequirements.length > 0 || largeModifiedRequirements.length > 0,
|
|
96
|
+
removedRequirements,
|
|
97
|
+
largeModifiedRequirements,
|
|
98
|
+
};
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -6,9 +6,21 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
const root = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
7
7
|
|
|
8
8
|
const requiredPaths = [
|
|
9
|
+
"assets/agents/sdd-apply.md",
|
|
10
|
+
"assets/agents/sdd-archive.md",
|
|
11
|
+
"assets/agents/sdd-design.md",
|
|
12
|
+
"assets/agents/sdd-explore.md",
|
|
9
13
|
"assets/agents/sdd-init.md",
|
|
14
|
+
"assets/agents/sdd-proposal.md",
|
|
15
|
+
"assets/agents/sdd-spec.md",
|
|
16
|
+
"assets/agents/sdd-sync.md",
|
|
17
|
+
"assets/agents/sdd-tasks.md",
|
|
18
|
+
"assets/agents/sdd-verify.md",
|
|
19
|
+
"assets/chains/sdd-full.chain.md",
|
|
10
20
|
"assets/chains/sdd-plan.chain.md",
|
|
21
|
+
"assets/chains/sdd-verify.chain.md",
|
|
11
22
|
"assets/support/strict-tdd.md",
|
|
23
|
+
"assets/support/strict-tdd-verify.md",
|
|
12
24
|
"extensions/gentle-ai.ts",
|
|
13
25
|
"extensions/sdd-init.ts",
|
|
14
26
|
"extensions/skill-registry.ts",
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
applyDeltaSpec,
|
|
5
|
+
parseDeltaSpec,
|
|
6
|
+
parseRequirementBlocks,
|
|
7
|
+
} from "../lib/openspec-deltas.ts";
|
|
8
|
+
|
|
9
|
+
const canonicalSpec = `# Example Specification
|
|
10
|
+
|
|
11
|
+
## Purpose
|
|
12
|
+
|
|
13
|
+
Example domain.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
### Requirement: Existing Behavior
|
|
18
|
+
|
|
19
|
+
The system MUST keep existing behavior.
|
|
20
|
+
|
|
21
|
+
#### Scenario: Happy path
|
|
22
|
+
|
|
23
|
+
- GIVEN an existing condition
|
|
24
|
+
- WHEN the action runs
|
|
25
|
+
- THEN existing behavior is preserved
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
### Requirement: Deprecated Behavior
|
|
30
|
+
|
|
31
|
+
The system MUST support old behavior.
|
|
32
|
+
|
|
33
|
+
#### Scenario: Old path
|
|
34
|
+
|
|
35
|
+
- GIVEN an old condition
|
|
36
|
+
- WHEN the action runs
|
|
37
|
+
- THEN old behavior is preserved
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const deltaSpec = `# Delta for Example
|
|
41
|
+
|
|
42
|
+
## ADDED Requirements
|
|
43
|
+
|
|
44
|
+
### Requirement: New Behavior
|
|
45
|
+
|
|
46
|
+
The system MUST support new behavior.
|
|
47
|
+
|
|
48
|
+
#### Scenario: New path
|
|
49
|
+
|
|
50
|
+
- GIVEN a new condition
|
|
51
|
+
- WHEN the action runs
|
|
52
|
+
- THEN new behavior is available
|
|
53
|
+
|
|
54
|
+
## MODIFIED Requirements
|
|
55
|
+
|
|
56
|
+
### Requirement: Existing Behavior
|
|
57
|
+
|
|
58
|
+
The system MUST keep existing behavior and report audit evidence.
|
|
59
|
+
(Previously: existing behavior did not report audit evidence)
|
|
60
|
+
|
|
61
|
+
#### Scenario: Happy path
|
|
62
|
+
|
|
63
|
+
- GIVEN an existing condition
|
|
64
|
+
- WHEN the action runs
|
|
65
|
+
- THEN existing behavior is preserved
|
|
66
|
+
- AND audit evidence is recorded
|
|
67
|
+
|
|
68
|
+
## REMOVED Requirements
|
|
69
|
+
|
|
70
|
+
### Requirement: Deprecated Behavior
|
|
71
|
+
|
|
72
|
+
(Reason: old behavior is no longer supported)
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
test("parseRequirementBlocks extracts requirement blocks with names", () => {
|
|
76
|
+
const blocks = parseRequirementBlocks(canonicalSpec);
|
|
77
|
+
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
blocks.map((block) => block.name),
|
|
80
|
+
["Existing Behavior", "Deprecated Behavior"],
|
|
81
|
+
);
|
|
82
|
+
assert.match(blocks[0].content, /Scenario: Happy path/);
|
|
83
|
+
assert.match(blocks[1].content, /old behavior/i);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("parseDeltaSpec extracts ADDED, MODIFIED, and REMOVED sections", () => {
|
|
87
|
+
const delta = parseDeltaSpec(deltaSpec);
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(
|
|
90
|
+
delta.added.map((block) => block.name),
|
|
91
|
+
["New Behavior"],
|
|
92
|
+
);
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
delta.modified.map((block) => block.name),
|
|
95
|
+
["Existing Behavior"],
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
delta.removed.map((block) => block.name),
|
|
99
|
+
["Deprecated Behavior"],
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("applyDeltaSpec applies ADDED, MODIFIED, and REMOVED while preserving unrelated content", () => {
|
|
104
|
+
const result = applyDeltaSpec(canonicalSpec, deltaSpec);
|
|
105
|
+
|
|
106
|
+
assert.match(result, /### Requirement: New Behavior/);
|
|
107
|
+
assert.match(result, /audit evidence is recorded/);
|
|
108
|
+
assert.doesNotMatch(result, /### Requirement: Deprecated Behavior/);
|
|
109
|
+
assert.match(result, /# Example Specification/);
|
|
110
|
+
assert.match(result, /## Purpose/);
|
|
111
|
+
assert.match(result, /## Requirements/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("applyDeltaSpec preserves sections after Requirements when appending ADDED", () => {
|
|
115
|
+
const result = applyDeltaSpec(
|
|
116
|
+
`${canonicalSpec}\n## Notes\n\nKeep this section.\n`,
|
|
117
|
+
`# Delta
|
|
118
|
+
|
|
119
|
+
## ADDED Requirements
|
|
120
|
+
|
|
121
|
+
### Requirement: New Behavior
|
|
122
|
+
|
|
123
|
+
The system MUST support new behavior.
|
|
124
|
+
`,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
assert.match(result, /### Requirement: New Behavior[\s\S]*\n\n## Notes\n\nKeep this section\./);
|
|
128
|
+
assert.doesNotMatch(result, /Behavior## Notes/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("applyDeltaSpec does not duplicate separators between multiple ADDED requirements", () => {
|
|
132
|
+
const result = applyDeltaSpec(
|
|
133
|
+
canonicalSpec,
|
|
134
|
+
`# Delta
|
|
135
|
+
|
|
136
|
+
## ADDED Requirements
|
|
137
|
+
|
|
138
|
+
### Requirement: First New Behavior
|
|
139
|
+
|
|
140
|
+
The system MUST support the first behavior.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### Requirement: Second New Behavior
|
|
145
|
+
|
|
146
|
+
The system MUST support the second behavior.
|
|
147
|
+
`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
assert.match(result, /### Requirement: First New Behavior[\s\S]*---[\s\S]*### Requirement: Second New Behavior/);
|
|
151
|
+
assert.doesNotMatch(result, /---\n\n---/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("applyDeltaSpec rejects MODIFIED requirements that do not exist", () => {
|
|
155
|
+
assert.throws(
|
|
156
|
+
() =>
|
|
157
|
+
applyDeltaSpec(
|
|
158
|
+
canonicalSpec,
|
|
159
|
+
`# Delta
|
|
160
|
+
|
|
161
|
+
## MODIFIED Requirements
|
|
162
|
+
|
|
163
|
+
### Requirement: Missing Behavior
|
|
164
|
+
|
|
165
|
+
The system MUST fail.
|
|
166
|
+
`,
|
|
167
|
+
),
|
|
168
|
+
/missing canonical requirement.*Missing Behavior/i,
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("applyDeltaSpec rejects REMOVED requirements that do not exist", () => {
|
|
173
|
+
assert.throws(
|
|
174
|
+
() =>
|
|
175
|
+
applyDeltaSpec(
|
|
176
|
+
canonicalSpec,
|
|
177
|
+
`# Delta
|
|
178
|
+
|
|
179
|
+
## REMOVED Requirements
|
|
180
|
+
|
|
181
|
+
### Requirement: Missing Behavior
|
|
182
|
+
|
|
183
|
+
(Reason: already absent)
|
|
184
|
+
`,
|
|
185
|
+
),
|
|
186
|
+
/missing canonical requirement.*Missing Behavior/i,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("applyDeltaSpec rejects duplicate operations for the same requirement", () => {
|
|
191
|
+
assert.throws(
|
|
192
|
+
() =>
|
|
193
|
+
parseDeltaSpec(`# Delta
|
|
194
|
+
|
|
195
|
+
## ADDED Requirements
|
|
196
|
+
|
|
197
|
+
### Requirement: Same Behavior
|
|
198
|
+
|
|
199
|
+
The system MUST do one thing.
|
|
200
|
+
|
|
201
|
+
## REMOVED Requirements
|
|
202
|
+
|
|
203
|
+
### Requirement: Same Behavior
|
|
204
|
+
|
|
205
|
+
(Reason: conflict)
|
|
206
|
+
`),
|
|
207
|
+
/duplicate delta operation.*Same Behavior/i,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtemp } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
analyzeDeltaDestructiveness,
|
|
9
|
+
detectActiveDomainCollisions,
|
|
10
|
+
detectLegacyFlatSpec,
|
|
11
|
+
} from "../lib/openspec-guardrails.ts";
|
|
12
|
+
|
|
13
|
+
test("detectActiveDomainCollisions finds other active changes touching the same domain", async () => {
|
|
14
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-guardrails-"));
|
|
15
|
+
mkdirSync(join(cwd, "openspec/changes/current/specs/sdd-openspec"), { recursive: true });
|
|
16
|
+
mkdirSync(join(cwd, "openspec/changes/other/specs/sdd-openspec"), { recursive: true });
|
|
17
|
+
mkdirSync(join(cwd, "openspec/changes/archive/2026-01-01-old/specs/sdd-openspec"), { recursive: true });
|
|
18
|
+
writeFileSync(join(cwd, "openspec/changes/current/specs/sdd-openspec/spec.md"), "# Current\n");
|
|
19
|
+
writeFileSync(join(cwd, "openspec/changes/other/specs/sdd-openspec/spec.md"), "# Other\n");
|
|
20
|
+
writeFileSync(join(cwd, "openspec/changes/archive/2026-01-01-old/specs/sdd-openspec/spec.md"), "# Old\n");
|
|
21
|
+
|
|
22
|
+
const collisions = detectActiveDomainCollisions(cwd, "current", "sdd-openspec");
|
|
23
|
+
|
|
24
|
+
assert.deepEqual(collisions.map((collision) => collision.change), ["other"]);
|
|
25
|
+
assert.match(collisions[0].path, /openspec\/changes\/other\/specs\/sdd-openspec\/spec\.md$/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("detectLegacyFlatSpec warns when a flat change spec exists without domain specs", async () => {
|
|
29
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-legacy-flat-"));
|
|
30
|
+
mkdirSync(join(cwd, "openspec/changes/legacy-change"), { recursive: true });
|
|
31
|
+
writeFileSync(join(cwd, "openspec/changes/legacy-change/spec.md"), "# Legacy\n");
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(detectLegacyFlatSpec(cwd, "legacy-change"), {
|
|
34
|
+
change: "legacy-change",
|
|
35
|
+
path: join(cwd, "openspec/changes/legacy-change/spec.md"),
|
|
36
|
+
hasDomainSpecs: false,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("detectLegacyFlatSpec reports domain specs when both old and new layouts exist", async () => {
|
|
41
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-legacy-both-"));
|
|
42
|
+
mkdirSync(join(cwd, "openspec/changes/mixed/specs/domain"), { recursive: true });
|
|
43
|
+
writeFileSync(join(cwd, "openspec/changes/mixed/spec.md"), "# Legacy\n");
|
|
44
|
+
writeFileSync(join(cwd, "openspec/changes/mixed/specs/domain/spec.md"), "# Domain\n");
|
|
45
|
+
|
|
46
|
+
assert.equal(detectLegacyFlatSpec(cwd, "mixed")?.hasDomainSpecs, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("analyzeDeltaDestructiveness reports removed and large modified requirements", () => {
|
|
50
|
+
const report = analyzeDeltaDestructiveness(
|
|
51
|
+
`# Delta
|
|
52
|
+
|
|
53
|
+
## MODIFIED Requirements
|
|
54
|
+
|
|
55
|
+
### Requirement: Big Replacement
|
|
56
|
+
|
|
57
|
+
${Array.from({ length: 15 }, (_, index) => `Line ${index + 1}`).join("\n")}
|
|
58
|
+
|
|
59
|
+
## REMOVED Requirements
|
|
60
|
+
|
|
61
|
+
### Requirement: Removed Behavior
|
|
62
|
+
|
|
63
|
+
(Reason: no longer supported)
|
|
64
|
+
`,
|
|
65
|
+
{ largeModifiedLineThreshold: 10 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
assert.equal(report.destructive, true);
|
|
69
|
+
assert.deepEqual(report.removedRequirements, ["Removed Behavior"]);
|
|
70
|
+
assert.deepEqual(report.largeModifiedRequirements.map((item) => item.name), ["Big Replacement"]);
|
|
71
|
+
});
|
|
@@ -288,6 +288,7 @@ async function run() {
|
|
|
288
288
|
{ action: "continue" },
|
|
289
289
|
);
|
|
290
290
|
assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), true);
|
|
291
|
+
assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-sync.md")), true);
|
|
291
292
|
assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
|
|
292
293
|
const lazyAppliedAgent = await readFile(
|
|
293
294
|
join(lazySddCwd, ".pi", "agents", "sdd-apply.md"),
|
|
@@ -383,11 +384,27 @@ async function run() {
|
|
|
383
384
|
await rm(installCwd, { recursive: true, force: true });
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
const staleAssetsCwd = await tempWorkspace();
|
|
388
|
+
try {
|
|
389
|
+
await mkdir(join(staleAssetsCwd, ".pi", "agents"), { recursive: true });
|
|
390
|
+
await mkdir(join(staleAssetsCwd, ".pi", "chains"), { recursive: true });
|
|
391
|
+
await writeFile(join(staleAssetsCwd, ".pi", "agents", "sdd-apply.md"), "stale apply\n");
|
|
392
|
+
await writeFile(join(staleAssetsCwd, ".pi", "agents", "sdd-spec.md"), "stale spec\n");
|
|
393
|
+
await writeFile(join(staleAssetsCwd, ".pi", "chains", "sdd-full.chain.md"), "stale chain\n");
|
|
394
|
+
const ctx = createCtx(staleAssetsCwd, true);
|
|
395
|
+
await commands.get("gentle-ai:status").handler("", ctx);
|
|
396
|
+
assert.match(ctx.ui.notifications.at(-1).message, /SDD assets stale: \d+ file\(s\)/);
|
|
397
|
+
assert.match(ctx.ui.notifications.at(-1).message, /gentle-ai:install-sdd --force/);
|
|
398
|
+
} finally {
|
|
399
|
+
await rm(staleAssetsCwd, { recursive: true, force: true });
|
|
400
|
+
}
|
|
401
|
+
|
|
386
402
|
const sddCwd = await tempWorkspace();
|
|
387
403
|
try {
|
|
388
404
|
const ctx = createCtx(sddCwd, true);
|
|
389
405
|
await commands.get("sdd-init").handler("", ctx);
|
|
390
406
|
assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), true);
|
|
407
|
+
assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-sync.md")), true);
|
|
391
408
|
assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
|
|
392
409
|
assert.equal(ctx.ui.selections.length, 3);
|
|
393
410
|
assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
|