@warnyin/sdlc 0.5.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
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
+
31
+ ## 0.5.2 (2026-08-25)
32
+
33
+ - **Fix (delta)**: a `### MODIFIED Requirement:` body replaces the requirement wholesale,
34
+ so one that carried over only some of the spec's scenarios dropped the rest in silence —
35
+ no error, no warning, `spec merged` printed either way. Both shapes are now reported:
36
+ the scenario name gone from the replacement body, and the name surviving while WHEN/THEN
37
+ clauses it promised have no counterpart (the one a name-level comparison cannot see).
38
+ `archive` prints the report before it writes a byte and counts it in the summary;
39
+ `validate` reports the same at warn level, so the loss is visible while the change folder
40
+ is still readable rather than after ship archived it. A warning, never an error — removing
41
+ a scenario is sometimes the point of the change, and only the silence was ever the bug.
42
+ A reworded clause reports the same as a deleted one: nothing mechanical can tell "said
43
+ better" from "promises less". Cosmetic churn — indentation, bullet marker, clause order,
44
+ heading case, whitespace — is normalized away and never warns. (#1)
45
+
3
46
  ## 0.5.1 (2026-08-25)
4
47
 
5
48
  - **Fix (cost)**: `costUsd()` never charged cache-write tokens, the highest-rate of
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
 
@@ -479,14 +498,22 @@ export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
479
498
 
480
499
  // Phase 1: compute every merge before writing anything (all-or-nothing).
481
500
  const merged = [];
501
+ const driftWarnings = [];
482
502
  for (const d of deltas) {
483
503
  const specPath = path.join(sdlcRoot, 'specs', d.capability, 'spec.md');
484
504
  const specText = fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf8') : null;
485
505
  const result = mergeDelta(specText, d.ops, d.capability);
486
506
  if (!result.ok) throw new Error(`spec merge failed for "${d.capability}": ${result.errors.join('; ')}`);
507
+ driftWarnings.push(...result.warnings);
487
508
  merged.push({ specPath, content: result.content, capability: d.capability });
488
509
  }
489
510
 
511
+ // A MODIFIED body replaces the requirement wholesale, so it can carry away a
512
+ // scenario the spec still promised. That is allowed — but it is said out loud
513
+ // here, while the change folder is still readable, not discovered in a diff
514
+ // after the folder moved under changes/archive/.
515
+ for (const w of driftWarnings) console.error(`⚠ ${w}`);
516
+
490
517
  // Phase 2: write specs, promote evals, stamp status, move to archive.
491
518
  for (const m of merged) writeFileNormalized(m.specPath, m.content);
492
519
 
@@ -502,14 +529,43 @@ export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
502
529
 
503
530
  const stamped = changeText.replace(/^status:\s*.*$/m, 'status: shipped');
504
531
  writeFileNormalized(path.join(changeDir, 'change.md'), stamped);
505
- 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);
506
545
 
507
546
  fs.renameSync(changeDir, destDir);
508
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
+
509
562
  console.log(`shipped: ${changeId}`);
510
563
  for (const m of merged) console.log(` spec merged: specs/${m.capability}/spec.md`);
564
+ if (driftWarnings.length) {
565
+ console.log(` ⚠ ${driftWarnings.length} scenario warning(s) above — re-read the spec diff before pushing`);
566
+ }
511
567
  console.log(` archived: changes/archive/${date}-${changeId}/`);
512
- return { archived: `${date}-${changeId}`, specs: merged.map((m) => m.capability) };
568
+ return { archived: `${date}-${changeId}`, specs: merged.map((m) => m.capability), warnings: driftWarnings };
513
569
  }
514
570
 
515
571
  // ---------- shared ----------
package/lib/delta.mjs CHANGED
@@ -12,12 +12,17 @@
12
12
  //
13
13
  // Requirement heading text is the identity key (OpenSpec convention).
14
14
  // `mergeDelta` applies ops mechanically to a living spec; a missing key on
15
- // MODIFIED/REMOVED is a hard error — never merge silently.
15
+ // MODIFIED/REMOVED is a hard error — never merge silently. MODIFIED replaces
16
+ // the whole body, so it can also drop a scenario the spec still carries: that
17
+ // is legal (removing a promise is sometimes the point) but never silent —
18
+ // `scenarioDrift` reports it and callers surface it as a warning.
16
19
 
17
20
  const DELTA_HEAD = /^## Delta:\s*(.+?)\s*$/;
