@warnyin/sdlc 0.14.0 → 0.14.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1 (2026-09-19)
4
+
5
+ - **Fix (init)**: adding a tool to a project that already has `sdlc/` no longer loses it on the
6
+ next update. `init --tool <newtool>` installed the tool's files but never recorded it in
7
+ `sdlc/config.yaml`'s `tools:` line, so the next plain `update` read that stale list and
8
+ **silently pruned the files it had just installed**. `init` now records what it installs,
9
+ added to whatever was already listed — never removing an entry, because `init` has no prune
10
+ capability and this does not give it one. Only `update --tool <list>` still replaces the list
11
+ wholesale and prunes what is left out. The install summary says `(recorded <tools>)` when the
12
+ list actually grew, and only then. A `config.yaml` carrying no `tools:` line at all is still
13
+ left alone, exactly as `update` already leaves it.
14
+ - **Fix (config)**: the `tools:` line rewrite — now shared by `init` and `update` instead of
15
+ living inline in one of them — passes its replacement as a function rather than a string, so
16
+ a tool name containing `$&`, `` $` `` or `$'` in a hand-edited config is written literally
17
+ instead of being interpreted by `String.replace` and mangling the surrounding file.
18
+
3
19
  ## 0.14.0 (2026-09-19)
4
20
 
5
21
  - **Feature (init)**: `kimi` (Kimi Code CLI) is now a supported tool. Selecting or detecting it
