@warnyin/sdlc 0.1.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 (80) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +74 -0
  4. package/bin/cli.mjs +470 -0
  5. package/lib/caps.mjs +45 -0
  6. package/lib/config.mjs +41 -0
  7. package/lib/delta.mjs +160 -0
  8. package/lib/frontmatter.mjs +59 -0
  9. package/lib/glob.mjs +29 -0
  10. package/lib/manifest.mjs +99 -0
  11. package/lib/observe.mjs +174 -0
  12. package/lib/settings-merge.mjs +63 -0
  13. package/lib/usage.mjs +46 -0
  14. package/lib/validate.mjs +186 -0
  15. package/package.json +42 -0
  16. package/payload/adapters/agents-md.md +8 -0
  17. package/payload/adapters/claude/agents/sdlc-architect.md +12 -0
  18. package/payload/adapters/claude/agents/sdlc-builder.md +14 -0
  19. package/payload/adapters/claude/agents/sdlc-contractor.md +13 -0
  20. package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -0
  21. package/payload/adapters/claude/agents/sdlc-learner.md +16 -0
  22. package/payload/adapters/claude/agents/sdlc-ops.md +11 -0
  23. package/payload/adapters/claude/agents/sdlc-quality.md +13 -0
  24. package/payload/adapters/claude/agents/sdlc-security.md +12 -0
  25. package/payload/adapters/claude/commands/sdlc/auto.md +5 -0
  26. package/payload/adapters/claude/commands/sdlc/build.md +5 -0
  27. package/payload/adapters/claude/commands/sdlc/contract.md +5 -0
  28. package/payload/adapters/claude/commands/sdlc/converge.md +5 -0
  29. package/payload/adapters/claude/commands/sdlc/design.md +5 -0
  30. package/payload/adapters/claude/commands/sdlc/init.md +4 -0
  31. package/payload/adapters/claude/commands/sdlc/new.md +5 -0
  32. package/payload/adapters/claude/commands/sdlc/next.md +4 -0
  33. package/payload/adapters/claude/commands/sdlc/observe.md +4 -0
  34. package/payload/adapters/claude/commands/sdlc/review.md +5 -0
  35. package/payload/adapters/claude/commands/sdlc/ship.md +5 -0
  36. package/payload/adapters/claude/commands/sdlc/steer.md +4 -0
  37. package/payload/adapters/claude/commands/sdlc/verify.md +5 -0
  38. package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -0
  39. package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +33 -0
  40. package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +26 -0
  41. package/payload/adapters/cline.md +8 -0
  42. package/payload/adapters/copilot.md +8 -0
  43. package/payload/adapters/cursor.mdc +7 -0
  44. package/payload/adapters/gemini.md +8 -0
  45. package/payload/adapters/windsurf.md +4 -0
  46. package/payload/hooks/_shared.mjs +141 -0
  47. package/payload/hooks/guard-writes.mjs +83 -0
  48. package/payload/hooks/inject-context.mjs +55 -0
  49. package/payload/hooks/journal.mjs +58 -0
  50. package/payload/hooks/session-summary.mjs +50 -0
  51. package/payload/hooks/validate-artifact.mjs +80 -0
  52. package/payload/playbook/README.md +30 -0
  53. package/payload/playbook/auto.md +21 -0
  54. package/payload/playbook/build.md +23 -0
  55. package/payload/playbook/context.md +26 -0
  56. package/payload/playbook/contract.md +23 -0
  57. package/payload/playbook/converge.md +19 -0
  58. package/payload/playbook/design.md +20 -0
  59. package/payload/playbook/init.md +22 -0
  60. package/payload/playbook/new.md +22 -0
  61. package/payload/playbook/next.md +12 -0
  62. package/payload/playbook/observe.md +20 -0
  63. package/payload/playbook/principles.md +28 -0
  64. package/payload/playbook/review.md +17 -0
  65. package/payload/playbook/routing.md +19 -0
  66. package/payload/playbook/rules-card.md +16 -0
  67. package/payload/playbook/ship.md +24 -0
  68. package/payload/playbook/steer.md +21 -0
  69. package/payload/playbook/verify.md +24 -0
  70. package/payload/templates/change-deep.md +29 -0
  71. package/payload/templates/change-standard.md +28 -0
  72. package/payload/templates/change-vibe.md +19 -0
  73. package/payload/templates/config.yaml +8 -0
  74. package/payload/templates/constitution.md +14 -0
  75. package/payload/templates/contract-evals.md +9 -0
  76. package/payload/templates/contract-tests.md +9 -0
  77. package/payload/templates/harness.md +33 -0
  78. package/payload/templates/spec.md +14 -0
  79. package/payload/templates/steering.md +9 -0
  80. package/scripts/validate.mjs +38 -0