18
21
  const OP_HEAD = /^### (ADDED|MODIFIED|REMOVED) Requirement:\s*(.+?)\s*$/;
19
22
  const BAD_OP_HEAD = /^### (\w+) Requirement:/;
20
23
  const SPEC_REQ_HEAD = /^### Requirement:\s*(.+?)\s*$/;
24
+ const SCENARIO_HEAD = /^#### Scenario:\s*(.+?)\s*$/;
25
+ const ANY_HEAD = /^#{1,6}\s/;
21
26
 
22
27
  export function parseDelta(changeText) {
23
28
  const lines = (changeText ?? '').split(/\r?\n/);
@@ -127,10 +132,71 @@ export function newSpecPreamble(capability) {
127
132
  ].join('\n');
128
133
  }
129
134
 
135
+ // ---------- scenario drift (what a MODIFIED body silently drops) ----------
136
+
137
+ // A clause is one WHEN/THEN line; compare on normalized text so indentation,
138
+ // bullet marker and trailing whitespace never read as a behavior change.
139
+ function normalizeClause(line) {
140
+ const stripped = line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim();
141
+ if (stripped === '' || /^<!--.*-->$/.test(stripped)) return null;
142
+ return stripped.replace(/\s+/g, ' ');
143
+ }
144
+
145
+ export function parseScenarios(body) {
146
+ const scenarios = [];
147
+ let current = null;
148
+ for (const line of (body ?? '').split(/\r?\n/)) {
149
+ const head = line.match(SCENARIO_HEAD);
150
+ if (head) {
151
+ current = { name: head[1], clauses: [] };
152
+ scenarios.push(current);
153
+ continue;
154
+ }
155
+ if (ANY_HEAD.test(line)) { current = null; continue; }
156
+ if (!current) continue;
157
+ const clause = normalizeClause(line);
158
+ if (clause) current.clauses.push(clause);
159
+ }
160
+ return scenarios;
161
+ }
162
+
163
+ // Two shapes of loss, both invisible in the merged output:
164
+ // dropped — the scenario name is gone from the replacement body
165
+ // weakened — the name survives but clauses the spec stated have no counterpart
166
+ export function scenarioDrift(oldBody, newBody) {
167
+ const after = new Map(parseScenarios(newBody).map((s) => [s.name.toLowerCase(), s]));
168
+ const dropped = [];
169
+ const weakened = [];
170
+ for (const before of parseScenarios(oldBody)) {
171
+ const next = after.get(before.name.toLowerCase());
172
+ if (!next) { dropped.push(before.name); continue; }
173
+ const kept = new Set(next.clauses.map((c) => c.toLowerCase()));
174
+ const lost = before.clauses.filter((c) => !kept.has(c.toLowerCase()));
175
+ if (lost.length) weakened.push({ name: before.name, lost });
176
+ }
177
+ return { dropped, weakened };
178
+ }
179
+
180
+ // One wording, used by both the archiver and the validator.
181
+ export function describeDrift(capability, requirement, drift) {
182
+ const where = `specs/${capability}/spec.md`;
183
+ const messages = [];
184
+ for (const name of drift.dropped) {
185
+ messages.push(`MODIFIED Requirement "${requirement}" drops scenario "${name}" that ${where} still carries`);
186
+ }
187
+ for (const { name, lost } of drift.weakened) {
188
+ const shown = lost.slice(0, 2).map((c) => `"${c}"`).join(', ');
189
+ const rest = lost.length > 2 ? `, +${lost.length - 2} more` : '';
190
+ messages.push(`MODIFIED Requirement "${requirement}" rewrites scenario "${name}": ${lost.length} clause(s) in ${where} have no counterpart in the new body (${shown}${rest})`);
191
+ }
192
+ return messages;
193
+ }
194
+
130
195
  // Apply one capability's ops to a living spec (or null to create it).
