create-sdd-project 0.16.8 → 0.16.10
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/bin/cli.js +2 -0
- package/lib/adapt-agents.js +117 -63
- package/lib/doctor.js +400 -0
- package/lib/init-generator.js +61 -40
- package/lib/upgrade-generator.js +253 -17
- package/package.json +1 -1
- package/template/.claude/agents/backend-planner.md +3 -3
- package/template/.claude/agents/frontend-planner.md +3 -3
- package/template/.gemini/agents/backend-planner.md +2 -2
- package/template/.gemini/agents/frontend-planner.md +3 -3
- package/template/gitignore +3 -0
package/bin/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ const isInit = args.includes('--init');
|
|
|
11
11
|
const isUpgrade = args.includes('--upgrade');
|
|
12
12
|
const isDoctor = args.includes('--doctor');
|
|
13
13
|
const isForce = args.includes('--force');
|
|
14
|
+
const isForceTemplate = args.includes('--force-template');
|
|
14
15
|
const isEject = args.includes('--eject');
|
|
15
16
|
const isDiff = args.includes('--diff');
|
|
16
17
|
|
|
@@ -185,6 +186,7 @@ async function runUpgrade() {
|
|
|
185
186
|
config.autonomyLevel = autonomy.level;
|
|
186
187
|
config.autonomyName = autonomy.name;
|
|
187
188
|
config.installedVersion = installedVersion;
|
|
189
|
+
config.forceTemplate = isForceTemplate;
|
|
188
190
|
|
|
189
191
|
// Build and show summary
|
|
190
192
|
const state = {
|
package/lib/adapt-agents.js
CHANGED
|
@@ -2,10 +2,114 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Per-agent-file adaptation rules for single-stack projects.
|
|
7
|
+
*
|
|
8
|
+
* For each projectType ('backend' or 'frontend'), maps filename to a list of
|
|
9
|
+
* [search, replace] tuples applied in order. When `search` is a RegExp, uses
|
|
10
|
+
* .replace(); when it's a string, uses split/join to replace all occurrences.
|
|
11
|
+
*
|
|
12
|
+
* These rules ONLY apply to files under .claude/agents/ or .gemini/agents/.
|
|
13
|
+
* Skill and template file adaptations live in the I/O wrapper below because
|
|
14
|
+
* they don't need the pure function (they're not used by upgrade smart-diff).
|
|
15
|
+
*
|
|
16
|
+
* fullstack projectType has no rules — templates ship in fullstack-ready form.
|
|
17
|
+
*/
|
|
18
|
+
const AGENT_ADAPTATION_RULES = {
|
|
19
|
+
backend: {
|
|
20
|
+
'spec-creator.md': [
|
|
21
|
+
[/### Frontend Specifications\n(?:- [^\n]*\n)+\n/, ''],
|
|
22
|
+
[/### For UI Changes\n```markdown\n(?:[^\n]*\n)*?```\n\n/, ''],
|
|
23
|
+
['Data Model Changes, UI Changes, Edge Cases', 'Data Model Changes, Edge Cases'],
|
|
24
|
+
['(`api-spec.yaml`, `ui-components.md`)', '(`api-spec.yaml`)'],
|
|
25
|
+
// Gemini agents have different text patterns (also applied to Claude; no-op if no match):
|
|
26
|
+
[/\(api-spec\.yaml, ui-components\.md\)/, '(api-spec.yaml)'],
|
|
27
|
+
],
|
|
28
|
+
'production-code-validator.md': [
|
|
29
|
+
[/- Components exported\/used that are NOT listed in `docs\/specs\/ui-components\.md`\n/, ''],
|
|
30
|
+
[/,? components not in ui-components\.md/, ''],
|
|
31
|
+
[/\.? ?Check components vs `ui-components\.md`/, ''],
|
|
32
|
+
],
|
|
33
|
+
'code-review-specialist.md': [
|
|
34
|
+
['(`backend-standards.mdc` / `frontend-standards.mdc`)', '(`backend-standards.mdc`)'],
|
|
35
|
+
['(`api-spec.yaml`, `ui-components.md`)', '(`api-spec.yaml`)'],
|
|
36
|
+
],
|
|
37
|
+
'qa-engineer.md': [
|
|
38
|
+
['(`api-spec.yaml`, `ui-components.md`)', '(`api-spec.yaml`)'],
|
|
39
|
+
[/- Frontend: `cd frontend && npm test`\n/, ''],
|
|
40
|
+
[/- \*\*Frontend\*\*: Write tests for error states[^\n]*\n/, ''],
|
|
41
|
+
['`backend-standards.mdc` / `frontend-standards.mdc`', '`backend-standards.mdc`'],
|
|
42
|
+
['`backend-standards.mdc` and/or `frontend-standards.mdc`', '`backend-standards.mdc`'],
|
|
43
|
+
[/ and\/or `(?:ai-specs\/specs\/)?frontend-standards\.mdc`/, ''],
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
frontend: {
|
|
47
|
+
'spec-creator.md': [
|
|
48
|
+
[/### Backend Specifications\n(?:- [^\n]*\n)+\n/, ''],
|
|
49
|
+
[/### For API Changes\n```yaml\n(?:[^\n]*\n)*?```\n\n/, ''],
|
|
50
|
+
['(`api-spec.yaml`, `ui-components.md`)', '(`ui-components.md`)'],
|
|
51
|
+
[/\(api-spec\.yaml, ui-components\.md\)/, '(ui-components.md)'],
|
|
52
|
+
],
|
|
53
|
+
'code-review-specialist.md': [
|
|
54
|
+
['(`backend-standards.mdc` / `frontend-standards.mdc`)', '(`frontend-standards.mdc`)'],
|
|
55
|
+
['(`api-spec.yaml`, `ui-components.md`)', '(`ui-components.md`)'],
|
|
56
|
+
],
|
|
57
|
+
'qa-engineer.md': [
|
|
58
|
+
['(`api-spec.yaml`, `ui-components.md`)', '(`ui-components.md`)'],
|
|
59
|
+
[/- Backend: `cd backend && npm test`\n/, ''],
|
|
60
|
+
[/- \*\*Backend\*\*: Write tests for error paths[^\n]*\n/, ''],
|
|
61
|
+
['`backend-standards.mdc` / `frontend-standards.mdc`', '`frontend-standards.mdc`'],
|
|
62
|
+
['`backend-standards.mdc` and/or `frontend-standards.mdc`', '`frontend-standards.mdc`'],
|
|
63
|
+
[/`(?:ai-specs\/specs\/)?backend-standards\.mdc` and\/or /, ''],
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Pure function — apply single-stack adaptation rules to an agent file's content.
|
|
70
|
+
*
|
|
71
|
+
* Used by both the I/O wrapper below AND by upgrade-generator.js smart-diff
|
|
72
|
+
* (v0.16.10+) to compute what the "pristine adapted target" should be for a
|
|
73
|
+
* given (rawTemplate, filename, projectType) tuple. The smart-diff compares
|
|
74
|
+
* the user's current file against this value — if they match, the user hasn't
|
|
75
|
+
* customized and the upgrade can safely replace.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} content - Raw template content
|
|
78
|
+
* @param {string} filename - Agent file basename (e.g. 'spec-creator.md')
|
|
79
|
+
* @param {string} projectType - 'fullstack' | 'backend' | 'frontend'
|
|
80
|
+
* @returns {string} - Adapted content (or unchanged if no rules apply)
|
|
81
|
+
*/
|
|
82
|
+
function adaptAgentContentString(content, filename, projectType) {
|
|
83
|
+
if (projectType === 'fullstack') return content;
|
|
84
|
+
|
|
85
|
+
const rulesForType = AGENT_ADAPTATION_RULES[projectType];
|
|
86
|
+
if (!rulesForType) return content;
|
|
87
|
+
|
|
88
|
+
const rules = rulesForType[filename];
|
|
89
|
+
if (!rules) return content;
|
|
90
|
+
|
|
91
|
+
let result = content;
|
|
92
|
+
for (const [search, replace] of rules) {
|
|
93
|
+
if (search instanceof RegExp) {
|
|
94
|
+
result = result.replace(search, replace);
|
|
95
|
+
} else {
|
|
96
|
+
result = result.split(search).join(replace);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
5
102
|
/**
|
|
6
103
|
* Shared agent/skill content adaptation for single-stack projects.
|
|
7
104
|
* Used by both generator.js (new projects) and init-generator.js (--init).
|
|
8
105
|
*
|
|
106
|
+
* As of v0.16.10, delegates per-agent-file transformations to the pure
|
|
107
|
+
* adaptAgentContentString() function so upgrade-generator.js smart-diff can
|
|
108
|
+
* share the same rules. Skill and template file adaptations (SKILL.md,
|
|
109
|
+
* ticket-template.md, pr-template.md, AGENTS.md ui-ux-designer removal,
|
|
110
|
+
* base-standards.mdc cleanup) remain inline because they're not needed by
|
|
111
|
+
* smart-diff.
|
|
112
|
+
*
|
|
9
113
|
* @param {string} dest - Destination directory
|
|
10
114
|
* @param {object} config - Config with projectType and aiTools
|
|
11
115
|
* @param {function} replaceInFileFn - Function(filePath, replacements) to perform replacements
|
|
@@ -15,71 +119,20 @@ function adaptAgentContentForProjectType(dest, config, replaceInFileFn) {
|
|
|
15
119
|
if (config.aiTools !== 'gemini') toolDirs.push('.claude');
|
|
16
120
|
if (config.aiTools !== 'claude') toolDirs.push('.gemini');
|
|
17
121
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'spec-creator.md'), [
|
|
22
|
-
[/### Frontend Specifications\n(?:- [^\n]*\n)+\n/, ''],
|
|
23
|
-
[/### For UI Changes\n```markdown\n(?:[^\n]*\n)*?```\n\n/, ''],
|
|
24
|
-
['Data Model Changes, UI Changes, Edge Cases', 'Data Model Changes, Edge Cases'],
|
|
25
|
-
['(`api-spec.yaml`, `ui-components.md`)', '(`api-spec.yaml`)'],
|
|
26
|
-
]);
|
|
27
|
-
// production-code-validator: remove ui-components line from Spec Drift
|
|
28
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'production-code-validator.md'), [
|
|
29
|
-
[/- Components exported\/used that are NOT listed in `docs\/specs\/ui-components\.md`\n/, ''],
|
|
30
|
-
]);
|
|
31
|
-
// code-review-specialist: backend-only standards ref, remove ui-components
|
|
32
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'code-review-specialist.md'), [
|
|
33
|
-
['(`backend-standards.mdc` / `frontend-standards.mdc`)', '(`backend-standards.mdc`)'],
|
|
34
|
-
['(`api-spec.yaml`, `ui-components.md`)', '(`api-spec.yaml`)'],
|
|
35
|
-
]);
|
|
36
|
-
// qa-engineer: remove frontend refs, adapt standards refs
|
|
37
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'qa-engineer.md'), [
|
|
38
|
-
['(`api-spec.yaml`, `ui-components.md`)', '(`api-spec.yaml`)'],
|
|
39
|
-
[/- Frontend: `cd frontend && npm test`\n/, ''],
|
|
40
|
-
[/- \*\*Frontend\*\*: Write tests for error states[^\n]*\n/, ''],
|
|
41
|
-
['`backend-standards.mdc` / `frontend-standards.mdc`', '`backend-standards.mdc`'],
|
|
42
|
-
['`backend-standards.mdc` and/or `frontend-standards.mdc`', '`backend-standards.mdc`'],
|
|
43
|
-
[/ and\/or `(?:ai-specs\/specs\/)?frontend-standards\.mdc`/, ''],
|
|
44
|
-
]);
|
|
45
|
-
}
|
|
46
|
-
} else if (config.projectType === 'frontend') {
|
|
122
|
+
// --- Agent file adaptations (delegate to pure function) ---
|
|
123
|
+
const rulesForType = AGENT_ADAPTATION_RULES[config.projectType];
|
|
124
|
+
if (rulesForType) {
|
|
47
125
|
for (const dir of toolDirs) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
[/### For API Changes\n```yaml\n(?:[^\n]*\n)*?```\n\n/, ''],
|
|
52
|
-
['(`api-spec.yaml`, `ui-components.md`)', '(`ui-components.md`)'],
|
|
53
|
-
]);
|
|
54
|
-
// code-review-specialist: frontend-only standards ref
|
|
55
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'code-review-specialist.md'), [
|
|
56
|
-
['(`backend-standards.mdc` / `frontend-standards.mdc`)', '(`frontend-standards.mdc`)'],
|
|
57
|
-
['(`api-spec.yaml`, `ui-components.md`)', '(`ui-components.md`)'],
|
|
58
|
-
]);
|
|
59
|
-
// qa-engineer: remove backend refs, adapt standards refs
|
|
60
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'qa-engineer.md'), [
|
|
61
|
-
['(`api-spec.yaml`, `ui-components.md`)', '(`ui-components.md`)'],
|
|
62
|
-
[/- Backend: `cd backend && npm test`\n/, ''],
|
|
63
|
-
[/- \*\*Backend\*\*: Write tests for error paths[^\n]*\n/, ''],
|
|
64
|
-
['`backend-standards.mdc` / `frontend-standards.mdc`', '`frontend-standards.mdc`'],
|
|
65
|
-
['`backend-standards.mdc` and/or `frontend-standards.mdc`', '`frontend-standards.mdc`'],
|
|
66
|
-
[/`(?:ai-specs\/specs\/)?backend-standards\.mdc` and\/or /, ''],
|
|
67
|
-
]);
|
|
126
|
+
for (const [filename, rules] of Object.entries(rulesForType)) {
|
|
127
|
+
replaceInFileFn(path.join(dest, dir, 'agents', filename), rules);
|
|
128
|
+
}
|
|
68
129
|
}
|
|
69
130
|
}
|
|
70
131
|
|
|
71
|
-
// Skills and templates: remove frontend/backend-specific references
|
|
132
|
+
// --- Skills and templates: remove frontend/backend-specific references ---
|
|
133
|
+
// These stay inline — they're not agent files, so not needed by upgrade smart-diff.
|
|
72
134
|
if (config.projectType === 'backend') {
|
|
73
135
|
for (const dir of toolDirs) {
|
|
74
|
-
// Gemini agents have different text patterns for spec-creator
|
|
75
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'spec-creator.md'), [
|
|
76
|
-
[/\(api-spec\.yaml, ui-components\.md\)/, '(api-spec.yaml)'],
|
|
77
|
-
['Data Model Changes, UI Changes, Edge Cases', 'Data Model Changes, Edge Cases'],
|
|
78
|
-
]);
|
|
79
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'production-code-validator.md'), [
|
|
80
|
-
[/,? components not in ui-components\.md/, ''],
|
|
81
|
-
[/\.? ?Check components vs `ui-components\.md`/, ''],
|
|
82
|
-
]);
|
|
83
136
|
// SKILL.md: remove ui-components references and design review step
|
|
84
137
|
replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'SKILL.md'), [
|
|
85
138
|
[/,? `ui-components\.md`\)/, ')'],
|
|
@@ -106,9 +159,6 @@ function adaptAgentContentForProjectType(dest, config, replaceInFileFn) {
|
|
|
106
159
|
]);
|
|
107
160
|
} else if (config.projectType === 'frontend') {
|
|
108
161
|
for (const dir of toolDirs) {
|
|
109
|
-
replaceInFileFn(path.join(dest, dir, 'agents', 'spec-creator.md'), [
|
|
110
|
-
[/\(api-spec\.yaml, ui-components\.md\)/, '(ui-components.md)'],
|
|
111
|
-
]);
|
|
112
162
|
replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'SKILL.md'), [
|
|
113
163
|
[/`api-spec\.yaml`,? /, ''],
|
|
114
164
|
[/- API endpoints → `docs\/specs\/api-spec\.yaml` \(MANDATORY\)\n/, ''],
|
|
@@ -124,4 +174,8 @@ function adaptAgentContentForProjectType(dest, config, replaceInFileFn) {
|
|
|
124
174
|
}
|
|
125
175
|
}
|
|
126
176
|
|
|
127
|
-
module.exports = {
|
|
177
|
+
module.exports = {
|
|
178
|
+
adaptAgentContentForProjectType,
|
|
179
|
+
adaptAgentContentString,
|
|
180
|
+
AGENT_ADAPTATION_RULES,
|
|
181
|
+
};
|
package/lib/doctor.js
CHANGED
|
@@ -73,6 +73,12 @@ function runDoctor(cwd) {
|
|
|
73
73
|
// 12. Gemini Settings Format
|
|
74
74
|
results.push(checkGeminiSettings(cwd, aiTools));
|
|
75
75
|
|
|
76
|
+
// 13. Gemini TOML Commands Format
|
|
77
|
+
results.push(checkGeminiCommands(cwd, aiTools));
|
|
78
|
+
|
|
79
|
+
// 14. AGENTS.md Standards References (v0.16.10)
|
|
80
|
+
results.push(checkAgentsMdStandardsRefs(cwd));
|
|
81
|
+
|
|
76
82
|
return results;
|
|
77
83
|
}
|
|
78
84
|
|
|
@@ -613,6 +619,400 @@ function checkGeminiSettings(cwd, aiTools) {
|
|
|
613
619
|
};
|
|
614
620
|
}
|
|
615
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Validate a .gemini/commands/*.toml file using a strict subset of TOML
|
|
624
|
+
* grammar sufficient for our narrow use case.
|
|
625
|
+
*
|
|
626
|
+
* Scope: the templates we ship only use two top-level keys (`description`,
|
|
627
|
+
* `prompt`) with string values — standard quoted (`"..."`), single-quoted
|
|
628
|
+
* literal (`'...'`), or triple-quoted multiline (`"""..."""` / `'''...'''`).
|
|
629
|
+
* This validator enforces that subset strictly:
|
|
630
|
+
*
|
|
631
|
+
* - Each non-blank, non-comment line must be either a top-level assignment
|
|
632
|
+
* `key = <string-literal>` or the start of a multiline string
|
|
633
|
+
* - Top-level keys must match `[A-Za-z][A-Za-z0-9_-]*` (bare keys only —
|
|
634
|
+
* quoted keys like `"prompt" = "x"` are flagged as invalid; our templates
|
|
635
|
+
* never use them)
|
|
636
|
+
* - Duplicate top-level keys are rejected (TOML spec forbids them)
|
|
637
|
+
* - Strings must be properly closed on the same line (except triple-quoted,
|
|
638
|
+
* which can span lines)
|
|
639
|
+
* - Trailing content after a closed string is rejected (only a `#` comment
|
|
640
|
+
* is allowed after the value)
|
|
641
|
+
* - Values that are not string literals (numbers, booleans, arrays, etc.)
|
|
642
|
+
* are flagged as non-string
|
|
643
|
+
* - Assignments inside `[table]` or `[[array-table]]` sections are not
|
|
644
|
+
* considered top-level and the scan stops there (our templates don't use
|
|
645
|
+
* tables)
|
|
646
|
+
*
|
|
647
|
+
* This validator is intentionally stricter than full TOML and looser in a
|
|
648
|
+
* few edge cases (e.g., escape sequences inside basic strings are accepted
|
|
649
|
+
* as `\\.`). The goal is to catch files that Gemini CLI's FileCommandLoader
|
|
650
|
+
* would silently skip — not to be a general-purpose TOML parser. If our
|
|
651
|
+
* templates ever need richer TOML features, upgrade to `@iarna/toml` as
|
|
652
|
+
* a runtime dependency at that point.
|
|
653
|
+
*
|
|
654
|
+
* Returns:
|
|
655
|
+
* { ok: true, keys: { prompt?: 'string' | 'non-string', description?: 'string' | 'non-string' } }
|
|
656
|
+
* { ok: false, error: '<message>', line: N }
|
|
657
|
+
*/
|
|
658
|
+
function validateTomlCommandFile(content) {
|
|
659
|
+
const keysSeen = {};
|
|
660
|
+
const lines = content.split(/\r?\n|\r/);
|
|
661
|
+
let i = 0;
|
|
662
|
+
|
|
663
|
+
while (i < lines.length) {
|
|
664
|
+
const raw = lines[i];
|
|
665
|
+
const trimmed = raw.trim();
|
|
666
|
+
|
|
667
|
+
// Blank line or full-line comment
|
|
668
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
669
|
+
i++;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Table / array-table — end of top-level scope, stop scanning
|
|
674
|
+
if (/^\[\[?/.test(trimmed)) {
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Top-level assignment: bare key = value
|
|
679
|
+
const keyMatch = trimmed.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(.*)$/);
|
|
680
|
+
if (!keyMatch) {
|
|
681
|
+
return {
|
|
682
|
+
ok: false,
|
|
683
|
+
error: `line ${i + 1}: not a valid top-level assignment: ${trimmed.slice(0, 60)}`,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const key = keyMatch[1];
|
|
688
|
+
const value = keyMatch[2];
|
|
689
|
+
|
|
690
|
+
if (keysSeen[key] !== undefined) {
|
|
691
|
+
return { ok: false, error: `line ${i + 1}: duplicate top-level key '${key}'` };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Multi-line basic string: """..."""
|
|
695
|
+
if (value.startsWith('"""')) {
|
|
696
|
+
const after = value.slice(3);
|
|
697
|
+
const closeIdx = after.indexOf('"""');
|
|
698
|
+
if (closeIdx !== -1) {
|
|
699
|
+
// Closed on same line — check no trailing content except optional comment
|
|
700
|
+
const trailing = after.slice(closeIdx + 3).trim();
|
|
701
|
+
if (trailing !== '' && !trailing.startsWith('#')) {
|
|
702
|
+
return {
|
|
703
|
+
ok: false,
|
|
704
|
+
error: `line ${i + 1}: trailing content after """ close: ${trailing.slice(0, 40)}`,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
keysSeen[key] = 'string';
|
|
708
|
+
i++;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
// Scan forward for closing """
|
|
712
|
+
let j = i + 1;
|
|
713
|
+
let closed = false;
|
|
714
|
+
while (j < lines.length) {
|
|
715
|
+
const idx2 = lines[j].indexOf('"""');
|
|
716
|
+
if (idx2 !== -1) {
|
|
717
|
+
const trailing2 = lines[j].slice(idx2 + 3).trim();
|
|
718
|
+
if (trailing2 !== '' && !trailing2.startsWith('#')) {
|
|
719
|
+
return {
|
|
720
|
+
ok: false,
|
|
721
|
+
error: `line ${j + 1}: trailing content after """ close: ${trailing2.slice(0, 40)}`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
closed = true;
|
|
725
|
+
i = j + 1;
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
j++;
|
|
729
|
+
}
|
|
730
|
+
if (!closed) {
|
|
731
|
+
return {
|
|
732
|
+
ok: false,
|
|
733
|
+
error: `line ${i + 1}: unterminated triple-quoted basic string (""" never closed)`,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
keysSeen[key] = 'string';
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Multi-line literal string: '''...'''
|
|
741
|
+
if (value.startsWith("'''")) {
|
|
742
|
+
const after = value.slice(3);
|
|
743
|
+
const closeIdx = after.indexOf("'''");
|
|
744
|
+
if (closeIdx !== -1) {
|
|
745
|
+
const trailing = after.slice(closeIdx + 3).trim();
|
|
746
|
+
if (trailing !== '' && !trailing.startsWith('#')) {
|
|
747
|
+
return {
|
|
748
|
+
ok: false,
|
|
749
|
+
error: `line ${i + 1}: trailing content after ''' close: ${trailing.slice(0, 40)}`,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
keysSeen[key] = 'string';
|
|
753
|
+
i++;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
let j = i + 1;
|
|
757
|
+
let closed = false;
|
|
758
|
+
while (j < lines.length) {
|
|
759
|
+
const idx2 = lines[j].indexOf("'''");
|
|
760
|
+
if (idx2 !== -1) {
|
|
761
|
+
const trailing2 = lines[j].slice(idx2 + 3).trim();
|
|
762
|
+
if (trailing2 !== '' && !trailing2.startsWith('#')) {
|
|
763
|
+
return {
|
|
764
|
+
ok: false,
|
|
765
|
+
error: `line ${j + 1}: trailing content after ''' close: ${trailing2.slice(0, 40)}`,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
closed = true;
|
|
769
|
+
i = j + 1;
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
j++;
|
|
773
|
+
}
|
|
774
|
+
if (!closed) {
|
|
775
|
+
return {
|
|
776
|
+
ok: false,
|
|
777
|
+
error: `line ${i + 1}: unterminated triple-quoted literal string (''' never closed)`,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
keysSeen[key] = 'string';
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Basic string: "..." with standard escapes; must close on same line
|
|
785
|
+
// and allow only a trailing comment after the closing quote.
|
|
786
|
+
if (value.startsWith('"')) {
|
|
787
|
+
const basicMatch = value.match(/^"((?:[^"\\]|\\.)*)"(?:\s*(?:#.*)?)?$/);
|
|
788
|
+
if (!basicMatch) {
|
|
789
|
+
return {
|
|
790
|
+
ok: false,
|
|
791
|
+
error: `line ${i + 1}: invalid basic string value (unterminated or trailing content): ${value.slice(0, 60)}`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
keysSeen[key] = 'string';
|
|
795
|
+
i++;
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Literal string: '...' with no escapes; must close on same line
|
|
800
|
+
if (value.startsWith("'")) {
|
|
801
|
+
const litMatch = value.match(/^'([^']*)'(?:\s*(?:#.*)?)?$/);
|
|
802
|
+
if (!litMatch) {
|
|
803
|
+
return {
|
|
804
|
+
ok: false,
|
|
805
|
+
error: `line ${i + 1}: invalid literal string value (unterminated or trailing content): ${value.slice(0, 60)}`,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
keysSeen[key] = 'string';
|
|
809
|
+
i++;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Any other value is not a string literal (int, bool, array, table, etc.)
|
|
814
|
+
keysSeen[key] = 'non-string';
|
|
815
|
+
i++;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return { ok: true, keys: keysSeen };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function checkGeminiCommands(cwd, aiTools) {
|
|
822
|
+
if (aiTools === 'claude') {
|
|
823
|
+
return {
|
|
824
|
+
status: PASS,
|
|
825
|
+
message: 'Gemini commands: N/A (Claude only)',
|
|
826
|
+
details: [],
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const commandsDir = path.join(cwd, '.gemini', 'commands');
|
|
831
|
+
if (!fs.existsSync(commandsDir)) {
|
|
832
|
+
return {
|
|
833
|
+
status: WARN,
|
|
834
|
+
message: 'Gemini commands: .gemini/commands/ missing',
|
|
835
|
+
details: ['Run: npx create-sdd-project --upgrade to recreate template commands'],
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// readdirSync with withFileTypes so we can filter symlinks before reading.
|
|
840
|
+
// Symlinks in .gemini/commands/ would make doctor read arbitrary files on
|
|
841
|
+
// the user's machine — low severity in a local CLI, but worth guarding.
|
|
842
|
+
const entries = fs
|
|
843
|
+
.readdirSync(commandsDir, { withFileTypes: true })
|
|
844
|
+
.filter((e) => e.name.endsWith('.toml'))
|
|
845
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
846
|
+
|
|
847
|
+
if (entries.length === 0) {
|
|
848
|
+
return {
|
|
849
|
+
status: WARN,
|
|
850
|
+
message: 'Gemini commands: no .toml files in .gemini/commands/',
|
|
851
|
+
details: ['Gemini CLI slash commands require .toml files. Run: npx create-sdd-project --upgrade'],
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const issues = [];
|
|
856
|
+
let validCount = 0;
|
|
857
|
+
|
|
858
|
+
for (const entry of entries) {
|
|
859
|
+
const file = entry.name;
|
|
860
|
+
const filePath = path.join(commandsDir, file);
|
|
861
|
+
|
|
862
|
+
// Reject symlinks (Dirent can lie about isFile() when followed; use lstat).
|
|
863
|
+
let lst;
|
|
864
|
+
try {
|
|
865
|
+
lst = fs.lstatSync(filePath);
|
|
866
|
+
} catch (e) {
|
|
867
|
+
issues.push(`${file}: cannot lstat (${e.code || e.message})`);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (lst.isSymbolicLink()) {
|
|
871
|
+
issues.push(`${file}: is a symlink — refusing to follow (security). Delete and run --upgrade to restore template`);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (!lst.isFile()) {
|
|
875
|
+
issues.push(`${file}: not a regular file`);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
let content;
|
|
880
|
+
try {
|
|
881
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
882
|
+
} catch (e) {
|
|
883
|
+
issues.push(`${file}: cannot read (${e.code || e.message})`);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (content.trim() === '') {
|
|
888
|
+
issues.push(`${file}: empty file (Gemini CLI will skip this command silently)`);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Validate using the strict grammar subset for our templates.
|
|
893
|
+
// Gemini CLI's FileCommandLoader schema is:
|
|
894
|
+
// z.object({ prompt: z.string(), description: z.string().optional() })
|
|
895
|
+
const result = validateTomlCommandFile(content);
|
|
896
|
+
|
|
897
|
+
if (!result.ok) {
|
|
898
|
+
issues.push(`${file}: ${result.error}`);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const promptKind = result.keys.prompt;
|
|
903
|
+
const descriptionKind = result.keys.description;
|
|
904
|
+
|
|
905
|
+
if (promptKind === undefined) {
|
|
906
|
+
issues.push(
|
|
907
|
+
`${file}: missing required field 'prompt' (Gemini CLI will silently skip this command)`
|
|
908
|
+
);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (promptKind !== 'string') {
|
|
912
|
+
issues.push(
|
|
913
|
+
`${file}: 'prompt' field must be a string (Gemini CLI requires z.string())`
|
|
914
|
+
);
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (descriptionKind !== undefined && descriptionKind !== 'string') {
|
|
918
|
+
issues.push(
|
|
919
|
+
`${file}: 'description' field is present but is not a string`
|
|
920
|
+
);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
validCount++;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (issues.length > 0) {
|
|
928
|
+
return {
|
|
929
|
+
status: FAIL,
|
|
930
|
+
message: `Gemini commands: ${issues.length} invalid TOML file${issues.length > 1 ? 's' : ''}`,
|
|
931
|
+
details: [
|
|
932
|
+
...issues,
|
|
933
|
+
'Gemini CLI silently skips invalid TOML commands — they will not appear as slash commands in the UI.',
|
|
934
|
+
'Run: npx create-sdd-project --upgrade to restore template commands.',
|
|
935
|
+
],
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return {
|
|
940
|
+
status: PASS,
|
|
941
|
+
message: `Gemini commands: ${validCount}/${entries.length} valid`,
|
|
942
|
+
details: [],
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Check #14 (v0.16.10): detect adapter-failure artifacts in AGENTS.md.
|
|
948
|
+
*
|
|
949
|
+
* The specific broken state seen in foodXPlorer after the v0.16.9 upgrade
|
|
950
|
+
* was `Standards References` containing `"Backend patterns ()"` — empty
|
|
951
|
+
* parens left behind after the template substitution failed. This check
|
|
952
|
+
* looks for that pattern plus any unsubstituted placeholders of the shape
|
|
953
|
+
* `[Something]` that don't match a known-good markdown link.
|
|
954
|
+
*
|
|
955
|
+
* Severity: WARN — an empty-parens AGENTS.md doesn't break the project,
|
|
956
|
+
* it just leaves the agents without full stack context.
|
|
957
|
+
*/
|
|
958
|
+
function checkAgentsMdStandardsRefs(cwd) {
|
|
959
|
+
const agentsMdPath = path.join(cwd, 'AGENTS.md');
|
|
960
|
+
if (!fs.existsSync(agentsMdPath)) {
|
|
961
|
+
return {
|
|
962
|
+
status: WARN,
|
|
963
|
+
message: 'AGENTS.md: missing',
|
|
964
|
+
details: ['Run: npx create-sdd-project --upgrade to recreate'],
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const content = fs.readFileSync(agentsMdPath, 'utf8');
|
|
969
|
+
const issues = [];
|
|
970
|
+
|
|
971
|
+
// Detect adapter failure: "Backend patterns ()" or "Frontend patterns ()"
|
|
972
|
+
const emptyParensMatch = content.match(/(?:Backend|Frontend) patterns \(\s*\)/g);
|
|
973
|
+
if (emptyParensMatch) {
|
|
974
|
+
issues.push(
|
|
975
|
+
`Adapter failure: ${emptyParensMatch.join(', ')} (empty parens after template substitution)`
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Detect unsubstituted placeholders that look like "[Framework, runtime, version]".
|
|
980
|
+
// Template placeholders are distinctive: (a) they contain at least one
|
|
981
|
+
// comma-separated descriptor or the literal word "your", (b) they are NOT
|
|
982
|
+
// the target of a markdown link (no `(` or `:` immediately after the `]`).
|
|
983
|
+
//
|
|
984
|
+
// Gemini cross-model review (v0.16.10) caught the original broad regex
|
|
985
|
+
// /\[[A-Z][^\]]{5,60}\]/g catching legitimate user-added markdown links
|
|
986
|
+
// like `[Architecture Doc](./docs/arch.md)`. AGENTS.md is explicitly meant
|
|
987
|
+
// to be user-customized, so the doctor must not warn on every link.
|
|
988
|
+
//
|
|
989
|
+
// Match only placeholder-shaped strings that are followed by whitespace,
|
|
990
|
+
// end-of-line, or punctuation (not `(` for a link target or `:` for a
|
|
991
|
+
// footnote reference), AND contain either a comma or the word "your" or
|
|
992
|
+
// "example" to distinguish them from section headers.
|
|
993
|
+
const PLACEHOLDER_RE = /\[[A-Z][^\]]{3,60}[,\s](?:[^\]]{0,60})\](?!\(|:)/g;
|
|
994
|
+
const HINT_WORDS_RE = /\b(?:your|example|framework|runtime|version|name|path)\b/i;
|
|
995
|
+
const candidates = content.match(PLACEHOLDER_RE) || [];
|
|
996
|
+
const unsubstituted = candidates.filter((c) => HINT_WORDS_RE.test(c));
|
|
997
|
+
if (unsubstituted.length > 0) {
|
|
998
|
+
issues.push(`Unsubstituted placeholders: ${unsubstituted.slice(0, 3).join(', ')}`);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (issues.length > 0) {
|
|
1002
|
+
return {
|
|
1003
|
+
status: WARN,
|
|
1004
|
+
message: `AGENTS.md: ${issues.length} issue${issues.length > 1 ? 's' : ''}`,
|
|
1005
|
+
details: [...issues, 'Run: npx create-sdd-project --upgrade --force to re-adapt'],
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return {
|
|
1010
|
+
status: PASS,
|
|
1011
|
+
message: 'AGENTS.md: standards references valid',
|
|
1012
|
+
details: [],
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
616
1016
|
module.exports = {
|
|
617
1017
|
runDoctor,
|
|
618
1018
|
printResults,
|
package/lib/init-generator.js
CHANGED
|
@@ -746,54 +746,75 @@ function findSrcRootName(scan) {
|
|
|
746
746
|
function adaptAgentsMd(template, config, scan) {
|
|
747
747
|
let content = template;
|
|
748
748
|
|
|
749
|
-
//
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
)
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
749
|
+
// v0.16.10: guard every replacement against an empty/unhelpful scan.
|
|
750
|
+
// The previous version unconditionally rewrote the project tree and the
|
|
751
|
+
// Standards References line, which produced broken output like
|
|
752
|
+
// `Backend patterns ()` (empty parens) when the scanner couldn't detect a
|
|
753
|
+
// framework. That was the root cause of the foodXPlorer v0.16.9 regression.
|
|
754
|
+
// Now each replacement only runs when the scan produced enough information
|
|
755
|
+
// to improve on the template defaults; otherwise the template stays.
|
|
756
|
+
|
|
757
|
+
// Replace project structure with actual directories — only when the scanner
|
|
758
|
+
// detected at least one meaningful (non-SDD-infrastructure) directory.
|
|
759
|
+
const rootDirs = scan.rootDirs || [];
|
|
760
|
+
const meaningfulDirs = rootDirs.filter((d) => {
|
|
761
|
+
const norm = d.replace(/\/$/, '');
|
|
762
|
+
return norm !== 'docs' && norm !== 'ai-specs';
|
|
763
|
+
});
|
|
764
|
+
if (meaningfulDirs.length > 0) {
|
|
765
|
+
const tree = rootDirs.map((d) => `├── ${d.replace(/\/$/, '/')} `).join('\n');
|
|
766
|
+
const treeBlock = `\`\`\`\nproject/\n${tree}\n└── docs/ ← Documentation\n\`\`\``;
|
|
763
767
|
content = content.replace(
|
|
764
|
-
|
|
765
|
-
|
|
768
|
+
/<!-- CONFIG: Adjust directories[^>]*-->\n+```\nproject\/\n[\s\S]*?```/,
|
|
769
|
+
treeBlock
|
|
766
770
|
);
|
|
771
|
+
|
|
772
|
+
// If not monorepo, simplify the install instructions (only when we
|
|
773
|
+
// actually rewrote the tree, otherwise leave the template alone).
|
|
774
|
+
if (!scan.isMonorepo) {
|
|
775
|
+
content = content.replace(
|
|
776
|
+
/\*\*Critical\*\*: NEVER install dependencies in the root directory\.\n\n(\|.*\n)+/,
|
|
777
|
+
''
|
|
778
|
+
);
|
|
779
|
+
}
|
|
767
780
|
}
|
|
768
781
|
|
|
769
|
-
// Adapt Standards
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
782
|
+
// Adapt Backend Standards description — only when we have enough stack info
|
|
783
|
+
// to build a non-empty parenthetical. Otherwise leave the template default.
|
|
784
|
+
if (scan.backend && scan.backend.detected) {
|
|
785
|
+
const parts = [
|
|
786
|
+
scan.srcStructure && scan.srcStructure.pattern ? patternLabelFor(scan.srcStructure.pattern) : null,
|
|
787
|
+
scan.backend.framework,
|
|
788
|
+
scan.backend.orm,
|
|
789
|
+
].filter(Boolean);
|
|
790
|
+
if (parts.length > 0) {
|
|
791
|
+
content = content.replace(
|
|
792
|
+
'Backend patterns (DDD, Express, Prisma)',
|
|
793
|
+
`Backend patterns (${parts.join(', ')})`
|
|
794
|
+
);
|
|
795
|
+
}
|
|
776
796
|
}
|
|
777
|
-
|
|
797
|
+
|
|
798
|
+
if (scan.frontend && scan.frontend.detected) {
|
|
778
799
|
const parts = [scan.frontend.framework, scan.frontend.styling, scan.frontend.components].filter(Boolean);
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
content = content.replace(
|
|
786
|
-
/- \[Frontend Standards\].*\n/,
|
|
787
|
-
''
|
|
788
|
-
);
|
|
800
|
+
if (parts.length > 0) {
|
|
801
|
+
content = content.replace(
|
|
802
|
+
'Frontend patterns (Next.js, Tailwind, Radix)',
|
|
803
|
+
`Frontend patterns (${parts.join(', ')})`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
789
806
|
}
|
|
790
807
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
808
|
+
// Standards-links pruning: use the authoritative `config.projectType`
|
|
809
|
+
// instead of scanner detection. Scanner is unreliable on freshly-scaffolded
|
|
810
|
+
// projects (no real source code yet) and would cause cross-path drift
|
|
811
|
+
// between scaffold and upgrade. config.projectType comes from user choice
|
|
812
|
+
// at scaffold time or detectProjectType() at upgrade time (which reads
|
|
813
|
+
// from existing files, not source patterns).
|
|
814
|
+
if (config && config.projectType === 'backend') {
|
|
815
|
+
content = content.replace(/- \[Frontend Standards\].*\n/, '');
|
|
816
|
+
} else if (config && config.projectType === 'frontend') {
|
|
817
|
+
content = content.replace(/- \[Backend Standards\].*\n/, '');
|
|
797
818
|
}
|
|
798
819
|
|
|
799
820
|
return content;
|
package/lib/upgrade-generator.js
CHANGED
|
@@ -9,7 +9,10 @@ const {
|
|
|
9
9
|
TEMPLATE_AGENTS,
|
|
10
10
|
TEMPLATE_COMMANDS,
|
|
11
11
|
} = require('./config');
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
adaptAgentContentForProjectType,
|
|
14
|
+
adaptAgentContentString,
|
|
15
|
+
} = require('./adapt-agents');
|
|
13
16
|
const {
|
|
14
17
|
adaptBaseStandards,
|
|
15
18
|
adaptBackendStandards,
|
|
@@ -22,6 +25,87 @@ const {
|
|
|
22
25
|
regexReplaceInFile,
|
|
23
26
|
} = require('./init-generator');
|
|
24
27
|
|
|
28
|
+
// --- v0.16.10: backup-before-replace helpers ---
|
|
29
|
+
//
|
|
30
|
+
// Nuclear safety net for smart-diff protection (Changes #1 + #2 + #3).
|
|
31
|
+
// Ensures no user file is overwritten during upgrade without a recoverable
|
|
32
|
+
// backup. Idempotent per run — each path is backed up at most once even if
|
|
33
|
+
// touched by multiple stages of the upgrade pipeline.
|
|
34
|
+
|
|
35
|
+
const backedUpPaths = new Set(); // reset at the start of every generateUpgrade call
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a collision-safe backup directory name.
|
|
39
|
+
*
|
|
40
|
+
* Format: YYYYMMDD-HHMMSS-NNNN where NNNN is the last 4 digits of the
|
|
41
|
+
* current epoch millisecond count. Two upgrades within the same second get
|
|
42
|
+
* distinct directory names because the millisecond suffix differs.
|
|
43
|
+
*
|
|
44
|
+
* Examples:
|
|
45
|
+
* 20260413-150000-1234
|
|
46
|
+
* 20260413-150000-5678 (one millisecond later)
|
|
47
|
+
*/
|
|
48
|
+
function buildBackupTimestamp() {
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const iso = now
|
|
51
|
+
.toISOString()
|
|
52
|
+
.replace(/[-:]/g, '')
|
|
53
|
+
.replace(/\..+$/, '')
|
|
54
|
+
.replace('T', '-');
|
|
55
|
+
const msSuffix = Date.now().toString().slice(-4);
|
|
56
|
+
return `${iso}-${msSuffix}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalize text for smart-diff comparison. Strips trailing whitespace
|
|
61
|
+
* from each line, normalizes CRLF/CR to LF, and trims leading/trailing
|
|
62
|
+
* blank lines. Prevents false-positive "customized" flags on Windows
|
|
63
|
+
* systems where git's `core.autocrlf=true` rewrites line endings to
|
|
64
|
+
* `\r\n` on checkout while our template reads as `\n`. Also tolerates
|
|
65
|
+
* editors that add/strip trailing whitespace.
|
|
66
|
+
*/
|
|
67
|
+
function normalizeForCompare(text) {
|
|
68
|
+
return text
|
|
69
|
+
.replace(/\r\n/g, '\n')
|
|
70
|
+
.replace(/\r/g, '\n')
|
|
71
|
+
.split('\n')
|
|
72
|
+
.map((l) => l.replace(/[ \t]+$/, ''))
|
|
73
|
+
.join('\n')
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Copy a user file to .sdd-backup/<timestamp>/<relativePath> before it is
|
|
79
|
+
* modified or replaced. Returns the absolute backup path, or null if the
|
|
80
|
+
* source doesn't exist, was already backed up this run, or the copy failed
|
|
81
|
+
* (non-fatal — warning printed).
|
|
82
|
+
*
|
|
83
|
+
* Contract:
|
|
84
|
+
* - Idempotent per run: calling twice for the same path is a no-op
|
|
85
|
+
* - Non-fatal on failure: upgrade continues even if backup can't be written
|
|
86
|
+
* - Does NOT mkdir the backup parent directory until we know the source
|
|
87
|
+
* file exists, to avoid leaving empty directories on failure
|
|
88
|
+
*/
|
|
89
|
+
function backupBeforeReplace(dest, relativePath, backupTimestamp) {
|
|
90
|
+
const key = `${backupTimestamp}::${relativePath}`;
|
|
91
|
+
if (backedUpPaths.has(key)) return null;
|
|
92
|
+
|
|
93
|
+
const sourcePath = path.join(dest, relativePath);
|
|
94
|
+
if (!fs.existsSync(sourcePath)) return null;
|
|
95
|
+
|
|
96
|
+
const backupRoot = path.join(dest, '.sdd-backup', backupTimestamp);
|
|
97
|
+
const backupPath = path.join(backupRoot, relativePath);
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
100
|
+
fs.copyFileSync(sourcePath, backupPath);
|
|
101
|
+
backedUpPaths.add(key);
|
|
102
|
+
return backupPath;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.warn(` ⚠ Backup of ${relativePath} failed: ${e.code || e.message}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
25
109
|
/**
|
|
26
110
|
* Read the installed SDD version from .sdd-version file.
|
|
27
111
|
*/
|
|
@@ -195,7 +279,18 @@ function generateUpgrade(config) {
|
|
|
195
279
|
const aiTools = config.aiTools;
|
|
196
280
|
const projectType = config.projectType;
|
|
197
281
|
|
|
282
|
+
// v0.16.10: Reset per-run backup tracking and build a collision-safe timestamp.
|
|
283
|
+
// Every file replaced by this upgrade will be backed up to .sdd-backup/<ts>/
|
|
284
|
+
// before modification, so the user can always recover.
|
|
285
|
+
backedUpPaths.clear();
|
|
286
|
+
const backupTimestamp = buildBackupTimestamp();
|
|
287
|
+
|
|
288
|
+
// Track which template agents and AGENTS.md were preserved due to customization,
|
|
289
|
+
// so we can surface the list in the upgrade result summary.
|
|
290
|
+
const modifiedAgentsResults = [];
|
|
291
|
+
|
|
198
292
|
console.log(`\nUpgrading SDD DevFlow in ${config.projectName}...\n`);
|
|
293
|
+
console.log(` Backup directory: .sdd-backup/${backupTimestamp}/\n`);
|
|
199
294
|
|
|
200
295
|
// --- a) Preserve user items ---
|
|
201
296
|
const autonomy = readAutonomyLevel(dest);
|
|
@@ -219,8 +314,10 @@ function generateUpgrade(config) {
|
|
|
219
314
|
|
|
220
315
|
for (const dir of toolDirs) {
|
|
221
316
|
const base = path.join(dest, dir);
|
|
222
|
-
//
|
|
223
|
-
|
|
317
|
+
// v0.16.10: we NO LONGER wholesale-delete agents/. The smart-diff loop below
|
|
318
|
+
// iterates TEMPLATE_AGENTS and preserves customized files individually.
|
|
319
|
+
// skills/hooks/styles remain SDD-owned with wholesale delete-and-replace.
|
|
320
|
+
for (const sub of ['skills', 'hooks', 'styles']) {
|
|
224
321
|
const subDir = path.join(base, sub);
|
|
225
322
|
if (fs.existsSync(subDir)) {
|
|
226
323
|
fs.rmSync(subDir, { recursive: true, force: true });
|
|
@@ -240,19 +337,80 @@ function generateUpgrade(config) {
|
|
|
240
337
|
for (const sub of ['agents', 'skills', 'hooks', 'styles', 'commands']) {
|
|
241
338
|
const srcSub = path.join(templateToolDir, sub);
|
|
242
339
|
const destSub = path.join(base, sub);
|
|
243
|
-
if (fs.existsSync(srcSub))
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
340
|
+
if (!fs.existsSync(srcSub)) continue;
|
|
341
|
+
|
|
342
|
+
// v0.16.10: smart-diff per template agent file (see Change #2 in
|
|
343
|
+
// /Users/pb/.claude/plans/validated-wobbling-blum.md).
|
|
344
|
+
//
|
|
345
|
+
// Invariant: this loop only processes files listed in TEMPLATE_AGENTS.
|
|
346
|
+
// Custom agents (files NOT in TEMPLATE_AGENTS) are left untouched on
|
|
347
|
+
// disk — they're also captured by collectCustomAgents earlier, so the
|
|
348
|
+
// existing restore loop at step (c) below will rewrite them from memory
|
|
349
|
+
// (redundant but harmless, same content).
|
|
350
|
+
if (sub === 'agents') {
|
|
351
|
+
fs.mkdirSync(destSub, { recursive: true });
|
|
352
|
+
|
|
353
|
+
const templateAgentFiles = fs
|
|
354
|
+
.readdirSync(srcSub, { withFileTypes: true })
|
|
355
|
+
.filter((e) => e.isFile() && TEMPLATE_AGENTS.includes(e.name))
|
|
356
|
+
.map((e) => e.name);
|
|
357
|
+
|
|
358
|
+
for (const file of templateAgentFiles) {
|
|
359
|
+
const templateAgentPath = path.join(srcSub, file);
|
|
360
|
+
const existingAgentPath = path.join(destSub, file);
|
|
361
|
+
const relativePath = path.relative(dest, existingAgentPath);
|
|
362
|
+
|
|
363
|
+
const rawTemplate = fs.readFileSync(templateAgentPath, 'utf8');
|
|
364
|
+
const adaptedTarget = adaptAgentContentString(rawTemplate, file, projectType);
|
|
365
|
+
|
|
366
|
+
if (fs.existsSync(existingAgentPath) && !config.forceTemplate) {
|
|
367
|
+
const existingContent = fs.readFileSync(existingAgentPath, 'utf8');
|
|
368
|
+
if (normalizeForCompare(existingContent) !== normalizeForCompare(adaptedTarget)) {
|
|
369
|
+
// Customization detected OR template drift across versions.
|
|
370
|
+
// Preserve user's file + save adapted target as .new so they
|
|
371
|
+
// can manually re-merge.
|
|
372
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
373
|
+
const newBackupPath = path.join(
|
|
374
|
+
dest,
|
|
375
|
+
'.sdd-backup',
|
|
376
|
+
backupTimestamp,
|
|
377
|
+
`${relativePath}.new`
|
|
378
|
+
);
|
|
379
|
+
try {
|
|
380
|
+
fs.mkdirSync(path.dirname(newBackupPath), { recursive: true });
|
|
381
|
+
fs.writeFileSync(newBackupPath, adaptedTarget, 'utf8');
|
|
382
|
+
} catch (e) {
|
|
383
|
+
console.warn(
|
|
384
|
+
` ⚠ Failed to write .new backup for ${relativePath}: ${e.code || e.message}`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
modifiedAgentsResults.push({ name: relativePath, modified: true });
|
|
388
|
+
preserved++;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// Pristine: back up before overwriting (cheap insurance)
|
|
392
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
393
|
+
} else if (fs.existsSync(existingAgentPath) && config.forceTemplate) {
|
|
394
|
+
// --force-template: always back up and overwrite
|
|
395
|
+
backupBeforeReplace(dest, relativePath, backupTimestamp);
|
|
250
396
|
}
|
|
251
|
-
|
|
252
|
-
|
|
397
|
+
fs.writeFileSync(existingAgentPath, adaptedTarget, 'utf8');
|
|
398
|
+
replaced++;
|
|
253
399
|
}
|
|
254
|
-
|
|
400
|
+
continue;
|
|
255
401
|
}
|
|
402
|
+
|
|
403
|
+
// For .claude/commands, merge: overwrite SDD template commands, preserve user's custom commands
|
|
404
|
+
if (dir === '.claude' && sub === 'commands') {
|
|
405
|
+
fs.mkdirSync(destSub, { recursive: true });
|
|
406
|
+
for (const file of fs.readdirSync(srcSub)) {
|
|
407
|
+
// Always overwrite template-owned files (they may have been updated)
|
|
408
|
+
fs.cpSync(path.join(srcSub, file), path.join(destSub, file));
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
fs.cpSync(srcSub, destSub, { recursive: true });
|
|
412
|
+
}
|
|
413
|
+
replaced++;
|
|
256
414
|
}
|
|
257
415
|
|
|
258
416
|
// Merge settings.json — strategy depends on the tool:
|
|
@@ -387,18 +545,52 @@ function generateUpgrade(config) {
|
|
|
387
545
|
}
|
|
388
546
|
|
|
389
547
|
// --- e) Replace top-level configs ---
|
|
390
|
-
// AGENTS.md
|
|
548
|
+
// AGENTS.md — v0.16.10 smart-diff (Change #3): mirror the standards pattern.
|
|
549
|
+
// Compare existing file against freshly-adapted template output. If they
|
|
550
|
+
// match, replace (safe pristine). If not, preserve + backup the new adapted
|
|
551
|
+
// version as .new so the user can manually re-merge. --force-template
|
|
552
|
+
// short-circuits and always replaces (with backup).
|
|
391
553
|
const agentsMdTemplate = fs.readFileSync(path.join(templateDir, 'AGENTS.md'), 'utf8');
|
|
392
554
|
const adaptedAgentsMd = adaptAgentsMd(agentsMdTemplate, config, scan);
|
|
393
|
-
|
|
394
|
-
|
|
555
|
+
const agentsMdDestPath = path.join(dest, 'AGENTS.md');
|
|
556
|
+
|
|
557
|
+
if (fs.existsSync(agentsMdDestPath) && !config.forceTemplate) {
|
|
558
|
+
const existingAgentsMd = fs.readFileSync(agentsMdDestPath, 'utf8');
|
|
559
|
+
if (normalizeForCompare(existingAgentsMd) !== normalizeForCompare(adaptedAgentsMd)) {
|
|
560
|
+
// Customization or template drift → preserve + .new backup
|
|
561
|
+
backupBeforeReplace(dest, 'AGENTS.md', backupTimestamp);
|
|
562
|
+
const newBackupPath = path.join(dest, '.sdd-backup', backupTimestamp, 'AGENTS.md.new');
|
|
563
|
+
try {
|
|
564
|
+
fs.mkdirSync(path.dirname(newBackupPath), { recursive: true });
|
|
565
|
+
fs.writeFileSync(newBackupPath, adaptedAgentsMd, 'utf8');
|
|
566
|
+
} catch (e) {
|
|
567
|
+
console.warn(` ⚠ Failed to write .new backup for AGENTS.md: ${e.code || e.message}`);
|
|
568
|
+
}
|
|
569
|
+
modifiedAgentsResults.push({ name: 'AGENTS.md', modified: true });
|
|
570
|
+
preserved++;
|
|
571
|
+
} else {
|
|
572
|
+
// Pristine → back up and replace
|
|
573
|
+
backupBeforeReplace(dest, 'AGENTS.md', backupTimestamp);
|
|
574
|
+
fs.writeFileSync(agentsMdDestPath, adaptedAgentsMd, 'utf8');
|
|
575
|
+
replaced++;
|
|
576
|
+
}
|
|
577
|
+
} else {
|
|
578
|
+
// Missing file, or --force-template: always back up (if exists) and overwrite
|
|
579
|
+
if (fs.existsSync(agentsMdDestPath)) {
|
|
580
|
+
backupBeforeReplace(dest, 'AGENTS.md', backupTimestamp);
|
|
581
|
+
}
|
|
582
|
+
fs.writeFileSync(agentsMdDestPath, adaptedAgentsMd, 'utf8');
|
|
583
|
+
replaced++;
|
|
584
|
+
}
|
|
395
585
|
|
|
396
|
-
// CLAUDE.md / GEMINI.md
|
|
586
|
+
// CLAUDE.md / GEMINI.md (back up before replace, not smart-diff'd)
|
|
397
587
|
if (aiTools !== 'gemini') {
|
|
588
|
+
backupBeforeReplace(dest, 'CLAUDE.md', backupTimestamp);
|
|
398
589
|
fs.copyFileSync(path.join(templateDir, 'CLAUDE.md'), path.join(dest, 'CLAUDE.md'));
|
|
399
590
|
replaced++;
|
|
400
591
|
}
|
|
401
592
|
if (aiTools !== 'claude') {
|
|
593
|
+
backupBeforeReplace(dest, 'GEMINI.md', backupTimestamp);
|
|
402
594
|
fs.copyFileSync(path.join(templateDir, 'GEMINI.md'), path.join(dest, 'GEMINI.md'));
|
|
403
595
|
replaced++;
|
|
404
596
|
}
|
|
@@ -448,6 +640,19 @@ function generateUpgrade(config) {
|
|
|
448
640
|
replaced++;
|
|
449
641
|
}
|
|
450
642
|
|
|
643
|
+
// --- e3) .gitignore — idempotent append of .sdd-backup/ (v0.16.10) ---
|
|
644
|
+
// Existing projects created before v0.16.10 don't have .sdd-backup/ in their
|
|
645
|
+
// .gitignore. Append it once so backup dirs aren't accidentally committed.
|
|
646
|
+
const userGitignorePath = path.join(dest, '.gitignore');
|
|
647
|
+
if (fs.existsSync(userGitignorePath)) {
|
|
648
|
+
const existingGitignore = fs.readFileSync(userGitignorePath, 'utf8');
|
|
649
|
+
if (!/^\s*\/?\.sdd-backup\/?\s*$/m.test(existingGitignore)) {
|
|
650
|
+
const appendBlock = '\n\n# sdd-devflow upgrade backups (ignored — kept locally for recovery only)\n.sdd-backup/\n';
|
|
651
|
+
fs.writeFileSync(userGitignorePath, existingGitignore.trimEnd() + appendBlock, 'utf8');
|
|
652
|
+
step('Updated .gitignore with .sdd-backup/ entry');
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
451
656
|
// --- f) Adapt for project type ---
|
|
452
657
|
// Remove agents for single-stack projects
|
|
453
658
|
if (projectType === 'backend') {
|
|
@@ -551,6 +756,37 @@ function generateUpgrade(config) {
|
|
|
551
756
|
}
|
|
552
757
|
}
|
|
553
758
|
|
|
759
|
+
if (modifiedAgentsResults.length > 0) {
|
|
760
|
+
console.log(
|
|
761
|
+
`\n ⚠ Review preserved customizations (backups in .sdd-backup/${backupTimestamp}/):`
|
|
762
|
+
);
|
|
763
|
+
for (const r of modifiedAgentsResults) {
|
|
764
|
+
console.log(
|
|
765
|
+
` - ${r.name} (not updated; new adapted version saved as ${r.name}.new)`
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
console.log(
|
|
769
|
+
`\n Note: this is EXPECTED on cross-version upgrades (e.g. ${config.installedVersion} → ${newVersion}).`
|
|
770
|
+
);
|
|
771
|
+
console.log(
|
|
772
|
+
` v0.16.10 uses conservative preserve semantics — any file that does not exactly`
|
|
773
|
+
);
|
|
774
|
+
console.log(
|
|
775
|
+
` match the new template's adapted output is preserved, even if you never edited it.`
|
|
776
|
+
);
|
|
777
|
+
console.log(
|
|
778
|
+
` Provenance tracking (v0.17.0) will eliminate these false positives.`
|
|
779
|
+
);
|
|
780
|
+
console.log(`\n If you have NOT customized these files:`);
|
|
781
|
+
console.log(
|
|
782
|
+
` → re-run with --force-template to accept the new template content in bulk`
|
|
783
|
+
);
|
|
784
|
+
console.log(`\n If you HAVE customized these files:`);
|
|
785
|
+
console.log(
|
|
786
|
+
` → diff .sdd-backup/${backupTimestamp}/<path>.new against your file and merge manually`
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
554
790
|
console.log(`\nNext: git add -A && git commit -m "chore: upgrade SDD DevFlow to ${newVersion}"\n`);
|
|
555
791
|
}
|
|
556
792
|
|
package/package.json
CHANGED
|
@@ -78,9 +78,9 @@ List every empirical check you executed using this format: `<command> → <obser
|
|
|
78
78
|
|
|
79
79
|
Example format:
|
|
80
80
|
|
|
81
|
-
- `Grep: "
|
|
82
|
-
- `Read:
|
|
83
|
-
- `Grep: "
|
|
81
|
+
- `Grep: "Status" in src/` → 2 hits: `src/domain/order.ts:14`, `src/schemas/enums.ts:8` → both must be updated in the same commit as the migration, listed under "Files to Modify"
|
|
82
|
+
- `Read: prisma/schema.prisma:45-60` → confirmed `id String @id @default(cuid())` → validator uses `z.string().cuid()`, NOT `z.string().uuid()` or `z.number().int()`
|
|
83
|
+
- `Grep: "formatStatusLabel" in src/` → helper does not yet exist → list under "Files to Create" before commits that depend on it
|
|
84
84
|
- (continue with every empirical check)
|
|
85
85
|
|
|
86
86
|
**If this subsection is empty or missing**, prepend the plan with a warning: `⚠ This plan is text-only and has not been empirically verified against the code. Cross-model reviewers MUST run empirical checks before approving.`
|
|
@@ -78,9 +78,9 @@ List every empirical check using this format: `<command> → <observed fact> →
|
|
|
78
78
|
|
|
79
79
|
Example format:
|
|
80
80
|
|
|
81
|
-
- `Grep: "
|
|
82
|
-
- `Read:
|
|
83
|
-
- `Grep: "aria-labelledby" in
|
|
81
|
+
- `Grep: "formatStatusLabel" in src/` → helper exists in `src/shared/format.ts:32` → do not duplicate inline, import from shared, list under "Existing Code to Reuse"
|
|
82
|
+
- `Read: src/schemas/order.ts:40-60` → confirmed `status` field is `z.enum([...])` with 4 variants → UI must handle all branches, listed under "Key Patterns"
|
|
83
|
+
- `Grep: "aria-labelledby" in src/components/` → existing pattern uses `useId()` for hook-generated IDs → reuse same pattern in new component, not hardcoded strings
|
|
84
84
|
- (continue with every empirical check)
|
|
85
85
|
|
|
86
86
|
**If this subsection is empty or missing**, prepend the plan with a warning: `⚠ This plan is text-only and has not been empirically verified against the code. Cross-model reviewers MUST run empirical checks before approving.`
|
|
@@ -45,8 +45,8 @@ Required checks:
|
|
|
45
45
|
|
|
46
46
|
Append to the ticket a final subsection `### Verification commands run`. Use this exact 3-field format per entry: `<command> → <observed fact> → <impact on plan>`. Every entry must have all three fields — a bare command without an observed fact is not verification. Example:
|
|
47
47
|
|
|
48
|
-
- `Grep: "
|
|
49
|
-
- `Read:
|
|
48
|
+
- `Grep: "Status" in src/` → 2 hits (`src/domain/order.ts:14`, `src/schemas/enums.ts:8`) → both must be updated in the same commit as the migration
|
|
49
|
+
- `Read: prisma/schema.prisma:45-60` → `id String @id @default(cuid())` → validator uses `z.string().cuid()`, NOT `z.string().uuid()` or `z.number().int()`
|
|
50
50
|
|
|
51
51
|
If the subsection is empty or missing, prepend the plan with `⚠ This plan is text-only and has not been empirically verified. Cross-model reviewers MUST run empirical checks.`
|
|
52
52
|
|
|
@@ -40,15 +40,15 @@ Before emitting the final plan, verify every structural claim empirically agains
|
|
|
40
40
|
Required checks:
|
|
41
41
|
|
|
42
42
|
1. Grep or read every file you cite — confirm path exists
|
|
43
|
-
2. Before proposing an inline helper, grep
|
|
43
|
+
2. Before proposing an inline helper, grep shared/utility directories for an existing equivalent. Helpers used across features MUST live in a shared location and be imported; do NOT duplicate inline
|
|
44
44
|
3. Read the shared validation schema for any API response the frontend renders. Frontend MUST match the backend contract, not invent fields
|
|
45
45
|
4. Verify CSS tokens and component primitives exist before proposing new classes. Design tokens live in `tailwind.config.ts` or `globals.css`, not in component files
|
|
46
46
|
5. Verify accessibility semantics (`aria-*`, role, labelled-by) against existing accessible components in the codebase
|
|
47
47
|
|
|
48
48
|
Append to the ticket a final subsection `### Verification commands run`. Use this exact 3-field format per entry: `<command> → <observed fact> → <impact on plan>`. Every entry must have all three fields. Example:
|
|
49
49
|
|
|
50
|
-
- `Grep: "
|
|
51
|
-
- `Read:
|
|
50
|
+
- `Grep: "formatStatusLabel" in src/` → helper exists in `src/shared/format.ts:32` → import from shared, do not duplicate
|
|
51
|
+
- `Read: src/schemas/order.ts:40-60` → `status` field is `z.enum([...])` with 4 variants → component handles all branches
|
|
52
52
|
|
|
53
53
|
If empty or missing, prepend plan with `⚠ This plan is text-only and has not been empirically verified. Cross-model reviewers MUST run empirical checks.`
|
|
54
54
|
|
package/template/gitignore
CHANGED