@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 +43 -0
- package/README.md +1 -1
- package/bin/cli.mjs +61 -5
- package/lib/delta.mjs +71 -4
- package/lib/journal.mjs +128 -0
- package/lib/observe.mjs +19 -16
- package/lib/validate.mjs +13 -3
- package/package.json +42 -42
- package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +3 -0
- package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +27 -26
- package/payload/hooks/_shared.mjs +9 -13
- package/payload/playbook/auto.md +12 -0
- package/payload/playbook/ship.md +35 -33
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/
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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(
|
|
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
|
}
|
package/lib/journal.mjs
ADDED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
63
|
+
const byName = new Map((spec?.requirements ?? []).map((r) => [r.name.toLowerCase(), r]));
|
|
64
64
|
for (const op of d.ops) {
|
|
65
|
-
|
|
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.
|
|
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
|
|
10
|
-
·
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- `
|
|
23
|
-
- `
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
143
|
-
|
|
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
|
}
|
package/payload/playbook/auto.md
CHANGED
|
@@ -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
|
package/payload/playbook/ship.md
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|