131
- // Returns { ok, content, errors }.
196
+ // Returns { ok, content, errors, warnings }.
132
197
  export function mergeDelta(specText, ops, capability) {
133
198
  const errors = [];
199
+ const warnings = [];
134
200
  const spec = specText == null
135
201
  ? { preamble: newSpecPreamble(capability), requirements: [] }
136
202
  : parseSpec(specText);
@@ -147,6 +213,7 @@ export function mergeDelta(specText, ops, capability) {
147
213
  byName.set(key, req);
148
214
  } else if (op === 'MODIFIED') {
149
215
  if (!existing) { errors.push(`MODIFIED Requirement "${name}" not found in spec "${capability}"`); continue; }
216
+ warnings.push(...describeDrift(capability, name, scenarioDrift(existing.body, body)));
150
217
  existing.body = body;
151
218
  } else if (op === 'REMOVED') {
152
219
  if (!existing) { errors.push(`REMOVED Requirement "${name}" not found in spec "${capability}"`); continue; }
@@ -155,6 +222,6 @@ export function mergeDelta(specText, ops, capability) {
155
222
  }
156
223
  }
157
224
 
158
- if (errors.length) return { ok: false, content: null, errors };
159
- return { ok: true, content: renderSpec(spec), errors: [] };
225
+ if (errors.length) return { ok: false, content: null, errors, warnings };
226
+ return { ok: true, content: renderSpec(spec), errors: [], warnings };
160
227
  }