@@ -0,0 +1,141 @@
1
+ // Shared plumbing for installed hooks. This file lives at
2
+ // <project>/sdlc/.hooks/_shared.mjs with lib/ as a sibling directory.
3
+ // Every hook must be fail-open: on any unexpected condition, exit 0 silently
4
+ // so the harness is never blocked by our tooling.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ export function resolveRoots(importMetaUrl) {
12
+ const hooksDir = path.dirname(fileURLToPath(importMetaUrl));
13
+ const sdlcRoot = path.dirname(hooksDir);
14
+ const projectRoot = path.dirname(sdlcRoot);
15
+ return { hooksDir, sdlcRoot, projectRoot };
16
+ }
17
+
18
+ export async function readStdinJson() {
19
+ try {
20
+ let data = '';
21
+ for await (const chunk of process.stdin) data += chunk;
22
+ return data.trim() ? JSON.parse(data) : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ // Resolve symlinks on the deepest EXISTING ancestor, then re-attach the tail.
29
+ // Needed because import.meta.url is symlink-resolved while tool file_paths may
30
+ // arrive through a symlink (/tmp → /private/tmp on macOS).
31
+ export function realResolve(p) {
32
+ let cur = path.resolve(p);
33
+ const tail = [];
34
+ while (!fs.existsSync(cur)) {
35
+ const parent = path.dirname(cur);
36
+ if (parent === cur) break;
37
+ tail.unshift(path.basename(cur));
38
+ cur = parent;
39
+ }
40
+ try { cur = fs.realpathSync.native(cur); } catch { /* keep as-is */ }
41
+ return tail.length ? path.join(cur, ...tail) : cur;
42
+ }
43
+
44
+ export function toPosixRel(projectRoot, absPath) {
45
+ const rel = path.relative(realResolve(projectRoot), realResolve(absPath));
46
+ if (rel.startsWith('..')) return null;
47
+ return rel.split(path.sep).join('/');
48
+ }
49
+
50
+ // Lexical (no-symlink-resolution) relative path: what the path CLAIMS to be.
51
+ // Tried against both the raw and the realpathed project root so /tmp-style
52
+ // root symlinks don't break matching. Guards must compare this against
53
+ // toPosixRel — a divergence means a symlink sits inside the project.
54
+ export function lexicalPosixRel(projectRoot, absPath) {
55
+ const abs = path.resolve(absPath);
56
+ const realRoot = realResolve(projectRoot);
57
+ for (const base of [path.resolve(projectRoot), realRoot]) {
58
+ const rel = path.relative(base, abs);
59
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
60
+ return rel.split(path.sep).join('/');
61
+ }
62
+ }
63
+ // Root-level symlinks (/tmp → /private/tmp): find the SHALLOWEST ancestor of
64
+ // abs whose realpath IS the project root; the remaining tail is the lexical
65
+ // claim. In-project symlinks are deliberately not resolved here.
66
+ const segs = abs.split(path.sep);
67
+ for (let i = 1; i < segs.length; i++) {
68
+ const ancestor = segs.slice(0, i).join(path.sep) || path.sep;
69
+ let real;
70
+ try { real = fs.realpathSync.native(ancestor); } catch { continue; }
71
+ if (real === realRoot) {
72
+ const tail = segs.slice(i).join('/');
73
+ return tail || null;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // Gate state written by `journal.mjs open-<phase>` — {phase, change?, expires}.
80
+ export function readPhase(sdlcRoot) {
81
+ try {
82
+ const raw = fs.readFileSync(path.join(sdlcRoot, '.state', 'phase.json'), 'utf8');
83
+ const phase = JSON.parse(raw);
84
+ if (phase.expires && Date.parse(phase.expires) < Date.now()) return null;
85
+ return phase;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ export function writePhase(sdlcRoot, phase, ttlMinutes = 30) {
92
+ const stateDir = path.join(sdlcRoot, '.state');
93
+ fs.mkdirSync(stateDir, { recursive: true });
94
+ const payload = { ...phase, expires: new Date(Date.now() + ttlMinutes * 60_000).toISOString() };
95
+ fs.writeFileSync(path.join(stateDir, 'phase.json'), JSON.stringify(payload));
96
+ return payload;
97
+ }
98
+
99
+ export function clearPhase(sdlcRoot) {
100
+ fs.rmSync(path.join(sdlcRoot, '.state', 'phase.json'), { force: true });
101
+ }
102
+
103
+ // Active change: explicit .state/active.json first, else the most recently
104
+ // modified changes/*/change.md.
105
+ export function activeChange(sdlcRoot) {
106
+ try {
107
+ const explicit = JSON.parse(fs.readFileSync(path.join(sdlcRoot, '.state', 'active.json'), 'utf8'));
108
+ if (explicit?.change && fs.existsSync(path.join(sdlcRoot, 'changes', explicit.change))) {
109
+ return explicit.change;
110
+ }
111
+ } catch { /* fall through */ }
112
+ const changesDir = path.join(sdlcRoot, 'changes');
113
+ if (!fs.existsSync(changesDir)) return null;
114
+ let best = null;
115
+ for (const d of fs.readdirSync(changesDir, { withFileTypes: true })) {
116
+ if (!d.isDirectory() || d.name === 'archive') continue;
117
+ const p = path.join(changesDir, d.name, 'change.md');
118
+ if (!fs.existsSync(p)) continue;
119
+ const mtime = fs.statSync(p).mtimeMs;
120
+ if (!best || mtime > best.mtime) best = { change: d.name, mtime };
121
+ }
122
+ return best?.change ?? null;
123
+ }
124
+
125
+ // Journal: per-change ndjson when a change is active, else a global one under
126
+ // .state/ so no signal is lost. Hook-written only — agents never hand-edit.
127
+ export function appendJournal(sdlcRoot, change, event) {
128
+ try {
129
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
130
+ if (change) {
131
+ const dir = path.join(sdlcRoot, 'changes', change);
132
+ if (fs.existsSync(dir)) {
133
+ fs.appendFileSync(path.join(dir, 'journal.ndjson'), line);
134
+ return;
135
+ }
136
+ }
137
+ const stateDir = path.join(sdlcRoot, '.state');
138
+ fs.mkdirSync(stateDir, { recursive: true });
139
+ fs.appendFileSync(path.join(stateDir, 'journal.ndjson'), line);
140
+ } catch { /* fail open */ }
141
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook — the deterministic guardrail ("things the agent should
3
+ // never forget but often does"). Denies direct edits to:
4
+ // sdlc/specs/** outside an open ship gate
5
+ // sdlc/changes/archive/** outside an open ship gate
6
+ // sdlc/context/constitution.md (existing) outside an open steer gate
7
+ // sdlc/.state/** and any journal.ndjson always (machine-owned)
8
+ // The sanctioned paths are the CLI (`warnyin-sdlc archive`) and the gates
9
+ // opened by `journal.mjs open-ship|open-steer`.
10
+
11
+ import fs from 'node:fs';
12
+ import process from 'node:process';
13
+ import path from 'node:path';
14
+ import {
15
+ resolveRoots, readStdinJson, readPhase, activeChange, appendJournal, toPosixRel, lexicalPosixRel,
16
+ } from './_shared.mjs';
17
+
18
+ const { sdlcRoot, projectRoot } = resolveRoots(import.meta.url);
19
+
20
+ function deny(reason, rel) {
21
+ appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: 'guard', action: 'deny', path: rel, reason });
22
+ console.log(JSON.stringify({
23
+ hookSpecificOutput: {
24
+ hookEventName: 'PreToolUse',
25
+ permissionDecision: 'deny',
26
+ permissionDecisionReason: reason,
27
+ },
28
+ }));
29
+ }
30
+
31
+ // Evaluate the lock rules against ONE view of the path. Returns true when a
32
+ // deny was emitted. Rules must hold for BOTH the lexical (claimed) and the
33
+ // realpath-resolved view — a symlink must never weaken a lock.
34
+ function guard(rel, phase) {
35
+ if (rel.startsWith('sdlc/.state/') || rel.endsWith('journal.ndjson')) {
36
+ deny(`"${rel}" is machine-owned (hooks/CLI write it) — never edit it by hand.`, rel);
37
+ return true;
38
+ }
39
+ if (rel.startsWith('sdlc/specs/') || rel.startsWith('sdlc/changes/archive/')) {
40
+ if (phase?.phase === 'ship') return false;
41
+ deny(
42
+ `"${rel}" is write-locked outside ship. Living specs change only by merging a change's Delta: `
43
+ + 'run `warnyin-sdlc archive <id>` (or `node sdlc/.hooks/journal.mjs open-ship <id>` first if you must edit).',
44
+ rel,
45
+ );
46
+ return true;
47
+ }
48
+ if (rel === 'sdlc/context/constitution.md' && fs.existsSync(path.join(projectRoot, rel))) {
49
+ if (phase?.phase === 'steer' || phase?.phase === 'ship') return false;
50
+ deny(
51
+ 'The constitution is always-loaded context — edits go through /sdlc:steer '
52
+ + '(`node sdlc/.hooks/journal.mjs open-steer` opens the gate).',
53
+ rel,
54
+ );
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ async function main() {
61
+ const input = await readStdinJson();
62
+ const filePath = input?.tool_input?.file_path ?? input?.tool_input?.notebook_path;
63
+ if (!filePath || !fs.existsSync(sdlcRoot)) return;
64
+
65
+ const abs = path.resolve(projectRoot, filePath);
66
+ const relLexical = lexicalPosixRel(projectRoot, abs);
67
+ const relReal = toPosixRel(projectRoot, abs);
68
+
69
+ // A path that CLAIMS to live under sdlc/ but resolves elsewhere (or out of
70
+ // the project) went through a symlink — deny conservatively; a symlink must
71
+ // never disable the write-lock.
72
+ if (relLexical?.startsWith('sdlc/') && relReal !== relLexical) {
73
+ deny(`"${relLexical}" resolves through a symlink to "${relReal ?? 'outside the project'}" — refusing to touch it.`, relLexical);
74
+ return;
75
+ }
76
+
77
+ const phase = readPhase(sdlcRoot);
78
+ for (const rel of new Set([relLexical, relReal].filter(Boolean))) {
79
+ if (rel.startsWith('sdlc/') && guard(rel, phase)) return;
80
+ }
81
+ }
82
+
83
+ main().catch(() => process.exit(0)); // fail open
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook — THE static-context loader. Emits (hard cap 60 lines):
3
+ // constitution + every `inclusion: always` steering file + a one-line
4
+ // pointer to the active change. Everything else stays dynamic.
5
+ // Journals what was injected so /sdlc:observe can price residency honestly.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_shared.mjs';
11
+ import { parseFrontmatter } from './lib/frontmatter.mjs';
12
+ import { CAPS } from './lib/caps.mjs';
13
+
14
+ const { sdlcRoot } = resolveRoots(import.meta.url);
15
+
16
+ async function main() {
17
+ await readStdinJson(); // drain; content not needed
18
+ if (!fs.existsSync(sdlcRoot)) return;
19
+
20
+ const injected = [];
21
+ const out = [];
22
+
23
+ const constitutionPath = path.join(sdlcRoot, 'context', 'constitution.md');
24
+ if (fs.existsSync(constitutionPath)) {
25
+ out.push(fs.readFileSync(constitutionPath, 'utf8').trim());
26
+ injected.push('context/constitution.md');
27
+ }
28
+
29
+ const steeringDir = path.join(sdlcRoot, 'context', 'steering');
30
+ if (fs.existsSync(steeringDir)) {
31
+ for (const f of fs.readdirSync(steeringDir).filter((n) => n.endsWith('.md')).sort()) {
32
+ const raw = fs.readFileSync(path.join(steeringDir, f), 'utf8');
33
+ const { data, body } = parseFrontmatter(raw);
34
+ if (data.inclusion !== 'always') continue;
35
+ out.push(body.trim());
36
+ injected.push(`context/steering/${f}`);
37
+ }
38
+ }
39
+
40
+ const active = activeChange(sdlcRoot);
41
+ if (active) out.push(`Active change: sdlc/changes/${active}/change.md — run /sdlc:next for status.`);
42
+
43
+ if (!out.length) return;
44
+
45
+ let lines = out.join('\n\n').split('\n');
46
+ if (lines.length > CAPS.alwaysBudget) {
47
+ lines = lines.slice(0, CAPS.alwaysBudget);
48
+ lines.push(`[sdlc] static context truncated at ${CAPS.alwaysBudget} lines — run /sdlc:steer to distill (validate also flags this).`);
49
+ }
50
+ console.log(lines.join('\n'));
51
+
52
+ appendJournal(sdlcRoot, active, { event: 'inject', files: injected, lines: lines.length });
53
+ }
54
+
55
+ main().catch(() => process.exit(0)); // fail open
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ // Gate + journal utility. Called by playbooks (sanctioned phase transitions)
3
+ // and wired as the PreCompact hook (`journal.mjs note compact`).
4
+ //
5
+ // node sdlc/.hooks/journal.mjs open-ship <change-id> unlock specs/archive writes (TTL 30m)
6
+ // node sdlc/.hooks/journal.mjs open-steer unlock constitution edits (TTL 30m)
7
+ // node sdlc/.hooks/journal.mjs close close any open gate
8
+ // node sdlc/.hooks/journal.mjs set-active <change-id> attribute sessions/events to a change
9
+ // node sdlc/.hooks/journal.mjs note <name> [k=v ...] append a journal event
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import process from 'node:process';
14
+ import {
15
+ resolveRoots, readStdinJson, writePhase, clearPhase, activeChange, appendJournal,
16
+ } from './_shared.mjs';
17
+
18
+ const { sdlcRoot } = resolveRoots(import.meta.url);
19
+
20
+ async function main() {
21
+ const [cmd, ...rest] = process.argv.slice(2);
22
+
23
+ if (cmd === 'open-ship') {
24
+ const change = rest[0];
25
+ if (!change) { console.error('usage: journal.mjs open-ship <change-id>'); process.exit(2); }
26
+ const phase = writePhase(sdlcRoot, { phase: 'ship', change });
27
+ appendJournal(sdlcRoot, change, { event: 'gate', gate: 'ship', action: 'open', expires: phase.expires });
28
+ console.log(`ship gate open for "${change}" until ${phase.expires}`);
29
+ } else if (cmd === 'open-steer') {
30
+ const phase = writePhase(sdlcRoot, { phase: 'steer' });
31
+ appendJournal(sdlcRoot, null, { event: 'gate', gate: 'steer', action: 'open', expires: phase.expires });
32
+ console.log(`steer gate open until ${phase.expires}`);
33
+ } else if (cmd === 'close') {
34
+ clearPhase(sdlcRoot);
35
+ console.log('gate closed');
36
+ } else if (cmd === 'set-active') {
37
+ const change = rest[0];
38
+ if (!change) { console.error('usage: journal.mjs set-active <change-id>'); process.exit(2); }
39
+ fs.mkdirSync(path.join(sdlcRoot, '.state'), { recursive: true });
40
+ fs.writeFileSync(path.join(sdlcRoot, '.state', 'active.json'), JSON.stringify({ change }));
41
+ console.log(`active change: ${change}`);
42
+ } else if (cmd === 'note') {
43
+ // When used as a hook, drain stdin so the harness never blocks on us.
44
+ if (!process.stdin.isTTY) await readStdinJson();
45
+ const name = rest[0] ?? 'note';
46
+ const extra = {};
47
+ for (const kv of rest.slice(1)) {
48
+ const [k, ...v] = kv.split('=');
49
+ if (k && v.length) extra[k] = v.join('=');
50
+ }
51
+ appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: name, ...extra });
52
+ } else {
53
+ console.error('usage: journal.mjs open-ship|open-steer|close|set-active|note ...');
54
+ process.exit(2);
55
+ }
56
+ }
57
+
58
+ main().catch(() => process.exit(0)); // fail open
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // Stop hook — the observability tap. Parses the session transcript's real
3
+ // usage numbers, attributes them to the active change, appends a `session`
4
+ // journal event, and prints a one-line summary. Cost is computed only when
5
+ // sdlc/config.yaml provides a price table — otherwise reported as n/a.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_shared.mjs';
11
+ import { parseTranscriptUsage, costUsd } from './lib/usage.mjs';
12
+ import { parseConfig } from './lib/config.mjs';
13
+
14
+ const { sdlcRoot } = resolveRoots(import.meta.url);
15
+
16
+ const fmt = (n) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M`
17
+ : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n));
18
+
19
+ async function main() {
20
+ const input = await readStdinJson();
21
+ const transcriptPath = input?.transcript_path;
22
+ if (!transcriptPath || !fs.existsSync(transcriptPath) || !fs.existsSync(sdlcRoot)) return;
23
+
24
+ const usage = parseTranscriptUsage(fs.readFileSync(transcriptPath, 'utf8'));
25
+ if (!usage.totals.input && !usage.totals.output) return;
26
+
27
+ let prices = null;
28
+ try {
29
+ prices = parseConfig(fs.readFileSync(path.join(sdlcRoot, 'config.yaml'), 'utf8')).prices;
30
+ } catch { /* no config, no cost */ }
31
+ const usd = costUsd(usage, prices);
32
+
33
+ const change = activeChange(sdlcRoot);
34
+ appendJournal(sdlcRoot, change, {
35
+ event: 'session',
36
+ session: input?.session_id ?? null,
37
+ totals: usage.totals,
38
+ models: usage.models,
39
+ costUsd: usd,
40
+ });
41
+
42
+ const t = usage.totals;
43
+ console.log(
44
+ `[sdlc] session: ${fmt(t.input)} in / ${fmt(t.output)} out / ${fmt(t.cacheRead)} cache-read`
45
+ + ` · cost ${usd == null ? 'n/a' : `$${usd}`}`
46
+ + (change ? ` · change ${change}` : ''),
47
+ );
48
+ }
49
+
50
+ main().catch(() => process.exit(0)); // fail open
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse hook — two token-lean feedback loops:
3
+ // (a) a write under sdlc/ → targeted structural validation, warnings back
4
+ // to the model as additionalContext (fix drift the moment it happens);
5
+ // (b) a source-file write matching a steering pathMatch → emit a POINTER to
6
+ // that steering file, once per session (a pointer, never the content —
7
+ // dynamic context loading at near-zero cost). Hits are journaled so the
8
+ // learner can expire steering that never fires.
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import process from 'node:process';
13
+ import { resolveRoots, readStdinJson, activeChange, appendJournal, toPosixRel } from './_shared.mjs';
14
+ import { parseFrontmatter } from './lib/frontmatter.mjs';
15
+ import { matchGlob } from './lib/glob.mjs';
16
+ import { validateChange, validateContext, formatIssues } from './lib/validate.mjs';
17
+
18
+ const { sdlcRoot, projectRoot } = resolveRoots(import.meta.url);
19
+
20
+ function respond(context) {
21
+ console.log(JSON.stringify({
22
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context },
23
+ }));
24
+ }
25
+
26
+ function validateSdlcWrite(rel) {
27
+ let issues = [];
28
+ const changeMatch = rel.match(/^sdlc\/changes\/([^/]+)\//);
29
+ if (changeMatch && changeMatch[1] !== 'archive') {
30
+ issues = validateChange(path.join(sdlcRoot, 'changes', changeMatch[1]), {
31
+ specsDir: path.join(sdlcRoot, 'specs'),
32
+ });
33
+ } else if (rel.startsWith('sdlc/context/') || rel === 'sdlc/harness.md') {
34
+ issues = validateContext(sdlcRoot);
35
+ }
36
+ if (issues.length) {
37
+ respond(`[sdlc validate]\n${formatIssues(issues)}\nFix errors before moving to the next stage.`);
38
+ }
39
+ }
40
+
41
+ function steeringPointer(rel, sessionId) {
42
+ const steeringDir = path.join(sdlcRoot, 'context', 'steering');
43
+ if (!fs.existsSync(steeringDir)) return;
44
+
45
+ const safeSession = String(sessionId ?? '').replace(/[^A-Za-z0-9_-]/g, '') || 'nosession';
46
+ const seenPath = path.join(sdlcRoot, '.state', `pointers-${safeSession}.json`);
47
+ let seen = [];
48
+ try { seen = JSON.parse(fs.readFileSync(seenPath, 'utf8')); } catch { /* first hit */ }
49
+
50
+ const hits = [];
51
+ for (const f of fs.readdirSync(steeringDir).filter((n) => n.endsWith('.md')).sort()) {
52
+ const { data } = parseFrontmatter(fs.readFileSync(path.join(steeringDir, f), 'utf8'));
53
+ if (data.inclusion !== 'paths' || !Array.isArray(data.pathMatch)) continue;
54
+ if (!matchGlob(rel, data.pathMatch)) continue;
55
+ appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: 'pointer', steering: f, file: rel });
56
+ if (!seen.includes(f)) hits.push(f);
57
+ }
58
+ if (!hits.length) return;
59
+
60
+ fs.mkdirSync(path.dirname(seenPath), { recursive: true });
61
+ fs.writeFileSync(seenPath, JSON.stringify([...seen, ...hits]));
62
+ respond(
63
+ 'Steering applies to this area — read before editing further: '
64
+ + hits.map((f) => `sdlc/context/steering/${f}`).join(', '),
65
+ );
66
+ }
67
+
68
+ async function main() {
69
+ const input = await readStdinJson();
70
+ const filePath = input?.tool_input?.file_path;
71
+ if (!filePath || !fs.existsSync(sdlcRoot)) return;
72
+
73
+ const rel = toPosixRel(projectRoot, path.resolve(projectRoot, filePath));
74
+ if (!rel) return;
75
+
76
+ if (rel.startsWith('sdlc/')) validateSdlcWrite(rel);
77
+ else steeringPointer(rel, input?.session_id);
78
+ }
79
+
80
+ main().catch(() => process.exit(0)); // fail open
@@ -0,0 +1,30 @@
1
+ # @warnyin/sdlc playbook
2
+
3
+ One change = one folder in `sdlc/changes/<id>/` moving through:
4
+
5
+ ```
6
+ new → [design] → contract → build → verify → [review] → ship
7
+ ```
8
+
9
+ | Command | Day-1 phase | Reads | Writes | Gate (automatic unless noted) |
10
+ |---|---|---|---|---|
11
+ | /sdlc:init | Configure harness | interview | constitution, harness.md | human approves (once) |
12
+ | /sdlc:auto | whole loop | — | everything below | escalation only |
13
+ | /sdlc:new | Requirements | specs Purpose headers | change.md | validator: delta + assumptions |
14
+ | /sdlc:design | Architecture | change + touched specs | change.md § Design | escalate irreversible only |
15
+ | /sdlc:contract | Contract-first | change.md | contract/*, failing tests | adversarial panel + validator |
16
+ | /sdlc:build | Run harness | change + contract + steering | code, task boxes | tasks done; specs locked by hook |
17
+ | /sdlc:verify | Feedback loop | contract | journal events | tests green AND evals ≥ bar |
18
+ | /sdlc:review | Review | diff + change | findings in change.md | blockers = 0 |
19
+ | /sdlc:ship | Ship | change | specs merge, archive, digest | validate --strict; policy may require human |
20
+ | /sdlc:observe | Observe | journals | report (chat) | — |
21
+ | /sdlc:converge | Maintenance | specs + code | proposed change | — |
22
+ | /sdlc:steer | Configure | context/ | steering, constitution | always-budget ≤ 60 |
23
+ | /sdlc:next | — | status | chat only | — |
24
+
25
+ Statuses: `new → contracted → building → verified → shipped`. Tiers: `vibe | standard | deep`
26
+ (triage table + Autonomy policy live in `sdlc/harness.md`).
27
+
28
+ Doctrine: `principles.md` (factory model, anti-garbage), `context.md` (static/dynamic),
29
+ `routing.md` (model tiers). Non-Claude harnesses: `rules-card.md` is embedded in your
30
+ tool's rules file; `npx @warnyin/sdlc validate` is the enforcement floor.
@@ -0,0 +1,21 @@
1
+ # /sdlc:auto <title> — the whole pipeline, one command
2
+
3
+ Runs new → [design] → contract → build → verify → [review] → ship, each stage by
4
+ its own playbook, WITHOUT pausing for the human except on the Autonomy-policy
5
+ escalation conditions:
6
+
7
+ - hard-floor surface (security, payments, data-loss, irreversible),
8
+ - a `[NEEDS CLARIFICATION]` the agent cannot resolve alone,
9
+ - verify failed more than 3 rounds,
10
+ - token budget exceeded (if the user set one),
11
+ - ship policy requires human approval (deep tier).
12
+
13
+ Rules:
14
+ - Announce the plan in ≤3 lines after triage (id, tier, task count), then work.
15
+ - Between stages run `npx @warnyin/sdlc validate <id>` — a red validator is a
16
+ hard stop for that stage, not a suggestion.
17
+ - On escalation: stop at the exact step, state what is needed in ≤5 lines, wait.
18
+ When the human answers, resume from that step — never restart the pipeline.
19
+ - On completion report one line: shipped + digest path + total cost if known.
20
+
21
+ This is orchestrator mode: the human describes the outcome and walks away.
@@ -0,0 +1,23 @@
1
+ # /sdlc:build <id> — implement (Run the harness)
2
+
3
+ Precondition: status ≥ contracted (vibe tier is exempt from contracts).
4
+ Set `status: building`.
5
+
6
+ Mode by size:
7
+ - **Conductor** (≤2 tasks): implement in this session, task by task.
8
+ - **Orchestrator** (>2 tasks): fan out one `sdlc-builder` subagent per task in a
9
+ `[P]` wave; serialize between dependent waves. Each builder receives ONLY:
10
+ its task line, `contract/tests.md`, the touched capability's spec, and steering
11
+ matching its file area — never the whole change history.
12
+
13
+ Rules for whoever implements:
14
+ - Follow steering pointers the moment the PostToolUse hook emits them.
15
+ - Never edit `sdlc/specs/**`, archive, journals, or lint/test configs to go
16
+ green — hooks deny the first two; the rest is the config-protection rule.
17
+ - Per-task self-check = that task's tests + lint only; the full test run belongs
18
+ to /sdlc:verify (moved, not removed).
19
+ - Tick `- [x]` in `## Tasks` as each task lands; note surprises in one line max.
20
+ - Honor `[tier:x]` markers when delegating (see routing.md).
21
+
22
+ Done when all tasks are ticked and the code compiles/lints.
23
+ `node sdlc/.hooks/journal.mjs note build tasks=<n>` then → /sdlc:verify.
@@ -0,0 +1,26 @@
1
+ # Context engineering
2
+
3
+ Six context types: instructions, knowledge, memory, examples, tools, guardrails.
4
+ The design decision is WHERE each lives: static (always loaded, expensive) vs
5
+ dynamic (loaded on demand, cheap). That boundary is versioned code, reviewed in PRs.
6
+
7
+ ## Static (injected every session by the SessionStart hook, hard cap 60 lines total)
8
+ - `sdlc/context/constitution.md` (≤30) — hard rules + stack facts only.
9
+ - `sdlc/context/steering/*.md` with `inclusion: always` — should be rare.
10
+ - One pointer line to the active change.
11
+
12
+ ## Dynamic (loaded only when needed)
13
+ - `inclusion: paths` steering — the PostToolUse hook emits a POINTER when an edited
14
+ file matches `pathMatch`; you then read the file. Pointers cost ~1 line, not 40.
15
+ - `inclusion: manual` — read only when a playbook or user names it.
16
+ - `inclusion: agent` — subagents load it themselves; the main loop never pays for it.
17
+ - Living specs — read the `## Purpose` header first; open the full spec only for
18
+ capabilities your change touches.
19
+ - Playbooks — each command reads exactly one playbook file.
20
+
21
+ ## Budget rules
22
+ - Always-budget (constitution + always-steering) ≤ 60 lines. Validator-enforced.
23
+ - Adding an always rule requires removing one, or demoting something to `paths`.
24
+ - `/sdlc:observe` reports steering that never fires — expire or demote it.
25
+ - A `compact` event in the journal means context overflowed: treat as a defect,
26
+ find the resident artifact that caused it.
@@ -0,0 +1,23 @@
1
+ # /sdlc:contract <id> — tests + evals before code
2
+
3
+ The contract IS the handshake. Nothing in `## Tasks` may be implemented while
4
+ status is `new`.
5
+
6
+ 1. From the Delta scenarios, write `contract/tests.md` (≤60 lines): one row per
7
+ behavior — Given/When/Then, kind, mapped requirement. List what is explicitly
8
+ out of scope and why.
9
+ 2. Deep tier (standard optional): write `contract/evals.md` (≤40 lines) — the
10
+ trajectory + quality rubric the sdlc-evaluator will score.
11
+ 3. Generate failing tests: delegate to the `sdlc-contractor` agent (cheap tier)
12
+ with ONLY tests.md + the delta + the project's test conventions. Run the test
13
+ command from `sdlc/harness.md` — every new test must FAIL (red) now; a test
14
+ that passes before implementation tests nothing.
15
+ 4. Adversarial check instead of human approval: ask the `sdlc-quality` agent to
16
+ attack the contract — uncovered scenarios, untestable rows, missing edge
17
+ cases vs the delta. Fix findings; one round is usually enough, two max.
18
+ 5. `npx @warnyin/sdlc validate <id>` clean → set frontmatter `status: contracted`
19
+ and `node sdlc/.hooks/journal.mjs note contract tests=<n>`.
20
+
21
+ Escalate only if the delta itself turns out ambiguous (back to /sdlc:new step 5).
22
+
23
+ Next: /sdlc:build.
@@ -0,0 +1,19 @@
1
+ # /sdlc:converge [capability] — spec ↔ code drift (Maintenance)
2
+
3
+ Living specs are only useful while they are true. Converge closes the gap.
4
+
5
+ 1. Scope: the named capability, or the ones `/sdlc:observe` flagged.
6
+ 2. For each requirement in `sdlc/specs/<capability>/spec.md`, check the code
7
+ actually behaves that way (read the relevant code; run targeted tests where
8
+ cheap). Three outcomes per requirement:
9
+ - **true** — nothing to do.
10
+ - **code drifted** — behavior no longer matches the spec.
11
+ - **spec stale** — the spec describes behavior nobody wants anymore.
12
+ 3. Report the diff table in chat (requirement · outcome · evidence file:line).
13
+ 4. For every drift/stale finding the user wants fixed, open ONE change via
14
+ /sdlc:new whose Delta uses MODIFIED/REMOVED against the exact requirement
15
+ names — converge itself NEVER edits specs or code directly.
16
+ 5. If a recent rule/playbook edit correlates with worse flywheel metrics
17
+ (observe shows it), propose the revert here as a change too.
18
+
19
+ Read-only except for creating proposed change folders.
@@ -0,0 +1,20 @@
1
+ # /sdlc:design <id> — decisions & trade-offs (deep tier, or on signal)
2
+
3
+ Run only when: tier is deep, OR the change needs an architectural decision
4
+ (new dependency, schema change, cross-capability contract). Otherwise skip —
5
+ an empty Design section is garbage.
6
+
7
+ 1. Read `change.md`, the full spec of every touched capability, and any steering
8
+ whose scope matches. Nothing else by default.
9
+ 2. Gather in parallel, judge serially: fan out read-only subagents for research
10
+ (one per question: prior art in this repo, external constraint, data shape).
11
+ The DECISION is made in the main loop — never delegated, never parallel.
12
+ 3. Fill `## Design` with decision lines only:
13
+ `- decision: <what> · alternatives: <a/b> · because: <why>`
14
+ Never restate the delta. Respect the tier cap (deep total ≤150).
15
+ 4. Escalate to the human ONLY for decisions listed in
16
+ `sdlc/harness.md § Autonomy policy` (irreversible or hard-floor). Everything
17
+ else: decide, record, move on.
18
+ 5. `npx @warnyin/sdlc validate <id>`.
19
+
20
+ Next: /sdlc:contract.
@@ -0,0 +1,22 @@
1
+ # /sdlc:init — configure the harness (run once per project)
2
+
3
+ The only planned blocking human gate in the framework: the human approves the
4
+ policy the AI will then drive under.
5
+
6
+ 1. Read `sdlc/config.yaml`, the repo README, manifest files (package.json etc.),
7
+ and skim the top-level structure. Do NOT deep-read the codebase.
8
+ 2. Interview the user briefly (≤6 questions): what the project is, hard rules the
9
+ agent must never break, test command, risk areas (security/payments/data-loss),
10
+ and how autonomous shipping should be (adjusts `## Autonomy policy`).
11
+ 3. Open the gate: `node sdlc/.hooks/journal.mjs open-steer`, then write:
12
+ - `sdlc/context/constitution.md` — replace template placeholders; ≤30 lines;
13
+ SHALL/SHALL NOT rules only, stack facts ≤3 lines.
14
+ - `sdlc/harness.md` — fill tools, test command, sandbox notes; adjust the
15
+ routing, triage, and Autonomy policy tables to this project.
16
+ - 0–3 steering seeds in `sdlc/context/steering/` for areas with real
17
+ conventions (prefer `inclusion: paths`; `always` needs strong justification).
18
+ 4. Run `npx @warnyin/sdlc validate` — fix every error.
19
+ 5. Show the user constitution + harness verbatim; iterate until approved.
20
+ 6. Close the gate: `node sdlc/.hooks/journal.mjs close`.
21
+
22
+ Output: approved constitution + harness. No change folder is created here.