package/bin/cli.mjs CHANGED
@@ -312,7 +312,7 @@ export async function resolveTools(args, {
312
312
  return picked;
313
313
  }
314
314
 
315
- function printInitSummary(tools, ctx, style, symbols, { configExisted }) {
315
+ function printInitSummary(tools, ctx, style, symbols, { configExisted, toolsAdded }) {
316
316
  const s = summarizeInstall(ctx.manifest.keys(), tools);
317
317
  const stats = ctx.stats;
318
318
  const line = (text) => console.log(` ${text}`);
@@ -329,7 +329,10 @@ function printInitSummary(tools, ctx, style, symbols, { configExisted }) {
329
329
  }
330
330
  line(`${s.hooks} hooks in sdlc/.hooks/`);
331
331
  line(`Playbook: sdlc/.playbook/ (${s.playbook} stages + ${s.templates} templates)`);
332
- line(`Config: sdlc/config.yaml${configExisted ? ' (kept)' : ''}`);
332
+ const configNote = toolsAdded?.length
333
+ ? ` (recorded ${toolsAdded.map(toolName).join(', ')})`
334
+ : configExisted ? ' (kept)' : '';
335
+ line(`Config: sdlc/config.yaml${configNote}`);
333
336
  line(style.dim(`Files: ${stats.written} written · ${stats.current} unchanged · ${stats.updated} refreshed · ${stats.kept} kept (yours)`));
334
337
  console.log('');
335
338
  console.log(` ${style.bold('Getting started:')}`);
@@ -341,7 +344,26 @@ export async function cmdInit(projectRoot, args) {
341
344
  const style = createStyle(colorEnabled());
342
345
  const symbols = symbolsFor();
343
346
  const tools = await resolveTools(args, { projectRoot, style });
344
- const configExisted = fs.existsSync(path.join(projectRoot, 'sdlc', 'config.yaml'));
347
+ const configPath = path.join(projectRoot, 'sdlc', 'config.yaml');
348
+ const configExisted = fs.existsSync(configPath);
349
+
350
+ // config.yaml is a seed — written once, never rewritten wholesale. But its `tools:` line
351
+ // is the one field this command promises to keep current ("filled by `warnyin-sdlc init`"
352
+ // in the template), so a second init adding a tool must not leave it stale: union what's
353
+ // already recorded with what this run installs, never removing an entry (init has no prune
354
+ // capability, and this doesn't give it one — only `update --tool <list>` can shrink the set).
355
+ let toolsAdded = [];
356
+ if (configExisted) {
357
+ const recorded = parseConfig(fs.readFileSync(configPath, 'utf8')).tools;
358
+ const newlySelected = tools.filter((t) => !recorded.includes(t));
359
+ // Only claim "recorded" in the summary when a write actually happened — a config
360
+ // predating the `tools:` key makes persistToolsLine a no-op, and the message must not
361
+ // say otherwise.
362
+ if (newlySelected.length && persistToolsLine(configPath, [...recorded, ...newlySelected])) {
363
+ toolsAdded = newlySelected;
364
+ }
365
+ }
366
+
345
367
  const ctx = {
346
368
  mode: 'install',
347
369
  manifest: new Map(),
@@ -354,7 +376,7 @@ export async function cmdInit(projectRoot, args) {
354
376
  writeManifestFile(projectRoot, ctx.manifest);
355
377
  ensureGitignore(projectRoot);
356
378
  for (const w of ctx.warnings) console.warn(` ${style.yellow(symbols.warn)} ${w}`);
357
- printInitSummary(tools, ctx, style, symbols, { configExisted });
379
+ printInitSummary(tools, ctx, style, symbols, { configExisted, toolsAdded });
358
380
  return { tools };
359
381
  }
360
382
 
@@ -395,6 +417,25 @@ export function forceNeedsAPerson(args, env = process.env, stdin = process.stdin
395
417
  + ' Run it yourself, or set WARNYIN_SDLC_FORCE=1 if this really is automation that meant it.';
396
418
  }
397
419
 
420
+ // Rewrites just the `tools:` line of `config.yaml` in place, leaving every other line
421
+ // untouched — a no-op when the file carries no `tools:` line at all (a config predating the
422
+ // key; matched, not fixed). Shared by `cmdInit` and `cmdUpdate` so the two paths can't drift.
423
+ // Reproduces the existing behavior exactly, trailing inline comment included: the line is
424
+ // replaced wholesale, so a hand-added comment after `tools: [...]` does not survive a rewrite.
425
+ // Returns whether it actually wrote — callers that report "recorded X" to the user must not
426
+ // claim it when this was a no-op (no `tools:` line to rewrite).
427
+ export function persistToolsLine(configPath, tools) {
428
+ const raw = fs.readFileSync(configPath, 'utf8');
429
+ if (!/^tools:/m.test(raw)) return false;
430
+ // A replacer FUNCTION, not a string: `recorded` (unioned in by cmdInit) is free text read
431
+ // back from the project's own config.yaml, not validated against the TOOLS registry the way
432
+ // CLI-sourced tool names are — a string replacement would let `$&`/`$'`/`` $` `` in a
433
+ // hand-edited or corrupted config get interpreted as replacement patterns instead of literal
434
+ // text, silently duplicating or mangling the surrounding file.
435
+ fs.writeFileSync(configPath, raw.replace(/^tools:.*$/m, () => `tools: [${tools.join(', ')}]`));
436
+ return true;
437
+ }
438
+
398
439
  export function cmdUpdate(projectRoot, args) {
399
440
  const refusal = refuseSelfUpdate(projectRoot, PKG_ROOT);
400
441
  if (refusal) throw new Error(refusal);
@@ -414,9 +455,7 @@ export function cmdUpdate(projectRoot, args) {
414
455
  // Persist an explicit --tool override so declared and installed state never
415
456
  // diverge (otherwise pruning tool-specific files leaves config.yaml stale).
416
457
  if (args.toolProvided) {
417
- const configPath = path.join(sdlcRoot, 'config.yaml');
418
- const raw = fs.readFileSync(configPath, 'utf8');
419
- fs.writeFileSync(configPath, raw.replace(/^tools:.*$/m, `tools: [${tools.join(', ')}]`));
458
+ persistToolsLine(path.join(sdlcRoot, 'config.yaml'), tools);
420
459
  }
421
460
 
422
461
  // Read before scaffolding: recordPayloadVersion overwrites version.json with our own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warnyin/sdlc",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Spec-driven, AI-driven SDLC framework — token-lean specs, contract-first changes, autonomous pipeline with managed hooks. Operationalizes the Day-1 'New SDLC with Vibe Coding' work process.",
5
5
  "type": "module",
6
6
  "bin": {