@@ -0,0 +1,128 @@
1
+ // Journal residency — where a change's telemetry lives, and how the two possible
2
+ // streams are read back as one.
3
+ //
4
+ // Telemetry is appended by hooks on their own schedule, so it must never live in a
5
+ // version-controlled file: a session would dirty the tree by merely running, and two
6
+ // people on one change would conflict on the appended tail. While a change is open it
7
+ // goes to `sdlc/.state/journal/<id>.ndjson` — `.state/` is gitignored in every
8
+ // installed project — and `archive` seals it into the shipped change folder, one
9
+ // write, at ship. Projects installed before this carry a legacy in-tree journal; it is
10
+ // still read, and consumed at ship.
11
+ //
12
+ // Shared by the CLI, the report builder and the installed hooks, so `node:*` only.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ // Windows resolves these to devices no matter what extension follows, so a write to
18
+ // `COM1.ndjson` goes to a serial port rather than a file.
19
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
20
+
21
+ // A change id reaches the path builder from `sdlc/.state/active.json` and from CLI
22
+ // argv — both user-writable. An id that is not a single safe path segment is refused
23
+ // outright rather than normalized: normalizing invites a `..` to be resolved into a
24
+ // write outside `.state/`, and there is no legitimate id that needs it.
25
+ //
26
+ // The Windows-specific rejections are not hypothetical for a CLI that installs itself
27
+ // into other people's checkouts: `:` makes NTFS treat the rest as an alternate data
28
+ // stream (`foo:bar.ndjson` writes a hidden stream on `foo`, invisible to a directory
29
+ // listing), and a trailing dot or space is stripped silently, so `add-2fa.` would
30
+ // alias onto the real `add-2fa` change and fold one change's telemetry into another's.
31
+ export function isSafeChangeId(id) {
32
+ return typeof id === 'string'
33
+ && id.length > 0
34
+ && id.length <= 100
35
+ && id !== '.'
36
+ && id !== '..'
37
+ && !id.includes('\0')
38
+ && !/[/\\:]/.test(id)
39
+ && !/[. ]$/.test(id)
40
+ && !WINDOWS_RESERVED.test(id);
41
+ }
42
+
43
+ // The stream a session appends to while the change is open. `null` for an unsafe id,
44
+ // so callers fall back to the global journal instead of writing somewhere surprising.
45
+ export function liveJournalPath(sdlcRoot, changeId) {
46
+ if (!isSafeChangeId(changeId)) return null;
47
+ return path.join(sdlcRoot, '.state', 'journal', `${changeId}.ndjson`);
48
+ }
49
+
50
+ // Events with no change to attribute them to.
51
+ export function globalJournalPath(sdlcRoot) {
52
+ return path.join(sdlcRoot, '.state', 'journal.ndjson');
53
+ }
54
+
55
+ // Where projects installed before this change already have telemetry. Read-only as far
56
+ // as new events are concerned — nothing appends here any more.
57
+ export function legacyJournalPath(sdlcRoot, changeId) {
58
+ if (!isSafeChangeId(changeId)) return null;
59
+ return path.join(sdlcRoot, 'changes', changeId, 'journal.ndjson');
60
+ }
61
+
62
+ // The sealed journal inside a shipped change folder.
63
+ export function sealedJournalPath(changeDir) {
64
+ return path.join(changeDir, 'journal.ndjson');
65
+ }
66
+
67
+ // One malformed line must not cost the rest of the stream: telemetry is best-effort
68
+ // evidence, and a truncated tail from a killed process is a normal way to find it.
69
+ export function parseJournal(text) {
70
+ const out = [];
71
+ for (const line of text.split('\n')) {
72
+ const trimmed = line.trim();
73
+ if (!trimmed) continue;
74
+ try {
75
+ const event = JSON.parse(trimmed);
76
+ if (event && typeof event === 'object') out.push(event);
77
+ } catch { /* skip the line, keep the stream */ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ export function readJournalFile(p) {
83
+ if (!p || !fs.existsSync(p)) return [];
84
+ try {
85
+ return parseJournal(fs.readFileSync(p, 'utf8'));
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ // Merge two streams by recorded time. The sort is stable and the tie-break is explicit
92
+ // — equal timestamps keep stream order (`older` first), then file order — so the sealed
93
+ // journal is byte-identical whatever order the reads happened in.
94
+ export function mergeByTime(older, newer) {
95
+ const decorated = [];
96
+ [older, newer].forEach((events, stream) => {
97
+ events.forEach((event, position) => decorated.push({ event, stream, position }));
98
+ });
99
+ decorated.sort((a, b) => {
100
+ const at = typeof a.event.ts === 'string' ? a.event.ts : '';
101
+ const bt = typeof b.event.ts === 'string' ? b.event.ts : '';
102
+ if (at !== bt) return at < bt ? -1 : 1;
103
+ if (a.stream !== b.stream) return a.stream - b.stream;
104
+ return a.position - b.position;
105
+ });
106
+ return decorated.map((d) => d.event);
107
+ }
108
+
109
+ // Everything recorded for an open change, legacy first so it wins a timestamp tie —
110
+ // it is by definition the older stream.
111
+ export function readChangeJournal(sdlcRoot, changeId) {
112
+ return mergeByTime(
113
+ readJournalFile(legacyJournalPath(sdlcRoot, changeId)),
114
+ readJournalFile(liveJournalPath(sdlcRoot, changeId)),
115
+ );
116
+ }
117
+
118
+ // Always `\n`: a legacy journal checked out on Windows can arrive with CRLF, and the
119
+ // sealed file is committed — mixing endings there would churn every diff after it.
120
+ export function serializeJournal(events) {
121
+ if (!events.length) return '';
122
+ return events.map((e) => JSON.stringify(e)).join('\n') + '\n';
123
+ }
124
+
125
+ export function appendEvent(filePath, event) {
126
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
127
+ fs.appendFileSync(filePath, JSON.stringify(event) + '\n');
128
+ }
package/lib/observe.mjs CHANGED
@@ -5,17 +5,23 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { parseFrontmatter } from './frontmatter.mjs';
7
7
  import { CAPS, countEffectiveLines } from './caps.mjs';
8
-
9
- function readJournal(dir) {
10
- const p = path.join(dir, 'journal.ndjson');
11
- if (!fs.existsSync(p)) return [];
12
- return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => {
13
- try { return JSON.parse(l); } catch { return null; }
14
- }).filter(Boolean);
8
+ import { readChangeJournal, readJournalFile, sealedJournalPath, globalJournalPath } from './journal.mjs';
9
+
10
+ // An open change's telemetry lives out of tree (plus a legacy in-tree stream in
11
+ // projects installed before that); a shipped one carries its sealed journal in the
12
+ // archived folder. Reading only the folder would report an open change as having done
13
+ // nothing, which reads as a quiet change rather than as a broken reader.
14
+ //
15
+ // `id` means different things on the two branches — a change id when open, the dated
16
+ // archive folder name when shipped — so the directory is derived here rather than
17
+ // passed in, and the layout rule stays in one place.
18
+ function changeEvents(sdlcRoot, id, archived) {
19
+ if (archived) return readJournalFile(sealedJournalPath(path.join(sdlcRoot, 'changes', 'archive', id)));
20
+ return readChangeJournal(sdlcRoot, id);
15
21
  }
16
22
 
17
- function summarizeChange(dir, id, archived) {
18
- const events = readJournal(dir);
23
+ function summarizeChange(sdlcRoot, dir, id, archived) {
24
+ const events = changeEvents(sdlcRoot, id, archived);
19
25
  const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
20
26
  let costUsd = 0;
21
27
  let costKnown = false;
@@ -76,13 +82,13 @@ export function buildReport(sdlcRoot) {
76
82
  if (fs.existsSync(changesDir)) {
77
83
  for (const d of fs.readdirSync(changesDir, { withFileTypes: true })) {
78
84
  if (!d.isDirectory() || d.name === 'archive') continue;
79
- changes.push(summarizeChange(path.join(changesDir, d.name), d.name, false));
85
+ changes.push(summarizeChange(sdlcRoot, path.join(changesDir, d.name), d.name, false));
80
86
  }
81
87
  const archiveDir = path.join(changesDir, 'archive');
82
88
  if (fs.existsSync(archiveDir)) {
83
89
  for (const d of fs.readdirSync(archiveDir, { withFileTypes: true })) {
84
90
  if (!d.isDirectory()) continue;
85
- changes.push(summarizeChange(path.join(archiveDir, d.name), d.name, true));
91
+ changes.push(summarizeChange(sdlcRoot, path.join(archiveDir, d.name), d.name, true));
86
92
  }
87
93
  }
88
94
  }
@@ -96,17 +102,14 @@ export function buildReport(sdlcRoot) {
96
102
  const steering = [];
97
103
  const pointerHits = new Map();
98
104
  for (const c of changes) {
99
- const dir = c.archived
100
- ? path.join(sdlcRoot, 'changes', 'archive', c.id)
101
- : path.join(sdlcRoot, 'changes', c.id);
102
- for (const e of readJournal(dir)) {
105
+ for (const e of changeEvents(sdlcRoot, c.id, c.archived)) {
103
106
  if (e.event === 'pointer' && e.steering) {
104
107
  pointerHits.set(e.steering, (pointerHits.get(e.steering) ?? 0) + 1);
105
108
  }
106
109
  }
107
110
  }
108
111
  // Global journal (events with no active change) counts too.
109
- for (const e of readJournal(path.join(sdlcRoot, '.state'))) {
112
+ for (const e of readJournalFile(globalJournalPath(sdlcRoot))) {
110
113
  if (e.event === 'pointer' && e.steering) {
111
114
  pointerHits.set(e.steering, (pointerHits.get(e.steering) ?? 0) + 1);
112
115
  }
package/lib/validate.mjs CHANGED
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { parseFrontmatter } from './frontmatter.mjs';
8
8
  import { CAPS, TIERS, STATUSES, countEffectiveLines, capForChange } from './caps.mjs';
9
- import { parseDelta, parseSpec } from './delta.mjs';
9
+ import { parseDelta, parseSpec, scenarioDrift, describeDrift } from './delta.mjs';
10
10
 
11
11
  const CLARIFICATION_RE = /\[NEEDS CLARIFICATION/g;
12
12
 
@@ -60,11 +60,21 @@ export function validateChange(changeDir, { strict = false, specsDir = null } =
60
60
  for (const d of deltas) {
61
61
  const specPath = path.join(specsDir, d.capability, 'spec.md');
62
62
  const spec = fs.existsSync(specPath) ? parseSpec(fs.readFileSync(specPath, 'utf8')) : null;
63
- const names = new Set((spec?.requirements ?? []).map((r) => r.name.toLowerCase()));
63
+ const byName = new Map((spec?.requirements ?? []).map((r) => [r.name.toLowerCase(), r]));
64
64
  for (const op of d.ops) {
65
- if ((op.op === 'MODIFIED' || op.op === 'REMOVED') && !names.has(op.name.toLowerCase())) {
65
+ const target = byName.get(op.name.toLowerCase());
66
+ if ((op.op === 'MODIFIED' || op.op === 'REMOVED') && !target) {
66
67
  issues.push(issue(strict ? 'error' : 'warn', id,
67
68
  `${op.op} Requirement "${op.name}" not found in specs/${d.capability}/spec.md`));
69
+ continue;
70
+ }
71
+ // MODIFIED is a full replacement: report the scenarios it carries away
72
+ // before ship merges it. Always a warning — dropping a scenario can be
73
+ // the point of the change; doing it silently never is.
74
+ if (op.op === 'MODIFIED' && target) {
75
+ for (const msg of describeDrift(d.capability, op.name, scenarioDrift(target.body, op.body))) {
76
+ issues.push(issue('warn', id, msg));
77
+ }
68
78
  }
69
79
  }
70
80
  }
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
- {
2
- "name": "@warnyin/sdlc",
3
- "version": "0.5.1",
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
- "type": "module",
6
- "bin": {
7
- "warnyin-sdlc": "bin/cli.mjs"
8
- },
9
- "files": [
10
- "bin",
11
- "lib",
12
- "scripts",
13
- "payload",
14
- "README.md",
15
- "CHANGELOG.md",
16
- "LICENSE"
17
- ],
18
- "scripts": {
19
- "test": "node --test",
20
- "setup:dogfood": "node bin/cli.mjs init --tool claude && node bin/cli.mjs update"
21
- },
22
- "engines": {
23
- "node": ">=20"
24
- },
25
- "publishConfig": {
26
- "access": "public"
27
- },
28
- "repository": {
29
- "type": "git",
30
- "url": "git+https://github.com/warnyin/warnyin-sdlc.git"
31
- },
32
- "keywords": [
33
- "sdlc",
34
- "spec-driven",
35
- "ai",
36
- "agents",
37
- "claude-code",
38
- "context-engineering"
39
- ],
40
- "author": "warnyin",
41
- "license": "MIT"
42
- }
1
+ {
2
+ "name": "@warnyin/sdlc",
3
+ "version": "0.6.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "warnyin-sdlc": "bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "scripts",
13
+ "payload",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test",
20
+ "setup:dogfood": "node bin/cli.mjs init --tool claude && node bin/cli.mjs update"
21
+ },
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/warnyin/warnyin-sdlc.git"
31
+ },
32
+ "keywords": [
33
+ "sdlc",
34
+ "spec-driven",
35
+ "ai",
36
+ "agents",
37
+ "claude-code",
38
+ "context-engineering"
39
+ ],
40
+ "author": "warnyin",
41
+ "license": "MIT"
42
+ }
@@ -29,5 +29,8 @@ Rules:
29
29
  - Observable behavior only; no class/function names, no implementation.
30
30
  - Every ADDED/MODIFIED requirement needs ≥1 scenario a test can be derived from.
31
31
  - Placeholders only (`<token>`, `user@example.com`) — never real secrets/PII.
32
+ - MODIFIED replaces the whole body: a scenario the spec carries and your body
33
+ omits — or whose WHEN/THEN you rewrite — is reported by `validate` and `archive`
34
+ as a warning. Deliberate removals are fine; carry the rest over verbatim.
32
35
  - `npx @warnyin/sdlc archive <id>` merges deltas mechanically at ship; a missing
33
36
  key aborts the merge — never work around it by editing specs directly.
@@ -1,26 +1,27 @@
1
- ---
2
- name: sdlc-conventions
3
- description: Cheat-sheet for the sdlc/ artifact layout, line caps, statuses, gates, and machine-owned files. Load when working with any file under sdlc/.
4
- user-invocable: false
5
- ---
6
- # sdlc/ conventions
7
-
8
- Layout: `config.yaml` · `context/{constitution.md, steering/*.md}` · `harness.md`
9
- · `specs/<capability>/spec.md` · `changes/<id>/{change.md, contract/, journal.ndjson}`
10
- · `changes/archive/<date>-<id>/` · `evals/<capability>/rubric.md` · `.state/` (machine).
11
-
12
- Line caps (validator-enforced; count = non-blank, non-comment body lines):
13
- constitution 30 · steering 40 each · always-budget 60 total · harness 60 ·
14
- change vibe/standard/deep 40/100/150 · tests.md 60 · evals.md 40 · spec soft 150.
15
-
16
- Frontmatter: `id` (= folder name) · `tier: vibe|standard|deep` ·
17
- `status: new|contracted|building|verified|shipped`.
18
- Steering: `inclusion: always|paths|manual|agent` (+ `pathMatch` for paths).
19
-
20
- Hard rules (hook-enforced):
21
- - `sdlc/specs/**` + `changes/archive/**`: writable only during an open ship gate.
22
- - `constitution.md`: writable only during an open steer gate.
23
- - `journal.ndjson` + `.state/**`: machine-owned, never hand-edit.
24
-
25
- Gates: `node sdlc/.hooks/journal.mjs open-ship <id> | open-steer | close | set-active <id> | note <name> [k=v]`.
26
- Validation: `npx @warnyin/sdlc validate [id] [--strict]` red = the gate did not pass.
1
+ ---
2
+ name: sdlc-conventions
3
+ description: Cheat-sheet for the sdlc/ artifact layout, line caps, statuses, gates, and machine-owned files. Load when working with any file under sdlc/.
4
+ user-invocable: false
5
+ ---
6
+ # sdlc/ conventions
7
+
8
+ Layout: `config.yaml` · `context/{constitution.md, steering/*.md}` · `harness.md`
9
+ · `specs/<capability>/spec.md` · `changes/<id>/{change.md, contract/}`
10
+ · telemetry: `.state/journal/<id>.ndjson` while open, sealed into the archived folder at ship
11
+ · `changes/archive/<date>-<id>/` · `evals/<capability>/rubric.md` · `.state/` (machine).
12
+
13
+ Line caps (validator-enforced; count = non-blank, non-comment body lines):
14
+ constitution 30 · steering 40 each · always-budget 60 total · harness 60 ·
15
+ change vibe/standard/deep 40/100/150 · tests.md 60 · evals.md 40 · spec soft 150.
16
+
17
+ Frontmatter: `id` (= folder name) · `tier: vibe|standard|deep` ·
18
+ `status: new|contracted|building|verified|shipped`.
19
+ Steering: `inclusion: always|paths|manual|agent` (+ `pathMatch` for paths).
20
+
21
+ Hard rules (hook-enforced):
22
+ - `sdlc/specs/**` + `changes/archive/**`: writable only during an open ship gate.
23
+ - `constitution.md`: writable only during an open steer gate.
24
+ - `journal.ndjson` + `.state/**`: machine-owned, never hand-edit.
25
+
26
+ Gates: `node sdlc/.hooks/journal.mjs open-ship <id> | open-steer | close | set-active <id> | note <name> [k=v]`.
27
+ Validation: `npx @warnyin/sdlc validate [id] [--strict]` — red = the gate did not pass.
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import process from 'node:process';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { liveJournalPath, globalJournalPath, appendEvent } from './lib/journal.mjs';
10
11
 
11
12
  export function resolveRoots(importMetaUrl) {
12
13
  const hooksDir = path.dirname(fileURLToPath(importMetaUrl));
@@ -135,20 +136,15 @@ export function activeChange(sdlcRoot) {
135
136
  return best?.change ?? null;
136
137
  }
137
138
 
138
- // Journal: per-change ndjson when a change is active, else a global one under
139
- // .state/ so no signal is lost. Hook-written only agents never hand-edit.
139
+ // Journal: per-change ndjson when a change is active, else a global one — both under
140
+ // .state/, which is gitignored, so a session never dirties a version-controlled file
141
+ // just by running. Hook-written only — agents never hand-edit.
142
+ //
143
+ // Attribution does not depend on the change folder existing: a stale active pointer
144
+ // still records the event under that id rather than silently reattributing it.
140
145
  export function appendJournal(sdlcRoot, change, event) {
141
146
  try {
142
- const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
143
- if (change) {
144
- const dir = path.join(sdlcRoot, 'changes', change);
145
- if (fs.existsSync(dir)) {
146
- fs.appendFileSync(path.join(dir, 'journal.ndjson'), line);
147
- return;
148
- }
149
- }
150
- const stateDir = path.join(sdlcRoot, '.state');
151
- fs.mkdirSync(stateDir, { recursive: true });
152
- fs.appendFileSync(path.join(stateDir, 'journal.ndjson'), line);
147
+ const target = (change && liveJournalPath(sdlcRoot, change)) || globalJournalPath(sdlcRoot);
148
+ appendEvent(target, { ts: new Date().toISOString(), ...event });
153
149
  } catch { /* fail open */ }
154
150
  }
@@ -22,6 +22,18 @@ escalation conditions — each with the choice `--auto` may pre-approve:
22
22
  pointer. A run that never gets confirmed must leave the repository unchanged.
23
23
  2. **Confirm.** One message, and it must be decidable item by item:
24
24
  - scope as you understood it, and the tier you triaged with its reason
25
+ - **the evidence under each scope item**: the command you ran and what it
26
+ returned — not the list it produced, and not your summary of it. A reader who
27
+ can only see the conclusion can only agree with it. Evidence goes per item:
28
+ one block for a whole scope buys a single yes for derivations nobody checked
29
+ separately. A scope you did not derive from a command says so plainly.
30
+ - **flag evidence that does not match the request**: name the term the request
31
+ used and the term you actually searched when they differ, and raise any
32
+ narrowing the request never asked for (a folder pattern, a naming convention)
33
+ as its own refusable item.
34
+ - **an exclusion made on an empty result names the pattern searched.** Finding
35
+ nothing is a claim about your pattern, not a fact about the candidate — a
36
+ convention you did not anticipate looks exactly like an absence.
25
37
  - every ambiguity, each with the assumption you intend to act on
26
38
  - one line per row of the escalation table above, each stating the choice you
27
39
  want pre-approved, and each refusable on its own. For the ship row, name the
@@ -1,33 +1,35 @@
1
- # /sdlc:ship <id> — merge, archive, learn, digest
2
-
3
- Precondition: `status: verified` (+ review passed when it ran).
4
-
5
- 1. **Policy check** (`sdlc/harness.md § Autonomy policy`): if this change is NOT
6
- auto-shippable (deep/hard-floor), show the human a 5-line summary (why, delta
7
- heads, verify result, cost so far) and wait for approval. Otherwise proceed.
8
- 2. Open the gate and archive mechanically:
9
- `node sdlc/.hooks/journal.mjs open-ship <id>`
10
- `npx @warnyin/sdlc archive <id>`
11
- (validates --strict, merges every Delta into `sdlc/specs/`, promotes evals,
12
- stamps `status: shipped`, moves the folder to `changes/archive/<date>-<id>/`).
13
- 3. **Learn** delegate to `sdlc-learner` (cheap) with the archived change.md +
14
- its journal.ndjson. It proposes ≤3 items: add-rule (with evidence pointer) /
15
- expire-or-demote (rule or steering that never fired) / harness tweak.
16
- Apply reductions and demotions immediately (they always save tokens).
17
- Additions to always-loaded context are NOT applied list them in the digest.
18
- 4. **Digest** write `sdlc/changes/archive/<date>-<id>/digest.md` (≤15 lines):
19
- what shipped, spec deltas merged, assumptions made, verify rounds, tokens/cost
20
- (from journal `session` events), learner proposals awaiting the human.
21
- When any verify or review note carries `mode=solo`, the digest SHALL say which
22
- outcomes were self-produced. A reader months from now cannot otherwise tell a
23
- panel's verdict from the author's own.
24
- When any `escalation` event carries `preauth=yes`, the digest SHALL list those
25
- pre-authorized escalations by condition — the points where a human would normally
26
- have stood and, this run, did not.
27
- 5. Close the gate: `node sdlc/.hooks/journal.mjs close`. Tell the user in one
28
- line: shipped + where the digest is.
29
-
30
- The digest is the async human touchpoint — reviewable and revertible later.
31
-
32
- `--auto`: do this stage, then continue to ship under `auto.md`'s unattended
33
- mode — gather, confirm once, run. The stage still does its own work first.
1
+ # /sdlc:ship <id> — merge, archive, learn, digest
2
+
3
+ Precondition: `status: verified` (+ review passed when it ran).
4
+
5
+ 1. **Policy check** (`sdlc/harness.md § Autonomy policy`): if this change is NOT
6
+ auto-shippable (deep/hard-floor), show the human a 5-line summary (why, delta
7
+ heads, verify result, cost so far) and wait for approval. Otherwise proceed.
8
+ 2. Open the gate and archive mechanically:
9
+ `node sdlc/.hooks/journal.mjs open-ship <id>`
10
+ `npx @warnyin/sdlc archive <id>`
11
+ (validates --strict, merges every Delta into `sdlc/specs/`, promotes evals,
12
+ stamps `status: shipped`, moves the folder to `changes/archive/<date>-<id>/`).
13
+ Any `⚠ MODIFIED Requirement …` line means the new body carried away a scenario
14
+ the spec still promised: confirm it was intended and name it in the digest.
15
+ 3. **Learn** delegate to `sdlc-learner` (cheap) with the archived change.md +
16
+ its journal.ndjson. It proposes ≤3 items: add-rule (with evidence pointer) /
17
+ expire-or-demote (rule or steering that never fired) / harness tweak.
18
+ Apply reductions and demotions immediately (they always save tokens).
19
+ Additions to always-loaded context are NOT applied list them in the digest.
20
+ 4. **Digest** — write `sdlc/changes/archive/<date>-<id>/digest.md` (≤15 lines):
21
+ what shipped, spec deltas merged, assumptions made, verify rounds, tokens/cost
22
+ (from journal `session` events), learner proposals awaiting the human.
23
+ When any verify or review note carries `mode=solo`, the digest SHALL say which
24
+ outcomes were self-produced. A reader months from now cannot otherwise tell a
25
+ panel's verdict from the author's own.
26
+ When any `escalation` event carries `preauth=yes`, the digest SHALL list those
27
+ pre-authorized escalations by condition the points where a human would normally
28
+ have stood and, this run, did not.
29
+ 5. Close the gate: `node sdlc/.hooks/journal.mjs close`. Tell the user in one
30
+ line: shipped + where the digest is.
31
+
32
+ The digest is the async human touchpoint reviewable and revertible later.
33
+
34
+ `--auto`: do this stage, then continue to ship under `auto.md`'s unattended
35
+ mode — gather, confirm once, run. The stage still does its own work first.