create-sdd-project 0.16.9 → 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 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 = {
@@ -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
- if (config.projectType === 'backend') {
19
- for (const dir of toolDirs) {
20
- // spec-creator: remove Frontend Specifications section, UI output format, ui-components refs
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
- // spec-creator: remove Backend Specifications section, api-spec refs
49
- replaceInFileFn(path.join(dest, dir, 'agents', 'spec-creator.md'), [
50
- [/### Backend Specifications\n(?:- [^\n]*\n)+\n/, ''],
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 = { adaptAgentContentForProjectType };
177
+ module.exports = {
178
+ adaptAgentContentForProjectType,
179
+ adaptAgentContentString,
180
+ AGENT_ADAPTATION_RULES,
181
+ };
package/lib/doctor.js CHANGED
@@ -76,6 +76,9 @@ function runDoctor(cwd) {
76
76
  // 13. Gemini TOML Commands Format
77
77
  results.push(checkGeminiCommands(cwd, aiTools));
78
78
 
79
+ // 14. AGENTS.md Standards References (v0.16.10)
80
+ results.push(checkAgentsMdStandardsRefs(cwd));
81
+
79
82
  return results;
80
83
  }
81
84
 
@@ -940,6 +943,76 @@ function checkGeminiCommands(cwd, aiTools) {
940
943
  };
941
944
  }
942
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
+
943
1016
  module.exports = {
944
1017
  runDoctor,
945
1018
  printResults,
@@ -746,54 +746,75 @@ function findSrcRootName(scan) {
746
746
  function adaptAgentsMd(template, config, scan) {
747
747
  let content = template;
748
748
 
749
- // Replace project structure with actual directories
750
- const rootDirs = scan.rootDirs;
751
- const tree = rootDirs.map((d) => `├── ${d.replace(/\/$/, '/')} `).join('\n');
752
- const treeBlock = `\`\`\`\nproject/\n${tree}\n└── docs/ ← Documentation\n\`\`\``;
753
-
754
- // Robust: flexible whitespace between CONFIG comment and code block
755
- content = content.replace(
756
- /<!-- CONFIG: Adjust directories[^>]*-->\n+```\nproject\/\n[\s\S]*?```/,
757
- treeBlock
758
- );
759
-
760
- // If not monorepo, simplify the install instructions
761
- // Robust: match any number of table rows (not hardcoded count)
762
- if (!scan.isMonorepo) {
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
- /\*\*Critical\*\*: NEVER install dependencies in the root directory\.\n\n(\|.*\n)+/,
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 References descriptions
770
- if (scan.backend.detected) {
771
- const parts = [scan.srcStructure.pattern ? patternLabelFor(scan.srcStructure.pattern) : null, scan.backend.framework, scan.backend.orm].filter(Boolean);
772
- content = content.replace(
773
- 'Backend patterns (DDD, Express, Prisma)',
774
- `Backend patterns (${parts.join(', ')})`
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
- if (scan.frontend.detected) {
797
+
798
+ if (scan.frontend && scan.frontend.detected) {
778
799
  const parts = [scan.frontend.framework, scan.frontend.styling, scan.frontend.components].filter(Boolean);
779
- content = content.replace(
780
- 'Frontend patterns (Next.js, Tailwind, Radix)',
781
- `Frontend patterns (${parts.join(', ')})`
782
- );
783
- } else {
784
- // Remove frontend-standards reference for backend-only projects
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
- if (!scan.backend.detected) {
792
- // Remove backend-standards reference for frontend-only projects
793
- content = content.replace(
794
- /- \[Backend Standards\].*\n/,
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;
@@ -9,7 +9,10 @@ const {
9
9
  TEMPLATE_AGENTS,
10
10
  TEMPLATE_COMMANDS,
11
11
  } = require('./config');
12
- const { adaptAgentContentForProjectType } = require('./adapt-agents');
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
- // Delete specific SDD-owned subdirectories (NOT commands for .claude)
223
- for (const sub of ['agents', 'skills', 'hooks', 'styles']) {
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
- // For .claude/commands, merge: overwrite SDD template commands, preserve user's custom commands
245
- if (dir === '.claude' && sub === 'commands') {
246
- fs.mkdirSync(destSub, { recursive: true });
247
- for (const file of fs.readdirSync(srcSub)) {
248
- // Always overwrite template-owned files (they may have been updated)
249
- fs.cpSync(path.join(srcSub, file), path.join(destSub, file));
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
- } else {
252
- fs.cpSync(srcSub, destSub, { recursive: true });
397
+ fs.writeFileSync(existingAgentPath, adaptedTarget, 'utf8');
398
+ replaced++;
253
399
  }
254
- replaced++;
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
- fs.writeFileSync(path.join(dest, 'AGENTS.md'), adaptedAgentsMd, 'utf8');
394
- replaced++;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-sdd-project",
3
- "version": "0.16.9",
3
+ "version": "0.16.10",
4
4
  "description": "Create a new SDD DevFlow project with AI-assisted development workflow",
5
5
  "bin": {
6
6
  "create-sdd-project": "bin/cli.js"
@@ -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: "PortionContext" in packages/` → 2 hits: `shared/src/schemas/enums.ts:18`, `shared/src/schemas/standardPortion.ts:4` → both must be deleted in the migration commit, listed under "Files to Modify"
82
- - `Read: packages/api/prisma/schema.prisma:318-330` → confirmed `dishId String @db.Uuid` (not int) → Seed CSV validator must use `z.string().uuid()`, NOT `z.number().int()`
83
- - `Grep: "formatPortionTermLabel" in packages/shared/` → helper does not yet exist → list under "Files to Create" for commit 1 of the TDD order
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: "formatPortionTermLabel" in packages/` → helper exists in `packages/shared/src/portion/portionLabel.ts:32` → do not duplicate inline, import from `@foodxplorer/shared`, list under "Existing Code to Reuse"
82
- - `Read: packages/shared/src/schemas/estimate.ts:180-205` → confirmed `portionAssumption` field is optional with `source: "per_dish" | "generic"` NutritionCard must handle both branches, listed under "Key Patterns"
83
- - `Grep: "aria-labelledby" in packages/web/src/components/` → existing pattern uses `useId()` for hook-generated IDs → reuse same pattern in new component, not hardcoded strings
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 variantsUI 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: "PortionContext" in packages/` → 2 hits (`enums.ts:18`, `standardPortion.ts:4`) → both must be deleted in the migration commit
49
- - `Read: packages/api/prisma/schema.prisma:323` → `dishId String @db.Uuid` (not int) → validator uses `z.string().uuid()`
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 `packages/shared/` for an existing equivalent. Helpers used by BOTH web and bot MUST live in `shared/` and be imported; do NOT duplicate inline per package
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: "formatPortionTermLabel" in packages/` → helper exists in `packages/shared/src/portion/portionLabel.ts:32` → import from `@foodxplorer/shared`, do not duplicate
51
- - `Read: packages/shared/src/schemas/estimate.ts:180-205` → `portionAssumption` is optional with `source: "per_dish" | "generic"` → component handles both branches
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
 
@@ -50,3 +50,6 @@ npm-debug.log*
50
50
  # SDD PM Orchestrator (ephemeral session control files)
51
51
  docs/project_notes/pm-session.lock
52
52
  docs/project_notes/pm-stop.md
53
+
54
+ # sdd-devflow upgrade backups (ignored — kept locally for recovery only)
55
+ .sdd-backup/