gentle-pi 0.3.2 → 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 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 → explore → proposal → spec → design → tasks → apply → verify → archive
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 mode, 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.
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 mode, 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.
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.
@@ -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,8 +158,10 @@ 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;
164
+ const SDD_AGENT_NAME_SET = new Set<string>(SDD_AGENT_NAMES);
133
165
 
134
166
  type SddAgentName = (typeof SDD_AGENT_NAMES)[number];
135
167
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -170,6 +202,34 @@ const MODEL_CONTROL_OPTIONS = [
170
202
  CUSTOM_MODEL,
171
203
  ] as const;
172
204
 
205
+ function readStringPath(value: unknown, path: string[]): string | undefined {
206
+ let current = value;
207
+ for (const key of path) {
208
+ if (!isRecord(current)) return undefined;
209
+ current = current[key];
210
+ }
211
+ return typeof current === "string" ? current : undefined;
212
+ }
213
+
214
+ function isSddAgentStartEvent(event: unknown): boolean {
215
+ const candidates = [
216
+ readStringPath(event, ["agentName"]),
217
+ readStringPath(event, ["agent"]),
218
+ readStringPath(event, ["name"]),
219
+ readStringPath(event, ["agent", "name"]),
220
+ readStringPath(event, ["subagent", "name"]),
221
+ ]
222
+ .filter((value): value is string => value !== undefined)
223
+ .map((value) => value.trim());
224
+ if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true;
225
+
226
+ const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
227
+ return SDD_AGENT_NAMES.some((name) => {
228
+ const phase = name.replace(/^sdd-/, "");
229
+ return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
230
+ });
231
+ }
232
+
173
233
  function evaluateDeniedCommand(
174
234
  command: string,
175
235
  ): ToolCallEventResult | undefined {
@@ -1209,7 +1269,10 @@ export default function gentleAi(pi: ExtensionAPI): void {
1209
1269
  return { action: "continue" };
1210
1270
  });
1211
1271
 
1212
- pi.on("before_agent_start", (event, ctx) => {
1272
+ pi.on("before_agent_start", async (event, ctx) => {
1273
+ if (isSddAgentStartEvent(event) && !getSddPreflightPreferences(ctx)) {
1274
+ await runSddPreflight(ctx);
1275
+ }
1213
1276
  const prefs = getSddPreflightPreferences(ctx);
1214
1277
  const sddPrompt = prefs ? `\n\n${renderSddPreflightPrompt(prefs)}` : "";
1215
1278
  return {
@@ -1306,6 +1369,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1306
1369
  const openspecConfigured = existsSync(
1307
1370
  join(ctx.cwd, "openspec", "config.yaml"),
1308
1371
  );
1372
+ const staleSddAssets = sddAssetDriftCount(ctx.cwd);
1309
1373
  const modelConfig = await readModelConfigAsync(ctx.cwd);
1310
1374
  ctx.ui.notify(
1311
1375
  [
@@ -1313,11 +1377,16 @@ export default function gentleAi(pi: ExtensionAPI): void {
1313
1377
  `Persona: ${readPersonaMode(ctx.cwd)}`,
1314
1378
  `SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
1315
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
+ }`,
1316
1385
  `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
1317
1386
  `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
1318
1387
  ...describeModelConfig(ctx.cwd, modelConfig),
1319
1388
  ].join("\n"),
1320
- "info",
1389
+ staleSddAssets > 0 ? "warning" : "info",
1321
1390
  );
1322
1391
  },
1323
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.2",
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
+ });
@@ -162,9 +162,14 @@ async function run() {
162
162
  const promptCwd = await tempWorkspace();
163
163
  try {
164
164
  const promptHook = hooks.get("before_agent_start")[0];
165
- const promptResult = promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
165
+ const promptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
166
166
  assert.match(promptResult.systemPrompt, /base/);
167
167
  assert.match(promptResult.systemPrompt, /el Gentleman/);
168
+ assert.equal(
169
+ existsSync(join(promptCwd, ".pi", "agents", "sdd-apply.md")),
170
+ false,
171
+ "normal agent startup must not run SDD preflight",
172
+ );
168
173
  } finally {
169
174
  await rm(promptCwd, { recursive: true, force: true });
170
175
  }
@@ -283,6 +288,7 @@ async function run() {
283
288
  { action: "continue" },
284
289
  );
285
290
  assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), true);
291
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-sync.md")), true);
286
292
  assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
287
293
  const lazyAppliedAgent = await readFile(
288
294
  join(lazySddCwd, ".pi", "agents", "sdd-apply.md"),
@@ -297,7 +303,7 @@ async function run() {
297
303
  await inputHook({ text: "/sdd-plan another change", source: "interactive" }, ctx);
298
304
  assert.equal(ctx.ui.selections.length, 3, "preflight should run only once per session");
299
305
  const promptHook = hooks.get("before_agent_start")[0];
300
- const promptResult = promptHook({ systemPrompt: "base" }, ctx);
306
+ const promptResult = await promptHook({ systemPrompt: "base" }, ctx);
301
307
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
302
308
  assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
303
309
  } finally {
@@ -317,6 +323,34 @@ async function run() {
317
323
  await rm(commandSddCwd, { recursive: true, force: true });
318
324
  }
319
325
 
326
+ const sddAgentGuardCwd = await tempWorkspace();
327
+ try {
328
+ const ctx = createCtx(sddAgentGuardCwd, true, "sdd-agent-guard-session");
329
+ const promptHook = hooks.get("before_agent_start")[0];
330
+ const promptResult = await promptHook(
331
+ {
332
+ systemPrompt: "You are the SDD proposal executor for Gentle AI.",
333
+ },
334
+ ctx,
335
+ );
336
+ assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "agents", "sdd-apply.md")), true);
337
+ assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "chains", "sdd-full.chain.md")), true);
338
+ assert.equal(ctx.ui.selections.length, 3);
339
+ assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
340
+ assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
341
+
342
+ await promptHook(
343
+ {
344
+ agentName: "sdd-tasks",
345
+ systemPrompt: "You are the SDD tasks executor for Gentle AI.",
346
+ },
347
+ ctx,
348
+ );
349
+ assert.equal(ctx.ui.selections.length, 3, "SDD agent guard should reuse session choices");
350
+ } finally {
351
+ await rm(sddAgentGuardCwd, { recursive: true, force: true });
352
+ }
353
+
320
354
  const invalidPreflightCwd = await tempWorkspace();
321
355
  try {
322
356
  await writeFile(globalModelsPath, "{ invalid json");
@@ -350,11 +384,27 @@ async function run() {
350
384
  await rm(installCwd, { recursive: true, force: true });
351
385
  }
352
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
+
353
402
  const sddCwd = await tempWorkspace();
354
403
  try {
355
404
  const ctx = createCtx(sddCwd, true);
356
405
  await commands.get("sdd-init").handler("", ctx);
357
406
  assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), true);
407
+ assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-sync.md")), true);
358
408
  assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
359
409
  assert.equal(ctx.ui.selections.length, 3);
360
410
  assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);