create-sdd-project 0.17.0 → 0.17.1

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.
@@ -65,6 +65,99 @@ const AGENT_ADAPTATION_RULES = {
65
65
  },
66
66
  };
67
67
 
68
+ /**
69
+ * v0.17.1: project-type-specific pruning rules for workflow-core files
70
+ * (SKILL.md + ticket-template.md). Keys are POSIX suffixes relative to
71
+ * the tool dir (e.g. `skills/development-workflow/SKILL.md`) so the same
72
+ * rules apply to both `.claude/` and `.gemini/` trees.
73
+ *
74
+ * These rules are the pure/in-memory equivalent of the inline block in
75
+ * `adaptAgentContentForProjectType`. Exposed here so upgrade-generator.js
76
+ * can build an accurate "what init would have produced" comparison target
77
+ * for smart-diff fallback paths — previously this was masked by
78
+ * unconditional `filesToAdapt.add` calls (pre-v0.17.1) that re-applied
79
+ * stack rules to restored user content, violating Codex M1 (Gemini round-3
80
+ * finding 1). Source of truth now lives here; disk-writing code below
81
+ * calls these same tables.
82
+ */
83
+ const WORKFLOW_CORE_PROJECT_TYPE_RULES = {
84
+ backend: {
85
+ 'skills/development-workflow/SKILL.md': [
86
+ [/,? `ui-components\.md`\)/, ')'],
87
+ [/- UI components → `docs\/specs\/ui-components\.md` \(MANDATORY\)\n/, ''],
88
+ [/\d+\. \*\*Design Review \(optional\):\*\*[^\n]*\n/, ''],
89
+ ],
90
+ 'skills/development-workflow/references/ticket-template.md': [
91
+ [/### UI Changes \(if applicable\)\n\n\[Components to add\/modify\. Reference `docs\/specs\/ui-components\.md`\.\]\n\n/, ''],
92
+ [' / `ui-components.md`', ''],
93
+ ],
94
+ },
95
+ frontend: {
96
+ 'skills/development-workflow/SKILL.md': [
97
+ [/`api-spec\.yaml`,? /, ''],
98
+ [/- API endpoints → `docs\/specs\/api-spec\.yaml` \(MANDATORY\)\n/, ''],
99
+ ],
100
+ 'skills/development-workflow/references/ticket-template.md': [
101
+ [/### API Changes \(if applicable\)\n\n\[Endpoints to add\/modify\. Reference[^\]]*\]\n\n/, ''],
102
+ ['`api-spec.yaml` / ', ''],
103
+ ],
104
+ },
105
+ };
106
+
107
+ const BASE_STANDARDS_PROJECT_TYPE_RULES = {
108
+ backend: [
109
+ [/\| `ui-ux-designer` \|[^\n]*\n/, ''],
110
+ ],
111
+ // frontend: no extra rules (base-standards template has no frontend-only refs to strip)
112
+ };
113
+
114
+ function applyProjectTypeRules(content, rules) {
115
+ let result = content;
116
+ for (const [search, replace] of rules) {
117
+ if (search instanceof RegExp) {
118
+ result = result.replace(search, replace);
119
+ } else {
120
+ result = result.split(search).join(replace);
121
+ }
122
+ }
123
+ return result;
124
+ }
125
+
126
+ /**
127
+ * v0.17.1 pure helper — apply project-type rules to a workflow-core file
128
+ * content. Returns content unchanged if projectType is fullstack or if
129
+ * posixPath doesn't match a known workflow-core file.
130
+ *
131
+ * @param {string} content - Raw or stack-adapted content
132
+ * @param {string} posixPath - Full POSIX path (e.g. '.claude/skills/development-workflow/SKILL.md')
133
+ * @param {string} projectType - 'fullstack' | 'backend' | 'frontend'
134
+ * @returns {string}
135
+ */
136
+ function adaptWorkflowCoreContentForProjectType(content, posixPath, projectType) {
137
+ if (projectType === 'fullstack') return content;
138
+ const rulesForType = WORKFLOW_CORE_PROJECT_TYPE_RULES[projectType];
139
+ if (!rulesForType) return content;
140
+
141
+ // Strip the tool prefix (.claude/ or .gemini/) to match the rule key.
142
+ const match = posixPath.match(/^\.(?:claude|gemini)\/(.+)$/);
143
+ if (!match) return content;
144
+ const rules = rulesForType[match[1]];
145
+ if (!rules) return content;
146
+
147
+ return applyProjectTypeRules(content, rules);
148
+ }
149
+
150
+ /**
151
+ * v0.17.1 pure helper — apply project-type rules to base-standards.mdc
152
+ * content. Called AFTER `adaptBaseStandards` to produce the full init
153
+ * equivalent for smart-diff comparison.
154
+ */
155
+ function adaptBaseStandardsContentForProjectType(content, projectType) {
156
+ const rules = BASE_STANDARDS_PROJECT_TYPE_RULES[projectType];
157
+ if (!rules) return content;
158
+ return applyProjectTypeRules(content, rules);
159
+ }
160
+
68
161
  /**
69
162
  * Pure function — apply single-stack adaptation rules to an agent file's content.
70
163
  *
@@ -130,43 +223,38 @@ function adaptAgentContentForProjectType(dest, config, replaceInFileFn) {
130
223
  }
131
224
 
132
225
  // --- Skills and templates: remove frontend/backend-specific references ---
133
- // These stay inline they're not agent files, so not needed by upgrade smart-diff.
226
+ // v0.17.1: SKILL.md + ticket-template.md rules now come from the
227
+ // WORKFLOW_CORE_PROJECT_TYPE_RULES table above so upgrade-generator.js
228
+ // can apply the same rules in-memory (smart-diff fallback comparison).
229
+ // pr-template.md + AGENTS.md + base-standards.mdc remain inline because
230
+ // they're not workflow-core files (pr-template is v0.17.2 scope).
231
+ const wfRules = WORKFLOW_CORE_PROJECT_TYPE_RULES[config.projectType];
232
+ if (wfRules) {
233
+ for (const dir of toolDirs) {
234
+ for (const [suffix, rules] of Object.entries(wfRules)) {
235
+ replaceInFileFn(path.join(dest, dir, ...suffix.split('/')), rules);
236
+ }
237
+ }
238
+ }
239
+
134
240
  if (config.projectType === 'backend') {
241
+ // AGENTS.md: remove ui-ux-designer from hook description
242
+ replaceInFileFn(path.join(dest, 'AGENTS.md'), [
243
+ [', `ui-ux-designer`', ''],
244
+ ]);
135
245
  for (const dir of toolDirs) {
136
- // SKILL.md: remove ui-components references and design review step
137
- replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'SKILL.md'), [
138
- [/,? `ui-components\.md`\)/, ')'],
139
- [/- UI components → `docs\/specs\/ui-components\.md` \(MANDATORY\)\n/, ''],
140
- [/\d+\. \*\*Design Review \(optional\):\*\*[^\n]*\n/, ''],
141
- ]);
142
- // AGENTS.md: remove ui-ux-designer from hook description
143
- replaceInFileFn(path.join(dest, 'AGENTS.md'), [
144
- [', `ui-ux-designer`', ''],
145
- ]);
146
- // ticket-template: remove UI Changes section, ui-components from checklists
147
- replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'references', 'ticket-template.md'), [
148
- [/### UI Changes \(if applicable\)\n\n\[Components to add\/modify\. Reference `docs\/specs\/ui-components\.md`\.\]\n\n/, ''],
149
- [' / `ui-components.md`', ''],
150
- ]);
151
- // pr-template: remove ui-components from checklist
246
+ // pr-template: remove ui-components from checklist (v0.17.2 scope)
152
247
  replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'references', 'pr-template.md'), [
153
248
  [' / ui-components.md', ''],
154
249
  ]);
155
250
  }
156
- // Shared files (outside tool dirs): remove ui-ux-designer and design-guidelines refs
157
- replaceInFileFn(path.join(dest, 'ai-specs', 'specs', 'base-standards.mdc'), [
158
- [/\| `ui-ux-designer` \|[^\n]*\n/, ''],
159
- ]);
251
+ // base-standards.mdc: remove ui-ux-designer table row (shared table above)
252
+ replaceInFileFn(
253
+ path.join(dest, 'ai-specs', 'specs', 'base-standards.mdc'),
254
+ BASE_STANDARDS_PROJECT_TYPE_RULES.backend
255
+ );
160
256
  } else if (config.projectType === 'frontend') {
161
257
  for (const dir of toolDirs) {
162
- replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'SKILL.md'), [
163
- [/`api-spec\.yaml`,? /, ''],
164
- [/- API endpoints → `docs\/specs\/api-spec\.yaml` \(MANDATORY\)\n/, ''],
165
- ]);
166
- replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'references', 'ticket-template.md'), [
167
- [/### API Changes \(if applicable\)\n\n\[Endpoints to add\/modify\. Reference[^\]]*\]\n\n/, ''],
168
- ['`api-spec.yaml` / ', ''],
169
- ]);
170
258
  replaceInFileFn(path.join(dest, dir, 'skills', 'development-workflow', 'references', 'pr-template.md'), [
171
259
  ['api-spec.yaml / ', ''],
172
260
  ]);
@@ -177,5 +265,9 @@ function adaptAgentContentForProjectType(dest, config, replaceInFileFn) {
177
265
  module.exports = {
178
266
  adaptAgentContentForProjectType,
179
267
  adaptAgentContentString,
268
+ adaptWorkflowCoreContentForProjectType,
269
+ adaptBaseStandardsContentForProjectType,
180
270
  AGENT_ADAPTATION_RULES,
271
+ WORKFLOW_CORE_PROJECT_TYPE_RULES,
272
+ BASE_STANDARDS_PROJECT_TYPE_RULES,
181
273
  };
@@ -13,11 +13,17 @@ const {
13
13
  adaptFrontendStandards,
14
14
  } = require('./init-generator');
15
15
  const {
16
- isStandardModified,
17
16
  getPackageVersion,
18
17
  } = require('./upgrade-generator');
18
+ const { normalizedContentEquals } = require('./meta');
19
19
  const { formatScanSummary } = require('./init-wizard');
20
20
 
21
+ // v0.17.1: isStandardModified was removed. Replace callers with inverted
22
+ // normalizedContentEquals — "modified" means "not equal after normalization".
23
+ function isStandardModified(existing, fresh) {
24
+ return !normalizedContentEquals(existing, fresh);
25
+ }
26
+
21
27
  const templateDir = path.join(__dirname, '..', 'template');
22
28
 
23
29
  /**
package/lib/doctor.js CHANGED
@@ -979,6 +979,24 @@ function checkAgentsMdStandardsRefs(cwd) {
979
979
  );
980
980
  }
981
981
 
982
+ // v0.17.1 observability (Gemini Q10): warn on sparse Backend/Frontend patterns
983
+ // — exactly 1 entry — suggesting scanner detection missed framework or ORM.
984
+ // Permissive: non-failing, informational. Two+ entries are assumed OK because
985
+ // projects legitimately vary (ORM-only backends, component-less frontends).
986
+ const sparseRe = /(Backend|Frontend) patterns \(([^)]+)\)/g;
987
+ let sparseMatch;
988
+ while ((sparseMatch = sparseRe.exec(content)) !== null) {
989
+ const rawEntries = sparseMatch[2]
990
+ .split(',')
991
+ .map((s) => s.trim())
992
+ .filter((s) => s.length > 0);
993
+ if (rawEntries.length === 1) {
994
+ issues.push(
995
+ `${sparseMatch[1]} patterns has only 1 entry (${rawEntries[0]}) — scanner detection may be incomplete; run \`npx create-sdd-project --upgrade\` after installing your stack deps to re-detect`
996
+ );
997
+ }
998
+ }
999
+
982
1000
  // Detect unsubstituted placeholders that look like "[Framework, runtime, version]".
983
1001
  // Template placeholders are distinctive: (a) they contain at least one
984
1002
  // comma-separated descriptor or the literal word "your", (b) they are NOT
@@ -576,10 +576,15 @@ function adaptFrontendStandards(template, scan) {
576
576
  );
577
577
 
578
578
  // Update Project Structure
579
+ // v0.17.1: the regex consumes the optional trailing TODO line so idempotent
580
+ // reapplication doesn't duplicate the marker. Before: on second call the
581
+ // regex only matched up to the closing ``` and the replacement re-inserted
582
+ // the TODO, producing two copies. After: the (\n\n<!-- TODO: ... -->)? group
583
+ // is included in the match so the replacement overwrites it.
579
584
  const rootDirs = scan.rootDirs.filter((d) => !['docs/', 'ai-specs/', 'node_modules/'].includes(d));
580
585
  const tree = rootDirs.map((d) => `├── ${d.replace(/\/$/, '/')}`).join('\n');
581
586
  content = content.replace(
582
- /## Project Structure\n\n```\n[\s\S]*?```/,
587
+ /## Project Structure\n\n```\n[\s\S]*?```(\n\n<!-- TODO: Expand the structure above[^\n]*-->)?/,
583
588
  `## Project Structure\n\n\`\`\`\nproject/\n${tree}\n\`\`\`\n\n<!-- TODO: Expand the structure above with your key subdirectories. -->`
584
589
  );
585
590
 
package/lib/meta.js CHANGED
@@ -64,6 +64,32 @@ function normalizeForCompare(text) {
64
64
  return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
65
65
  }
66
66
 
67
+ /**
68
+ * v0.17.1: file-agnostic content equality helper. Returns true when two
69
+ * strings are equal after CR/CRLF normalization. Replaces the v0.17.0
70
+ * `isStandardModified` function, which was standards-specific in name but
71
+ * identical in logic after the Gemini M2 normalization fix.
72
+ *
73
+ * Rename rationale (round-2 review consolidated): Codex round-1 Q9 wanted
74
+ * `isStandardModified` deleted; Gemini round-1 Q9 wanted a named helper
75
+ * for fallback-path readability; Codex round-2 Q11 pointed out that the
76
+ * kept-name-as-wrapper compromise was vestigial because the name still
77
+ * implied standards-specific policy. This rename makes the helper's
78
+ * file-agnostic nature explicit and its location (next to its dependency
79
+ * `normalizeForCompare`) structural.
80
+ *
81
+ * Scope of normalization (important, often misstated in earlier plan
82
+ * drafts): this helper ONLY normalizes CR/CRLF line endings. It does NOT
83
+ * strip trailing whitespace per line nor leading/trailing blank lines —
84
+ * that would destroy markdown hard-breaks (two trailing spaces render as
85
+ * `<br>`) and silently wipe user customizations that only touched
86
+ * whitespace. The conservative behavior is intentional; the alternative
87
+ * is strictly worse.
88
+ */
89
+ function normalizedContentEquals(a, b) {
90
+ return normalizeForCompare(a) === normalizeForCompare(b);
91
+ }
92
+
67
93
  /**
68
94
  * Compute the content-addressable hash of a string.
69
95
  *
@@ -203,9 +229,16 @@ function writeMeta(dest, hashes) {
203
229
  * if the file exists on disk.
204
230
  *
205
231
  * v0.17.0 scope: template agents (.claude/agents/*, .gemini/agents/*)
206
- * + AGENTS.md. SKILL.md / ticket-template.md / documentation-standards.mdc
207
- * are tracked with wholesale-recopy + stack-adaptations on every upgrade
208
- * (v0.16.10 behavior); they are NOT in this set.
232
+ * + AGENTS.md.
233
+ *
234
+ * v0.17.1 scope extension (Codex M1 option 2 deferred from v0.17.0):
235
+ * - 4 standards files (ai-specs/specs/*.mdc) — always tracked
236
+ * - 6 workflow-core files (development-workflow SKILL.md + ticket-template.md
237
+ * + merge-checklist.md, × 2 tools) — filtered by aiTools
238
+ *
239
+ * Out of scope for v0.17.1 (deferred to v0.17.2): bug-workflow/SKILL.md,
240
+ * health-check/SKILL.md, pm-orchestrator/SKILL.md, project-memory/SKILL.md,
241
+ * and all references/ files except the 3 development-workflow ones above.
209
242
  */
210
243
  function expectedSmartDiffTrackedPaths(aiTools, projectType) {
211
244
  const paths = new Set();
@@ -228,6 +261,25 @@ function expectedSmartDiffTrackedPaths(aiTools, projectType) {
228
261
 
229
262
  paths.add('AGENTS.md');
230
263
 
264
+ // v0.17.1: standards — always tracked (independent of aiTools/projectType).
265
+ // Project-type filtering (backend-only skips frontend-standards.mdc) is
266
+ // enforced by the existing install/upgrade pipeline that only writes the
267
+ // relevant standards for the project type; their hash entries are simply
268
+ // absent for the non-applicable side.
269
+ paths.add('ai-specs/specs/base-standards.mdc');
270
+ if (projectType !== 'frontend') paths.add('ai-specs/specs/backend-standards.mdc');
271
+ if (projectType !== 'backend') paths.add('ai-specs/specs/frontend-standards.mdc');
272
+ paths.add('ai-specs/specs/documentation-standards.mdc');
273
+
274
+ // v0.17.1: development-workflow skill core files — filtered by aiTools.
275
+ // bug-workflow, health-check, pm-orchestrator, project-memory are OUT OF
276
+ // SCOPE for v0.17.1 (deferred to v0.17.2).
277
+ for (const dir of toolDirs) {
278
+ paths.add(`${dir}/skills/development-workflow/SKILL.md`);
279
+ paths.add(`${dir}/skills/development-workflow/references/ticket-template.md`);
280
+ paths.add(`${dir}/skills/development-workflow/references/merge-checklist.md`);
281
+ }
282
+
231
283
  return paths;
232
284
  }
233
285
 
@@ -283,6 +335,7 @@ module.exports = {
283
335
  hashFileOnDisk,
284
336
  toPosix,
285
337
  normalizeForCompare,
338
+ normalizedContentEquals,
286
339
  readMeta,
287
340
  writeMeta,
288
341
  expectedSmartDiffTrackedPaths,
package/lib/scanner.js CHANGED
@@ -7,17 +7,56 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.n
7
7
 
8
8
  /**
9
9
  * Scan an existing project directory and return detected configuration.
10
+ *
11
+ * v0.17.1: monorepo-aware. If the root `package.json` does not yield a
12
+ * backend/frontend detection AND the project is a monorepo with
13
+ * `package.json#workspaces`, enumerate workspace `package.json` files in
14
+ * declaration order (pattern outer, lexical inner, deduped by normalized
15
+ * path) and run `detectBackend` / `detectFrontend` per workspace. The
16
+ * FIRST workspace returning `detected: true` wins and its result is merged
17
+ * into `result.backend` / `result.frontend` with a `workspaceSource` field
18
+ * recording the detected workspace's relative path (for diagnostics).
19
+ *
20
+ * Scanner additive invariant (v0.17.1): for single-package projects, or
21
+ * monorepos where root detection already succeeded, the workspace
22
+ * enumeration never fires and the result is byte-identical to v0.17.0.
10
23
  */
11
24
  function scan(projectDir) {
12
25
  const pkg = readPackageJson(projectDir);
13
26
 
14
- const result = {
27
+ const backend = detectBackend(projectDir, pkg);
28
+ const frontend = detectFrontend(projectDir, pkg);
29
+ const isMonorepo = detectMonorepo(projectDir, pkg);
30
+
31
+ // v0.17.1 monorepo fallback
32
+ if (isMonorepo && (!backend.detected || !frontend.detected)) {
33
+ const workspaces = enumerateWorkspaces(projectDir, pkg);
34
+ for (const wsRel of workspaces) {
35
+ const wsAbs = path.join(projectDir, ...wsRel.split('/'));
36
+ const wsPkg = readPackageJson(wsAbs);
37
+ if (!backend.detected) {
38
+ const wsBackend = detectBackend(wsAbs, wsPkg);
39
+ if (wsBackend.detected) {
40
+ Object.assign(backend, wsBackend, { workspaceSource: wsRel });
41
+ }
42
+ }
43
+ if (!frontend.detected) {
44
+ const wsFrontend = detectFrontend(wsAbs, wsPkg);
45
+ if (wsFrontend.detected) {
46
+ Object.assign(frontend, wsFrontend, { workspaceSource: wsRel });
47
+ }
48
+ }
49
+ if (backend.detected && frontend.detected) break;
50
+ }
51
+ }
52
+
53
+ return {
15
54
  projectName: pkg.name || path.basename(projectDir),
16
55
  description: pkg.description || '',
17
56
  language: detectLanguage(projectDir),
18
- backend: detectBackend(projectDir, pkg),
19
- frontend: detectFrontend(projectDir, pkg),
20
- isMonorepo: detectMonorepo(projectDir, pkg),
57
+ backend,
58
+ frontend,
59
+ isMonorepo,
21
60
  rootDirs: listRootDirs(projectDir),
22
61
  srcStructure: detectArchitecture(projectDir, pkg),
23
62
  tests: detectTests(projectDir, pkg),
@@ -25,8 +64,90 @@ function scan(projectDir) {
25
64
  gitBranch: detectGitBranch(projectDir),
26
65
  hasGit: fs.existsSync(path.join(projectDir, '.git')),
27
66
  };
67
+ }
28
68
 
29
- return result;
69
+ /**
70
+ * v0.17.1: enumerate workspace paths declared in `pkg.workspaces`.
71
+ *
72
+ * Supports:
73
+ * - Array form: `"workspaces": ["packages/*", "apps/*"]`
74
+ * - Object form: `"workspaces": { "packages": ["packages/*"] }`
75
+ * - Literal paths: `"packages/api"` (no glob)
76
+ * - Single-wildcard patterns: `"packages/*"` (expand immediate subdirs)
77
+ *
78
+ * Does NOT support: `**` recursive patterns, `!exclude` negation, or
79
+ * `pnpm-workspace.yaml` — all deferred to v0.17.2.
80
+ *
81
+ * Returns a deterministic, deduplicated array of POSIX-style relative
82
+ * workspace paths. Ordering: outer = declaration order of patterns; inner
83
+ * = lexical Unicode codepoint sort of expanded subdirs; dedupe = first
84
+ * occurrence wins after flattening (Codex + Gemini round-2 Q7).
85
+ */
86
+ function enumerateWorkspaces(dir, pkg) {
87
+ let patterns = [];
88
+ if (Array.isArray(pkg.workspaces)) {
89
+ patterns = pkg.workspaces;
90
+ } else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) {
91
+ patterns = pkg.workspaces.packages;
92
+ }
93
+ if (patterns.length === 0) return [];
94
+
95
+ const flat = [];
96
+ for (const pattern of patterns) {
97
+ flat.push(...expandWorkspacePattern(dir, pattern));
98
+ }
99
+
100
+ const seen = new Set();
101
+ const deduped = [];
102
+ for (const wsPath of flat) {
103
+ const normalized = wsPath.replace(/\\/g, '/').replace(/\/$/, '');
104
+ if (seen.has(normalized)) continue;
105
+ seen.add(normalized);
106
+ deduped.push(normalized);
107
+ }
108
+ return deduped;
109
+ }
110
+
111
+ function expandWorkspacePattern(dir, pattern) {
112
+ // npm/yarn workspace semantics: a workspace is a DIRECTORY CONTAINING
113
+ // package.json. Directories without package.json are not workspaces,
114
+ // even if they live under a matched glob (Codex round-3 finding 1).
115
+ // This prevents the scanner from wasting work on stray folders
116
+ // (docs/, shared assets, build outputs) and keeps first-match-wins
117
+ // deterministic against only declared workspace packages.
118
+ const hasPkgJson = (absDir) => {
119
+ try {
120
+ return fs.statSync(path.join(absDir, 'package.json')).isFile();
121
+ } catch {
122
+ return false;
123
+ }
124
+ };
125
+
126
+ if (!pattern.includes('*')) {
127
+ const absPath = path.join(dir, pattern);
128
+ try {
129
+ if (fs.statSync(absPath).isDirectory() && hasPkgJson(absPath)) {
130
+ return [pattern.replace(/\\/g, '/')];
131
+ }
132
+ } catch { /* not found or not a dir */ }
133
+ return [];
134
+ }
135
+ // Only support trailing single-wildcard: `foo/*` or `foo/bar/*`
136
+ const match = pattern.match(/^(.+)\/\*$/);
137
+ if (!match) return [];
138
+ const baseDir = match[1];
139
+ const baseDirAbs = path.join(dir, baseDir);
140
+ let entries;
141
+ try {
142
+ entries = fs.readdirSync(baseDirAbs, { withFileTypes: true });
143
+ } catch {
144
+ return [];
145
+ }
146
+ return entries
147
+ .filter((e) => e.isDirectory() && hasPkgJson(path.join(baseDirAbs, e.name)))
148
+ .map((e) => e.name)
149
+ .sort()
150
+ .map((name) => `${baseDir}/${name}`);
30
151
  }
31
152
 
32
153
  // --- Helpers ---
@@ -541,4 +662,4 @@ function detectGitBranch(dir) {
541
662
  return 'main';
542
663
  }
543
664
 
544
- module.exports = { scan };
665
+ module.exports = { scan, enumerateWorkspaces, expandWorkspacePattern };
@@ -12,6 +12,8 @@ const {
12
12
  const {
13
13
  adaptAgentContentForProjectType,
14
14
  adaptAgentContentString,
15
+ adaptWorkflowCoreContentForProjectType,
16
+ adaptBaseStandardsContentForProjectType,
15
17
  } = require('./adapt-agents');
16
18
  const {
17
19
  adaptBaseStandards,
@@ -25,6 +27,7 @@ const {
25
27
  regexReplaceInFile,
26
28
  } = require('./init-generator');
27
29
  // v0.17.0: hash-based smart-diff + shared stack adaptations
30
+ // v0.17.1: + normalizedContentEquals (replaces isStandardModified)
28
31
  const {
29
32
  readMeta,
30
33
  writeMeta,
@@ -34,6 +37,7 @@ const {
34
37
  pruneExpectedAbsent,
35
38
  expectedSmartDiffTrackedPaths,
36
39
  normalizeForCompare: metaNormalizeForCompare,
40
+ normalizedContentEquals,
37
41
  } = require('./meta');
38
42
  const {
39
43
  applyStackAdaptations,
@@ -223,14 +227,6 @@ function collectCustomCommands(dest) {
223
227
  return customs;
224
228
  }
225
229
 
226
- /**
227
- * Check if a standard file has been modified by the user.
228
- * Compares existing file against freshly generated version.
229
- */
230
- function isStandardModified(existingContent, freshContent) {
231
- return existingContent.trim() !== freshContent.trim();
232
- }
233
-
234
230
  /**
235
231
  * Build the upgrade summary for display.
236
232
  */
@@ -308,6 +304,38 @@ function generateUpgrade(config) {
308
304
  const newHashes = { ...(meta?.hashes ?? {}) };
309
305
  const filesToAdapt = new Set();
310
306
 
307
+ // v0.17.1: before the skills/ wholesale delete-and-copy, save the content
308
+ // of the 6 workflow-core files so we can restore them if their hash tells
309
+ // us they were customized. Map keyed by absolute path, value = string or
310
+ // null (null = file didn't exist before upgrade).
311
+ const workflowCoreBackup = new Map();
312
+ const workflowCorePosixPaths = [];
313
+ {
314
+ const dirsForBackup = [];
315
+ if (aiTools !== 'gemini') dirsForBackup.push('.claude');
316
+ if (aiTools !== 'claude') dirsForBackup.push('.gemini');
317
+ for (const dir of dirsForBackup) {
318
+ for (const relSub of [
319
+ 'skills/development-workflow/SKILL.md',
320
+ 'skills/development-workflow/references/ticket-template.md',
321
+ 'skills/development-workflow/references/merge-checklist.md',
322
+ ]) {
323
+ const posix = `${dir}/${relSub}`;
324
+ const abs = path.join(dest, ...posix.split('/'));
325
+ workflowCorePosixPaths.push({ posix, abs });
326
+ if (fs.existsSync(abs)) {
327
+ try {
328
+ workflowCoreBackup.set(abs, fs.readFileSync(abs, 'utf8'));
329
+ } catch {
330
+ workflowCoreBackup.set(abs, null);
331
+ }
332
+ } else {
333
+ workflowCoreBackup.set(abs, null);
334
+ }
335
+ }
336
+ }
337
+ }
338
+
311
339
  console.log(`\nUpgrading SDD DevFlow in ${config.projectName}...\n`);
312
340
  console.log(` Backup directory: .sdd-backup/${backupTimestamp}/\n`);
313
341
 
@@ -567,67 +595,266 @@ function generateUpgrade(config) {
567
595
  preserved++;
568
596
  }
569
597
 
570
- // --- d) Handle standards (smart diff) ---
571
- const standardsResults = [];
598
+ // --- c2) v0.17.1: workflow-core smart-diff protection ---
599
+ //
600
+ // The 6 development-workflow files (SKILL.md + ticket-template.md +
601
+ // merge-checklist.md, × 2 tools) were just wholesale-copied by the
602
+ // skills/ delete-and-replace at step (b). Without this block, user
603
+ // customizations to the core workflow files (ticket templates, merge
604
+ // checklists, SKILL.md definitions) would be silently lost every
605
+ // upgrade. Check each against its pre-upgrade backup (captured before
606
+ // step (b)) via the hash decision tree; restore the backup if the user
607
+ // had customized it, otherwise leave the template version in place.
608
+ for (const { posix, abs } of workflowCorePosixPaths) {
609
+ const backup = workflowCoreBackup.get(abs);
610
+ const relativePath = path.relative(dest, abs);
611
+
612
+ if (backup === null || backup === undefined) {
613
+ // File did not exist pre-upgrade (first install or user deleted it)
614
+ // → leave the freshly-copied template version in place.
615
+ if (fs.existsSync(abs)) {
616
+ filesToAdapt.add(posix);
617
+ replaced++;
618
+ }
619
+ continue;
620
+ }
572
621
 
573
- // base-standards.mdc
574
- const baseStdPath = path.join(dest, 'ai-specs', 'specs', 'base-standards.mdc');
575
- if (fs.existsSync(baseStdPath)) {
576
- const existing = fs.readFileSync(baseStdPath, 'utf8');
577
- const template = fs.readFileSync(path.join(templateDir, 'ai-specs', 'specs', 'base-standards.mdc'), 'utf8');
578
- const fresh = adaptBaseStandards(template, scan, config);
579
- if (isStandardModified(existing, fresh)) {
580
- standardsResults.push({ name: 'ai-specs/specs/base-standards.mdc', modified: true });
581
- preserved++;
582
- } else {
583
- fs.writeFileSync(baseStdPath, fresh, 'utf8');
584
- standardsResults.push({ name: 'ai-specs/specs/base-standards.mdc', modified: false });
622
+ if (config.forceTemplate) {
623
+ // --force-template: keep the fresh template, back up the old content.
624
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
625
+ filesToAdapt.add(posix);
585
626
  replaced++;
627
+ continue;
586
628
  }
587
- }
588
629
 
589
- // backend-standards.mdc
590
- if (projectType !== 'frontend') {
591
- const backendStdPath = path.join(dest, 'ai-specs', 'specs', 'backend-standards.mdc');
592
- if (fs.existsSync(backendStdPath)) {
593
- const existing = fs.readFileSync(backendStdPath, 'utf8');
594
- const template = fs.readFileSync(path.join(templateDir, 'ai-specs', 'specs', 'backend-standards.mdc'), 'utf8');
595
- const fresh = adaptBackendStandards(template, scan);
596
- if (isStandardModified(existing, fresh)) {
597
- standardsResults.push({ name: 'ai-specs/specs/backend-standards.mdc', modified: true });
598
- preserved++;
599
- } else {
600
- fs.writeFileSync(backendStdPath, fresh, 'utf8');
601
- standardsResults.push({ name: 'ai-specs/specs/backend-standards.mdc', modified: false });
630
+ const freshContent = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
631
+ if (freshContent === null) {
632
+ // Template no longer ships this file (should not happen in v0.17.1)
633
+ continue;
634
+ }
635
+
636
+ const storedHash = meta && meta.hashes[posix];
637
+ const backupHash = computeHash(backup);
638
+
639
+ if (storedHash) {
640
+ // Case 2: hash-based path.
641
+ if (backupHash === storedHash) {
642
+ // Pristine → keep the fresh copy, back up the pre-upgrade version.
643
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
644
+ filesToAdapt.add(posix);
602
645
  replaced++;
646
+ continue;
603
647
  }
648
+ // Hash mismatch → customized → restore backup + write .new with
649
+ // adapted target so user can diff against canonical v0.17.1 output.
650
+ // v0.17.1 round-3: the .new backup must mirror what init would have
651
+ // produced (stack rules + project-type rules), so the user can diff
652
+ // apples-to-apples against their customized file.
653
+ const stackTarget = applyStackAdaptationsToContent(freshContent, posix, scan, config);
654
+ const adaptedTarget = adaptWorkflowCoreContentForProjectType(stackTarget, posix, projectType);
655
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
656
+ fs.writeFileSync(abs, backup, 'utf8');
657
+ const newBackupPath = path.join(
658
+ dest,
659
+ '.sdd-backup',
660
+ backupTimestamp,
661
+ `${relativePath}.new`
662
+ );
663
+ try {
664
+ fs.mkdirSync(path.dirname(newBackupPath), { recursive: true });
665
+ fs.writeFileSync(newBackupPath, adaptedTarget, 'utf8');
666
+ } catch (e) {
667
+ console.warn(` ⚠ Failed to write .new backup for ${relativePath}: ${e.code || e.message}`);
668
+ }
669
+ modifiedAgentsResults.push({ name: relativePath, modified: true });
670
+ preserved++;
671
+ continue;
672
+ }
673
+
674
+ // Case 3: fallback path — no stored hash (pre-v0.17.1 project). Compare
675
+ // backup against the FULLY adapted template target (stack rules +
676
+ // project-type rules) OR the raw template content. Both are valid
677
+ // "pristine" states for a pre-v0.17.1 project:
678
+ // (a) adapted: --init ran applyStackAdaptations AND
679
+ // adaptAgentContentForProjectType at install time (full adapted)
680
+ // (b) raw: generator.js scaffold copied template without running adapters
681
+ // v0.17.1 round-3: BOTH stack rules and project-type rules must be
682
+ // applied to the comparison target. Previously only stack rules were
683
+ // applied, which caused false-positive preserve on single-stack projects
684
+ // (scenario 55 regression guard).
685
+ const stackTarget = applyStackAdaptationsToContent(freshContent, posix, scan, config);
686
+ const adaptedTarget = adaptWorkflowCoreContentForProjectType(stackTarget, posix, projectType);
687
+ if (
688
+ normalizedContentEquals(backup, adaptedTarget) ||
689
+ normalizedContentEquals(backup, freshContent)
690
+ ) {
691
+ // Pristine per content compare → keep the fresh copy.
692
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
693
+ filesToAdapt.add(posix);
694
+ replaced++;
695
+ continue;
604
696
  }
697
+ // Customized → restore backup + write .new with adapted target.
698
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
699
+ fs.writeFileSync(abs, backup, 'utf8');
700
+ const newBackupPath = path.join(
701
+ dest,
702
+ '.sdd-backup',
703
+ backupTimestamp,
704
+ `${relativePath}.new`
705
+ );
706
+ try {
707
+ fs.mkdirSync(path.dirname(newBackupPath), { recursive: true });
708
+ fs.writeFileSync(newBackupPath, adaptedTarget, 'utf8');
709
+ } catch (e) {
710
+ console.warn(` ⚠ Failed to write .new backup for ${relativePath}: ${e.code || e.message}`);
711
+ }
712
+ modifiedAgentsResults.push({ name: relativePath, modified: true });
713
+ preserved++;
605
714
  }
606
715
 
607
- // frontend-standards.mdc
608
- if (projectType !== 'backend') {
609
- const frontendStdPath = path.join(dest, 'ai-specs', 'specs', 'frontend-standards.mdc');
610
- if (fs.existsSync(frontendStdPath)) {
611
- const existing = fs.readFileSync(frontendStdPath, 'utf8');
612
- const template = fs.readFileSync(path.join(templateDir, 'ai-specs', 'specs', 'frontend-standards.mdc'), 'utf8');
613
- const fresh = adaptFrontendStandards(template, scan);
614
- if (isStandardModified(existing, fresh)) {
615
- standardsResults.push({ name: 'ai-specs/specs/frontend-standards.mdc', modified: true });
616
- preserved++;
617
- } else {
618
- fs.writeFileSync(frontendStdPath, fresh, 'utf8');
619
- standardsResults.push({ name: 'ai-specs/specs/frontend-standards.mdc', modified: false });
716
+ // --- d) Handle standards (smart diff) ---
717
+ const standardsResults = [];
718
+
719
+ // v0.17.1: hash decision tree for all 4 standards.
720
+ //
721
+ // Each standard uses its own adapter (adaptBaseStandards, adaptBackendStandards,
722
+ // adaptFrontendStandards, or — for documentation-standards — the imperative
723
+ // branch in applyStackAdaptationsToContent). The decision tree is uniform:
724
+ // 1. Missing or --force-template unconditional write + hash update
725
+ // 2. Stored hash match → pristine, replace + hash update
726
+ // 3. Stored hash mismatch → customized, preserve + .new backup, NO hash update
727
+ // 4. No stored hash → fallback compare against adapted target (normalizedContentEquals)
728
+ //
729
+ // Replaces v0.17.0's isStandardModified main-path compare (deleted in this commit).
730
+ // Standards are added to filesToAdapt when replaced, so their hashes get
731
+ // computed at the end of the upgrade via the filesToAdapt loop.
732
+ const standardsSpecs = [
733
+ {
734
+ posix: 'ai-specs/specs/base-standards.mdc',
735
+ relTemplate: ['ai-specs', 'specs', 'base-standards.mdc'],
736
+ // v0.17.1 round-3: init applies project-type rules via
737
+ // adaptAgentContentForProjectType AFTER adaptBaseStandards, so the
738
+ // comparison target must include both layers to avoid false-positive
739
+ // preserve on single-stack upgrades (scenario 55 regression guard).
740
+ adapter: (tpl) => adaptBaseStandardsContentForProjectType(
741
+ adaptBaseStandards(tpl, scan, config),
742
+ projectType
743
+ ),
744
+ include: true,
745
+ },
746
+ {
747
+ posix: 'ai-specs/specs/backend-standards.mdc',
748
+ relTemplate: ['ai-specs', 'specs', 'backend-standards.mdc'],
749
+ adapter: (tpl) => adaptBackendStandards(tpl, scan),
750
+ include: projectType !== 'frontend',
751
+ },
752
+ {
753
+ posix: 'ai-specs/specs/frontend-standards.mdc',
754
+ relTemplate: ['ai-specs', 'specs', 'frontend-standards.mdc'],
755
+ adapter: (tpl) => adaptFrontendStandards(tpl, scan),
756
+ include: projectType !== 'backend',
757
+ },
758
+ {
759
+ posix: 'ai-specs/specs/documentation-standards.mdc',
760
+ relTemplate: ['ai-specs', 'specs', 'documentation-standards.mdc'],
761
+ // documentation-standards has no dedicated adapter; stack-adaptations.js
762
+ // handles project-type pruning via its imperative branch at the end of
763
+ // applyStackAdaptations. For the adapted target used in fallback
764
+ // content-compare here, we apply it in-memory via
765
+ // applyStackAdaptationsToContent (same helper, different invocation).
766
+ adapter: (tpl) => applyStackAdaptationsToContent(tpl, 'ai-specs/specs/documentation-standards.mdc', scan, config),
767
+ include: true,
768
+ },
769
+ ];
770
+
771
+ for (const spec of standardsSpecs) {
772
+ if (!spec.include) continue;
773
+ const absPath = path.join(dest, ...spec.posix.split('/'));
774
+ const templatePath = path.join(templateDir, ...spec.relTemplate);
775
+ if (!fs.existsSync(templatePath)) continue;
776
+ const template = fs.readFileSync(templatePath, 'utf8');
777
+ const freshAdapted = spec.adapter(template);
778
+ const relativePath = path.relative(dest, absPath);
779
+
780
+ if (!fs.existsSync(absPath)) {
781
+ // Missing → unconditional write
782
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
783
+ fs.writeFileSync(absPath, freshAdapted, 'utf8');
784
+ filesToAdapt.add(spec.posix);
785
+ standardsResults.push({ name: spec.posix, modified: false });
786
+ replaced++;
787
+ continue;
788
+ }
789
+
790
+ if (config.forceTemplate) {
791
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
792
+ fs.writeFileSync(absPath, freshAdapted, 'utf8');
793
+ filesToAdapt.add(spec.posix);
794
+ standardsResults.push({ name: spec.posix, modified: false });
795
+ replaced++;
796
+ continue;
797
+ }
798
+
799
+ const existing = fs.readFileSync(absPath, 'utf8');
800
+ const storedHash = meta && meta.hashes[spec.posix];
801
+
802
+ const preserveStandard = () => {
803
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
804
+ const newBackupPath = path.join(dest, '.sdd-backup', backupTimestamp, `${relativePath}.new`);
805
+ try {
806
+ fs.mkdirSync(path.dirname(newBackupPath), { recursive: true });
807
+ fs.writeFileSync(newBackupPath, freshAdapted, 'utf8');
808
+ } catch (e) {
809
+ console.warn(` ⚠ Failed to write .new backup for ${relativePath}: ${e.code || e.message}`);
810
+ }
811
+ standardsResults.push({ name: spec.posix, modified: true });
812
+ preserved++;
813
+ // Codex M1 invariant: do NOT update newHashes[spec.posix] — the
814
+ // inherited hash (if any) persists untouched for preserved files.
815
+ };
816
+
817
+ if (storedHash) {
818
+ // Case 2: hash-based path.
819
+ const currentHash = computeHash(existing);
820
+ if (currentHash === storedHash) {
821
+ // Pristine → replace with adapted target.
822
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
823
+ fs.writeFileSync(absPath, freshAdapted, 'utf8');
824
+ filesToAdapt.add(spec.posix);
825
+ standardsResults.push({ name: spec.posix, modified: false });
620
826
  replaced++;
827
+ continue;
621
828
  }
829
+ // Hash mismatch → preserve.
830
+ preserveStandard();
831
+ continue;
622
832
  }
623
- }
624
833
 
625
- // documentation-standards.mdc always replace (unlikely customized, no adaptation)
626
- const docStdSrc = path.join(templateDir, 'ai-specs', 'specs', 'documentation-standards.mdc');
627
- const docStdDest = path.join(dest, 'ai-specs', 'specs', 'documentation-standards.mdc');
628
- if (fs.existsSync(docStdSrc)) {
629
- fs.copyFileSync(docStdSrc, docStdDest);
630
- replaced++;
834
+ // Case 3: fallback content-compare (no stored hash, pre-v0.17.1 project).
835
+ //
836
+ // Two acceptable "pristine" states for a pre-v0.17.1 project:
837
+ // (a) existing matches the adapted target — the --init path ran the
838
+ // adapter at install time (existing is adapted content)
839
+ // (b) existing matches the RAW template content — the generator.js
840
+ // scaffold path copied the template without running the adapter
841
+ // (existing is raw content)
842
+ //
843
+ // Without case (b), every fresh-scaffolded v0.17.0 project upgrading to
844
+ // v0.17.1 would false-positive-preserve all 4 standards on first upgrade
845
+ // (the template content on disk would not match the adapter output).
846
+ if (
847
+ normalizedContentEquals(existing, freshAdapted) ||
848
+ normalizedContentEquals(existing, template)
849
+ ) {
850
+ backupBeforeReplace(dest, relativePath, backupTimestamp);
851
+ fs.writeFileSync(absPath, freshAdapted, 'utf8');
852
+ filesToAdapt.add(spec.posix);
853
+ standardsResults.push({ name: spec.posix, modified: false });
854
+ replaced++;
855
+ continue;
856
+ }
857
+ preserveStandard();
631
858
  }
632
859
 
633
860
  step('Updated standards files');
@@ -827,18 +1054,15 @@ function generateUpgrade(config) {
827
1054
  // mangled by the rule replacements (Codex M1 + plan v1.1 § Allowlist
828
1055
  // semantics).
829
1056
  //
830
- // SKILL.md, ticket-template.md, and documentation-standards.mdc were
831
- // wholesale-recopied earlier in the upgrade (via fs.cpSync and the
832
- // standards pipeline), so they are always in the "replaced" state and
833
- // must be in the allowlist.
834
- for (const dir of toolDirs) {
835
- filesToAdapt.add(toPosix(`${dir}/skills/development-workflow/SKILL.md`));
836
- filesToAdapt.add(
837
- toPosix(`${dir}/skills/development-workflow/references/ticket-template.md`)
838
- );
839
- }
840
- filesToAdapt.add(toPosix('ai-specs/specs/documentation-standards.mdc'));
841
-
1057
+ // v0.17.1: SKILL.md, ticket-template.md, and documentation-standards.mdc
1058
+ // are now smart-diff-protected (c2 block and standards block respectively)
1059
+ // and ARE added to filesToAdapt conditionally on being replaced (NOT
1060
+ // preserved). The pre-v0.17.1 unconditional adds that used to live here
1061
+ // were removed because they violated the Codex M1 invariant when the
1062
+ // new smart-diff blocks preserved a customized file (Gemini round-3
1063
+ // finding 1): the unconditional add would re-apply stack adaptations
1064
+ // to the restored user content, mangling it, and then the hash loop
1065
+ // would overwrite the preserved hash.
842
1066
  applyStackAdaptations(dest, scan, config, filesToAdapt);
843
1067
 
844
1068
  step('Adapted files for project type and stack');
@@ -935,16 +1159,19 @@ function generateUpgrade(config) {
935
1159
  );
936
1160
  }
937
1161
  console.log(
938
- `\n Note: this is EXPECTED on cross-version upgrades (e.g. ${config.installedVersion} → ${newVersion}).`
1162
+ `\n Note: this is expected on the first v0.17.0+ upgrade from a pre-v0.17.0 project`
1163
+ );
1164
+ console.log(
1165
+ ` for files the user had not touched — the fallback content-compare path is`
939
1166
  );
940
1167
  console.log(
941
- ` v0.16.10 uses conservative preserve semantics any file that does not exactly`
1168
+ ` conservative by design. After this upgrade, .sdd-meta.json records the hash of`
942
1169
  );
943
1170
  console.log(
944
- ` match the new template's adapted output is preserved, even if you never edited it.`
1171
+ ` every SDD-managed file. Subsequent upgrades will use hash-based precision and`
945
1172
  );
946
1173
  console.log(
947
- ` Provenance tracking (v0.17.0) will eliminate these false positives.`
1174
+ ` will only warn on files the user actually edited.`
948
1175
  );
949
1176
  console.log(`\n If you have NOT customized these files:`);
950
1177
  console.log(
@@ -972,6 +1199,5 @@ module.exports = {
972
1199
  readAutonomyLevel,
973
1200
  collectCustomAgents,
974
1201
  collectCustomCommands,
975
- isStandardModified,
976
1202
  buildSummary,
977
1203
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-sdd-project",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
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"