@warnyin/sdlc 0.5.2 → 0.6.0

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1 -1
  4. package/bin/cli.mjs +49 -4
  5. package/lib/caps.mjs +45 -45
  6. package/lib/config.mjs +41 -41
  7. package/lib/delta.mjs +227 -227
  8. package/lib/frontmatter.mjs +59 -59
  9. package/lib/glob.mjs +29 -29
  10. package/lib/journal.mjs +128 -0
  11. package/lib/manifest.mjs +99 -99
  12. package/lib/observe.mjs +19 -16
  13. package/lib/settings-merge.mjs +63 -63
  14. package/lib/validate.mjs +196 -196
  15. package/package.json +1 -1
  16. package/payload/adapters/agents-md.md +8 -8
  17. package/payload/adapters/claude/agents/sdlc-architect.md +12 -12
  18. package/payload/adapters/claude/agents/sdlc-builder.md +14 -14
  19. package/payload/adapters/claude/agents/sdlc-contractor.md +13 -13
  20. package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -13
  21. package/payload/adapters/claude/agents/sdlc-learner.md +16 -16
  22. package/payload/adapters/claude/agents/sdlc-ops.md +11 -11
  23. package/payload/adapters/claude/agents/sdlc-quality.md +13 -13
  24. package/payload/adapters/claude/agents/sdlc-security.md +12 -12
  25. package/payload/adapters/claude/commands/sdlc/converge.md +5 -5
  26. package/payload/adapters/claude/commands/sdlc/init.md +4 -4
  27. package/payload/adapters/claude/commands/sdlc/next.md +4 -4
  28. package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
  29. package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
  30. package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
  31. package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +36 -36
  32. package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +2 -1
  33. package/payload/adapters/cline.md +8 -8
  34. package/payload/adapters/copilot.md +8 -8
  35. package/payload/adapters/cursor.mdc +7 -7
  36. package/payload/adapters/gemini.md +8 -8
  37. package/payload/adapters/windsurf.md +4 -4
  38. package/payload/hooks/_shared.mjs +150 -154
  39. package/payload/hooks/guard-writes.mjs +83 -83
  40. package/payload/hooks/inject-context.mjs +55 -55
  41. package/payload/hooks/journal.mjs +58 -58
  42. package/payload/hooks/session-summary.mjs +50 -50
  43. package/payload/hooks/validate-artifact.mjs +80 -80
  44. package/payload/playbook/auto.md +12 -0
  45. package/payload/playbook/context.md +26 -26
  46. package/payload/playbook/converge.md +19 -19
  47. package/payload/playbook/init.md +22 -22
  48. package/payload/playbook/observe.md +20 -20
  49. package/payload/playbook/principles.md +28 -28
  50. package/payload/playbook/routing.md +19 -19
  51. package/payload/playbook/rules-card.md +16 -16
  52. package/payload/playbook/ship.md +35 -35
  53. package/payload/playbook/steer.md +21 -21
  54. package/payload/templates/change-deep.md +29 -29
  55. package/payload/templates/change-standard.md +28 -28
  56. package/payload/templates/change-vibe.md +19 -19
  57. package/payload/templates/config.yaml +8 -8
  58. package/payload/templates/constitution.md +14 -14
  59. package/payload/templates/contract-evals.md +9 -9
  60. package/payload/templates/contract-tests.md +9 -9
  61. package/payload/templates/harness.md +33 -33
  62. package/payload/templates/spec.md +14 -14
  63. package/payload/templates/steering.md +9 -9
  64. package/scripts/validate.mjs +47 -47
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 (2026-09-10)
4
+
5
+ - **Fix (auto)**: the Confirm step of an unattended run showed the scope it had settled on
6
+ and asked for approval, but never how it got there — so a derivation that searched the
7
+ wrong thing was approved as readily as a right one. It now shows, per scope item, the
8
+ command that established it and what that command returned; a summary of what a search
9
+ found does not count. Evidence that searched a term the request did not name is flagged
10
+ with both terms side by side, a narrowing the request never asked for (a folder pattern,
11
+ a naming convention) becomes its own refusable item, and an exclusion made on an empty
12
+ result must name the pattern searched — finding nothing is a claim about the pattern, not
13
+ a fact about the candidate. This is doctrine the model follows, checked by tests on the
14
+ doctrine and by the eval rubric; the confirmation is written at runtime, so nothing gates
15
+ it mechanically. (#2)
16
+ - **Fix (journal)**: telemetry the hooks append lived in `sdlc/changes/<id>/journal.ndjson`,
17
+ which git tracks, so merely opening a project modified a shared file no human touched —
18
+ `git pull` and branch switches refused to move until someone discarded it, and two people
19
+ on one change conflicted on the appended tail for a reason unrelated to the change under
20
+ review. While a change is open, telemetry now goes to `sdlc/.state/journal/<id>.ndjson`;
21
+ `.state/` is already git-ignored in every installed project, so no new `.gitignore` entry
22
+ and no `git rm --cached` is needed. `archive` seals the journal into the shipped change
23
+ folder in one write at ship, so `/sdlc:observe` still reports cost and verify history for
24
+ changes a teammate shipped. Projects installed before this keep their in-tree journal: it
25
+ is read alongside the new stream and consumed at ship, so no recorded event is lost.
26
+ Two interim states worth knowing: a project that runs `update` mid-change carries telemetry
27
+ split across the two files until that change ships, and downgrading to 0.5.2 afterwards
28
+ leaves anything under `.state/journal/` unread by the older code — it is still on disk,
29
+ but that version does not know to look there. (#3)
30
+
3
31
  ## 0.5.2 (2026-08-25)
4
32
 
5
33
  - **Fix (delta)**: a `### MODIFIED Requirement:` body replaces the requirement wholesale,
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 warnyin
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 warnyin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -47,7 +47,7 @@ sdlc/
47
47
  ├── context/steering/*.md scoped knowledge · inclusion: always|paths|manual|agent
48
48
  ├── harness.md tools, model routing, tier triage, autonomy policy
49
49
  ├── specs/<capability>/ living specs — WHEN/THEN SHALL, merged mechanically at ship
50
- ├── changes/<id>/ one change: change.md (Why+Delta+Tasks) + contract/ + journal
50
+ ├── changes/<id>/ one change: change.md (Why+Delta+Tasks) + contract/
51
51
  └── changes/archive/ shipped changes + digests (the async human touchpoint)
52
52
  ```
53
53
 
package/bin/cli.mjs CHANGED
@@ -16,6 +16,10 @@ import { mergeHookSettings } from '../lib/settings-merge.mjs';
16
16
  import { buildReport, renderReport } from '../lib/observe.mjs';
17
17
  import { parseManifest, renderManifest, computeStale, containedIn, hasSymlinkSegment, PRUNE_BLAST_CAP } from '../lib/manifest.mjs';
18
18
  import { validateAll, formatIssues, listChangeDirs } from '../lib/validate.mjs';
19
+ import {
20
+ readChangeJournal, liveJournalPath, sealedJournalPath, serializeJournal, appendEvent,
21
+ isSafeChangeId,
22
+ } from '../lib/journal.mjs';
19
23
  import { detectTools, toolName } from './detect.mjs';
20
24
  import { colorEnabled, createStyle, symbolsFor, summarizeInstall, startHints } from './ui.mjs';
21
25
  import { multiSelect } from './multiselect.mjs';
@@ -358,6 +362,11 @@ export function cmdUpdate(projectRoot, args) {
358
362
  const ctx = { mode: 'update', manifest: new Map(), oldManifest, warnings: [] };
359
363
  scaffoldSdlc(projectRoot, tools, ctx);
360
364
  installToolAdapters(projectRoot, tools, ctx);
365
+ // `update` is how an existing project acquires hooks that journal under .state/, and
366
+ // that whole design rests on the entry being there. Re-assert it: a project whose
367
+ // .gitignore never had it, or lost it, would otherwise start reporting telemetry as
368
+ // untracked noise — the same symptom in a subtler form.
369
+ ensureGitignore(projectRoot);
361
370
 
362
371
  // Prune: old-manifest entries no longer in the payload, guarded six ways.
363
372
  const { stale, rejected, overCap } = computeStale(oldManifest, new Set(ctx.manifest.keys()));
@@ -440,15 +449,25 @@ export function cmdObserve(projectRoot, { json = false } = {}) {
440
449
 
441
450
  // ---------- archive (= mechanical part of ship) ----------
442
451
 
443
- export function appendJournal(changeDir, event) {
444
- const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
445
- fs.appendFileSync(path.join(changeDir, 'journal.ndjson'), line + '\n');
452
+ // The CLI's own events go to the same out-of-tree stream the hooks append to, so the
453
+ // ship event does not become the one write that dirties the tree.
454
+ export function appendJournal(sdlcRoot, changeId, event) {
455
+ const target = liveJournalPath(sdlcRoot, changeId);
456
+ if (!target) return;
457
+ appendEvent(target, { ts: new Date().toISOString(), ...event });
446
458
  }
447
459
 
448
460
  export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
449
461
  const sdlcRoot = path.join(projectRoot, 'sdlc');
450
462
  requireSdlc(sdlcRoot);
451
463
  if (!changeId) throw new Error('usage: warnyin-sdlc archive <change-id>');
464
+ // Refuse before anything is read or written. An id like `a/../b` resolves to a real
465
+ // folder, so without this it would ship — merging specs and moving the folder — and
466
+ // only then fail on the journal paths that do gate the id, reporting a completed
467
+ // ship as an error.
468
+ if (!isSafeChangeId(changeId)) {
469
+ throw new Error(`"${changeId}" is not a valid change id — one path segment, no separators`);
470
+ }
452
471
  const changeDir = path.join(sdlcRoot, 'changes', changeId);
453
472
  if (!fs.existsSync(changeDir)) throw new Error(`change "${changeId}" not found`);
454
473
 
@@ -510,10 +529,36 @@ export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
510
529
 
511
530
  const stamped = changeText.replace(/^status:\s*.*$/m, 'status: shipped');
512
531
  writeFileNormalized(path.join(changeDir, 'change.md'), stamped);
513
- appendJournal(changeDir, { event: 'ship', change: changeId, specs: merged.map((m) => m.capability) });
532
+ appendJournal(sdlcRoot, changeId, { event: 'ship', change: changeId, specs: merged.map((m) => m.capability) });
533
+
534
+ // Telemetry stays out of the tree for the whole life of the change and becomes
535
+ // tracked exactly once — here, in the ship commit — so no session can dirty it and no
536
+ // appended tail can conflict.
537
+ //
538
+ // Read before the move, write after it. For an open change `sealedJournalPath` and
539
+ // `legacyJournalPath` are the SAME file, so sealing first would leave the merged
540
+ // union sitting at the legacy path if the rename then failed (EPERM/EBUSY on Windows
541
+ // is the realistic way); the retry would merge that union with the still-present live
542
+ // stream and double every event. Reading first and writing into `destDir` means a
543
+ // failed rename has consumed nothing.
544
+ const sealed = readChangeJournal(sdlcRoot, changeId);
514
545
 
515
546
  fs.renameSync(changeDir, destDir);
516
547
 
548
+ // Past the point of no return: specs are merged and the folder has moved. Nothing
549
+ // below may throw, or a completed ship reports as a failure and the human retries
550
+ // into "change not found".
551
+ try {
552
+ // Empty only if the id was never journalled at all — the ship event above normally
553
+ // guarantees at least one entry. An empty file would be worse than none.
554
+ if (sealed.length) {
555
+ writeFileNormalized(sealedJournalPath(destDir), serializeJournal(sealed));
556
+ }
557
+ fs.rmSync(liveJournalPath(sdlcRoot, changeId), { force: true });
558
+ } catch (err) {
559
+ console.error(`⚠ shipped, but the journal was not fully sealed: ${err.message}`);
560
+ }
561
+
517
562
  console.log(`shipped: ${changeId}`);
518
563
  for (const m of merged) console.log(` spec merged: specs/${m.capability}/spec.md`);
519
564
  if (driftWarnings.length) {
package/lib/caps.mjs CHANGED
@@ -1,45 +1,45 @@
1
- // Canonical line-cap table — the single source of truth for artifact
2
- // residency budgets. Templates quote these numbers in HTML comments and
3
- // tests/caps-sync.test.mjs asserts both stay identical.
4
- //
5
- // Rationale (Day-1 economics): every resident line is paid for in every
6
- // turn. Caps make "learning = distilling" structural, not disciplinary.
7
-
8
- import { parseFrontmatter } from './frontmatter.mjs';
9
-
10
- export const CAPS = Object.freeze({
11
- constitution: 30, // sdlc/context/constitution.md — always loaded
12
- steeringFile: 40, // each sdlc/context/steering/*.md
13
- alwaysBudget: 60, // constitution + all `inclusion: always` steering, combined
14
- harness: 60, // sdlc/harness.md
15
- spec: 150, // sdlc/specs/<capability>/spec.md (soft — split capability beyond)
16
- change: Object.freeze({ vibe: 40, standard: 100, deep: 150 }),
17
- contractTests: 60, // changes/<id>/contract/tests.md
18
- contractEvals: 40, // changes/<id>/contract/evals.md
19
- });
20
-
21
- export const TIERS = Object.freeze(['vibe', 'standard', 'deep']);
22
-
23
- export const STATUSES = Object.freeze([
24
- 'new', 'contracted', 'building', 'verified', 'shipped',
25
- ]);
26
-
27
- // Effective lines = body lines after frontmatter, excluding blanks and
28
- // single-line HTML comments. Caps meter prose the model must carry, not
29
- // machine metadata or annotation comments.
30
- export function countEffectiveLines(text) {
31
- const { body } = parseFrontmatter(text ?? '');
32
- return body
33
- .split(/\r?\n/)
34
- .filter((line) => {
35
- const t = line.trim();
36
- if (t === '') return false;
37
- if (t.startsWith('<!--') && t.endsWith('-->')) return false;
38
- return true;
39
- })
40
- .length;
41
- }
42
-
43
- export function capForChange(tier) {
44
- return CAPS.change[tier] ?? CAPS.change.standard;
45
- }
1
+ // Canonical line-cap table — the single source of truth for artifact
2
+ // residency budgets. Templates quote these numbers in HTML comments and
3
+ // tests/caps-sync.test.mjs asserts both stay identical.
4
+ //
5
+ // Rationale (Day-1 economics): every resident line is paid for in every
6
+ // turn. Caps make "learning = distilling" structural, not disciplinary.
7
+
8
+ import { parseFrontmatter } from './frontmatter.mjs';
9
+
10
+ export const CAPS = Object.freeze({
11
+ constitution: 30, // sdlc/context/constitution.md — always loaded
12
+ steeringFile: 40, // each sdlc/context/steering/*.md
13
+ alwaysBudget: 60, // constitution + all `inclusion: always` steering, combined
14
+ harness: 60, // sdlc/harness.md
15
+ spec: 150, // sdlc/specs/<capability>/spec.md (soft — split capability beyond)
16
+ change: Object.freeze({ vibe: 40, standard: 100, deep: 150 }),
17
+ contractTests: 60, // changes/<id>/contract/tests.md
18
+ contractEvals: 40, // changes/<id>/contract/evals.md
19
+ });
20
+
21
+ export const TIERS = Object.freeze(['vibe', 'standard', 'deep']);
22
+
23
+ export const STATUSES = Object.freeze([
24
+ 'new', 'contracted', 'building', 'verified', 'shipped',
25
+ ]);
26
+
27
+ // Effective lines = body lines after frontmatter, excluding blanks and
28
+ // single-line HTML comments. Caps meter prose the model must carry, not
29
+ // machine metadata or annotation comments.
30
+ export function countEffectiveLines(text) {
31
+ const { body } = parseFrontmatter(text ?? '');
32
+ return body
33
+ .split(/\r?\n/)
34
+ .filter((line) => {
35
+ const t = line.trim();
36
+ if (t === '') return false;
37
+ if (t.startsWith('<!--') && t.endsWith('-->')) return false;
38
+ return true;
39
+ })
40
+ .length;
41
+ }
42
+
43
+ export function capForChange(tier) {
44
+ return CAPS.change[tier] ?? CAPS.change.standard;
45
+ }
package/lib/config.mjs CHANGED
@@ -1,41 +1,41 @@
1
- // Minimal reader for sdlc/config.yaml — supports exactly what the template
2
- // documents: scalar keys, inline arrays, and a `prices:` block of inline
3
- // objects. Anything else is ignored (never crash a hook on config).
4
-
5
- export function parseConfig(text) {
6
- const config = { language: 'en', tools: [], prices: null };
7
- const lines = (text ?? '').split(/\r?\n/);
8
- let inPrices = false;
9
- for (const raw of lines) {
10
- if (!raw.trim() || raw.trim().startsWith('#')) continue;
11
- const isIndented = /^\s/.test(raw);
12
- if (!isIndented) inPrices = false;
13
-
14
- if (inPrices) {
15
- const m = raw.match(/^\s+([^:#]+):\s*\{(.*)\}\s*$/);
16
- if (!m) continue;
17
- const model = m[1].trim();
18
- const obj = {};
19
- for (const part of m[2].split(',')) {
20
- const kv = part.split(':');
21
- if (kv.length !== 2) continue;
22
- const num = Number(kv[1].trim());
23
- if (!Number.isNaN(num)) obj[kv[0].trim()] = num;
24
- }
25
- config.prices = { ...(config.prices ?? {}), [model]: obj };
26
- continue;
27
- }
28
-
29
- const kv = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
30
- if (!kv) continue;
31
- const [, key, valueRaw] = kv;
32
- const value = valueRaw.replace(/\s+#.*$/, '').trim();
33
- if (key === 'prices' && value === '') { inPrices = true; continue; }
34
- if (value.startsWith('[') && value.endsWith(']')) {
35
- config[key] = value.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
36
- } else if (value !== '') {
37
- config[key] = value;
38
- }
39
- }
40
- return config;
41
- }
1
+ // Minimal reader for sdlc/config.yaml — supports exactly what the template
2
+ // documents: scalar keys, inline arrays, and a `prices:` block of inline
3
+ // objects. Anything else is ignored (never crash a hook on config).
4
+
5
+ export function parseConfig(text) {
6
+ const config = { language: 'en', tools: [], prices: null };
7
+ const lines = (text ?? '').split(/\r?\n/);
8
+ let inPrices = false;
9
+ for (const raw of lines) {
10
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
11
+ const isIndented = /^\s/.test(raw);
12
+ if (!isIndented) inPrices = false;
13
+
14
+ if (inPrices) {
15
+ const m = raw.match(/^\s+([^:#]+):\s*\{(.*)\}\s*$/);
16
+ if (!m) continue;
17
+ const model = m[1].trim();
18
+ const obj = {};
19
+ for (const part of m[2].split(',')) {
20
+ const kv = part.split(':');
21
+ if (kv.length !== 2) continue;
22
+ const num = Number(kv[1].trim());
23
+ if (!Number.isNaN(num)) obj[kv[0].trim()] = num;
24
+ }
25
+ config.prices = { ...(config.prices ?? {}), [model]: obj };
26
+ continue;
27
+ }
28
+
29
+ const kv = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
30
+ if (!kv) continue;
31
+ const [, key, valueRaw] = kv;
32
+ const value = valueRaw.replace(/\s+#.*$/, '').trim();
33
+ if (key === 'prices' && value === '') { inPrices = true; continue; }
34
+ if (value.startsWith('[') && value.endsWith(']')) {
35
+ config[key] = value.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
36
+ } else if (value !== '') {
37
+ config[key] = value;
38
+ }
39
+ }
40
+ return config;
41
+ }