@warnyin/sdlc 0.1.0 → 0.2.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 (83) hide show
  1. package/CHANGELOG.md +53 -19
  2. package/LICENSE +21 -21
  3. package/README.md +83 -74
  4. package/bin/cli.mjs +111 -31
  5. package/bin/detect.mjs +51 -0
  6. package/bin/multiselect.mjs +147 -0
  7. package/bin/ui.mjs +78 -0
  8. package/lib/caps.mjs +45 -45
  9. package/lib/config.mjs +41 -41
  10. package/lib/delta.mjs +160 -160
  11. package/lib/frontmatter.mjs +59 -59
  12. package/lib/glob.mjs +29 -29
  13. package/lib/manifest.mjs +99 -99
  14. package/lib/observe.mjs +174 -174
  15. package/lib/settings-merge.mjs +63 -63
  16. package/lib/usage.mjs +46 -46
  17. package/lib/validate.mjs +186 -186
  18. package/package.json +42 -42
  19. package/payload/adapters/agents-md.md +8 -8
  20. package/payload/adapters/claude/agents/sdlc-architect.md +12 -12
  21. package/payload/adapters/claude/agents/sdlc-builder.md +14 -14
  22. package/payload/adapters/claude/agents/sdlc-contractor.md +13 -13
  23. package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -13
  24. package/payload/adapters/claude/agents/sdlc-learner.md +16 -16
  25. package/payload/adapters/claude/agents/sdlc-ops.md +11 -11
  26. package/payload/adapters/claude/agents/sdlc-quality.md +13 -13
  27. package/payload/adapters/claude/agents/sdlc-security.md +12 -12
  28. package/payload/adapters/claude/commands/sdlc/auto.md +5 -5
  29. package/payload/adapters/claude/commands/sdlc/build.md +5 -5
  30. package/payload/adapters/claude/commands/sdlc/contract.md +5 -5
  31. package/payload/adapters/claude/commands/sdlc/converge.md +5 -5
  32. package/payload/adapters/claude/commands/sdlc/design.md +5 -5
  33. package/payload/adapters/claude/commands/sdlc/init.md +4 -4
  34. package/payload/adapters/claude/commands/sdlc/new.md +5 -5
  35. package/payload/adapters/claude/commands/sdlc/next.md +4 -4
  36. package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
  37. package/payload/adapters/claude/commands/sdlc/review.md +5 -5
  38. package/payload/adapters/claude/commands/sdlc/ship.md +5 -5
  39. package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
  40. package/payload/adapters/claude/commands/sdlc/verify.md +5 -5
  41. package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
  42. package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +33 -33
  43. package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +26 -26
  44. package/payload/adapters/cline.md +8 -8
  45. package/payload/adapters/copilot.md +8 -8
  46. package/payload/adapters/cursor.mdc +7 -7
  47. package/payload/adapters/gemini.md +8 -8
  48. package/payload/adapters/windsurf.md +4 -4
  49. package/payload/hooks/_shared.mjs +154 -141
  50. package/payload/hooks/guard-writes.mjs +83 -83
  51. package/payload/hooks/inject-context.mjs +55 -55
  52. package/payload/hooks/journal.mjs +58 -58
  53. package/payload/hooks/session-summary.mjs +50 -50
  54. package/payload/hooks/validate-artifact.mjs +80 -80
  55. package/payload/playbook/README.md +30 -30
  56. package/payload/playbook/auto.md +21 -21
  57. package/payload/playbook/build.md +23 -23
  58. package/payload/playbook/context.md +26 -26
  59. package/payload/playbook/contract.md +23 -23
  60. package/payload/playbook/converge.md +19 -19
  61. package/payload/playbook/design.md +20 -20
  62. package/payload/playbook/init.md +22 -22
  63. package/payload/playbook/new.md +22 -22
  64. package/payload/playbook/next.md +12 -12
  65. package/payload/playbook/observe.md +20 -20
  66. package/payload/playbook/principles.md +28 -28
  67. package/payload/playbook/review.md +17 -17
  68. package/payload/playbook/routing.md +19 -19
  69. package/payload/playbook/rules-card.md +16 -16
  70. package/payload/playbook/ship.md +24 -24
  71. package/payload/playbook/steer.md +21 -21
  72. package/payload/playbook/verify.md +24 -24
  73. package/payload/templates/change-deep.md +29 -29
  74. package/payload/templates/change-standard.md +28 -28
  75. package/payload/templates/change-vibe.md +19 -19
  76. package/payload/templates/config.yaml +8 -8
  77. package/payload/templates/constitution.md +14 -14
  78. package/payload/templates/contract-evals.md +9 -9
  79. package/payload/templates/contract-tests.md +9 -9
  80. package/payload/templates/harness.md +33 -33
  81. package/payload/templates/spec.md +14 -14
  82. package/payload/templates/steering.md +9 -9
  83. package/scripts/validate.mjs +47 -38
@@ -1,63 +1,63 @@
1
- // Non-destructive management of our hook entries inside the project's
2
- // .claude/settings.json. Ownership marker: any hook command that references
3
- // `sdlc/.hooks/` is ours; everything else is the user's and is never touched.
4
- // Merge is idempotent: remove ours, re-add current set, preserve the rest.
5
-
6
- const OWNERSHIP_MARKER = 'sdlc/.hooks/';
7
-
8
- const hookCmd = (script, extraArgs = '') =>
9
- `node "$CLAUDE_PROJECT_DIR/sdlc/.hooks/${script}"${extraArgs ? ' ' + extraArgs : ''}`;
10
-
11
- export function sdlcHookEntries() {
12
- return {
13
- SessionStart: [
14
- { hooks: [{ type: 'command', command: hookCmd('inject-context.mjs') }] },
15
- ],
16
- PreToolUse: [
17
- {
18
- matcher: 'Edit|Write|MultiEdit|NotebookEdit',
19
- hooks: [{ type: 'command', command: hookCmd('guard-writes.mjs') }],
20
- },
21
- ],
22
- PostToolUse: [
23
- {
24
- matcher: 'Edit|Write|MultiEdit',
25
- hooks: [{ type: 'command', command: hookCmd('validate-artifact.mjs') }],
26
- },
27
- ],
28
- Stop: [
29
- { hooks: [{ type: 'command', command: hookCmd('session-summary.mjs') }] },
30
- ],
31
- PreCompact: [
32
- { hooks: [{ type: 'command', command: hookCmd('journal.mjs', 'note compact') }] },
33
- ],
34
- };
35
- }
36
-
37
- function isOurs(matcherEntry) {
38
- return (matcherEntry?.hooks ?? []).some(
39
- (h) => typeof h?.command === 'string' && h.command.includes(OWNERSHIP_MARKER),
40
- );
41
- }
42
-
43
- // settingsJson: parsed object (or {}). Returns a NEW object (immutability).
44
- export function mergeHookSettings(settingsJson) {
45
- const settings = structuredClone(settingsJson ?? {});
46
- const hooks = { ...(settings.hooks ?? {}) };
47
- for (const [event, entries] of Object.entries(sdlcHookEntries())) {
48
- const existing = (hooks[event] ?? []).filter((e) => !isOurs(e));
49
- hooks[event] = [...existing, ...entries];
50
- }
51
- return { ...settings, hooks };
52
- }
53
-
54
- export function removeHookSettings(settingsJson) {
55
- const settings = structuredClone(settingsJson ?? {});
56
- if (!settings.hooks) return settings;
57
- const hooks = {};
58
- for (const [event, entries] of Object.entries(settings.hooks)) {
59
- const kept = entries.filter((e) => !isOurs(e));
60
- if (kept.length) hooks[event] = kept;
61
- }
62
- return { ...settings, hooks };
63
- }
1
+ // Non-destructive management of our hook entries inside the project's
2
+ // .claude/settings.json. Ownership marker: any hook command that references
3
+ // `sdlc/.hooks/` is ours; everything else is the user's and is never touched.
4
+ // Merge is idempotent: remove ours, re-add current set, preserve the rest.
5
+
6
+ const OWNERSHIP_MARKER = 'sdlc/.hooks/';
7
+
8
+ const hookCmd = (script, extraArgs = '') =>
9
+ `node "$CLAUDE_PROJECT_DIR/sdlc/.hooks/${script}"${extraArgs ? ' ' + extraArgs : ''}`;
10
+
11
+ export function sdlcHookEntries() {
12
+ return {
13
+ SessionStart: [
14
+ { hooks: [{ type: 'command', command: hookCmd('inject-context.mjs') }] },
15
+ ],
16
+ PreToolUse: [
17
+ {
18
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
19
+ hooks: [{ type: 'command', command: hookCmd('guard-writes.mjs') }],
20
+ },
21
+ ],
22
+ PostToolUse: [
23
+ {
24
+ matcher: 'Edit|Write|MultiEdit',
25
+ hooks: [{ type: 'command', command: hookCmd('validate-artifact.mjs') }],
26
+ },
27
+ ],
28
+ Stop: [
29
+ { hooks: [{ type: 'command', command: hookCmd('session-summary.mjs') }] },
30
+ ],
31
+ PreCompact: [
32
+ { hooks: [{ type: 'command', command: hookCmd('journal.mjs', 'note compact') }] },
33
+ ],
34
+ };
35
+ }
36
+
37
+ function isOurs(matcherEntry) {
38
+ return (matcherEntry?.hooks ?? []).some(
39
+ (h) => typeof h?.command === 'string' && h.command.includes(OWNERSHIP_MARKER),
40
+ );
41
+ }
42
+
43
+ // settingsJson: parsed object (or {}). Returns a NEW object (immutability).
44
+ export function mergeHookSettings(settingsJson) {
45
+ const settings = structuredClone(settingsJson ?? {});
46
+ const hooks = { ...(settings.hooks ?? {}) };
47
+ for (const [event, entries] of Object.entries(sdlcHookEntries())) {
48
+ const existing = (hooks[event] ?? []).filter((e) => !isOurs(e));
49
+ hooks[event] = [...existing, ...entries];
50
+ }
51
+ return { ...settings, hooks };
52
+ }
53
+
54
+ export function removeHookSettings(settingsJson) {
55
+ const settings = structuredClone(settingsJson ?? {});
56
+ if (!settings.hooks) return settings;
57
+ const hooks = {};
58
+ for (const [event, entries] of Object.entries(settings.hooks)) {
59
+ const kept = entries.filter((e) => !isOurs(e));
60
+ if (kept.length) hooks[event] = kept;
61
+ }
62
+ return { ...settings, hooks };
63
+ }
package/lib/usage.mjs CHANGED
@@ -1,46 +1,46 @@
1
- // Transcript-usage parser: Claude Code transcripts are JSONL; assistant
2
- // entries carry `message.usage` and `message.model`. We sum per model and
3
- // price via the optional table in sdlc/config.yaml. Never guess: when a
4
- // price is unknown, cost stays null (reported as n/a).
5
-
6
- export function parseTranscriptUsage(jsonlText) {
7
- const byModel = new Map();
8
- for (const line of (jsonlText ?? '').split('\n')) {
9
- if (!line.trim()) continue;
10
- let entry;
11
- try { entry = JSON.parse(line); } catch { continue; }
12
- const msg = entry?.message;
13
- const usage = msg?.usage;
14
- if (!usage || typeof usage !== 'object') continue;
15
- const model = msg.model ?? 'unknown';
16
- const acc = byModel.get(model) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
17
- acc.input += usage.input_tokens ?? 0;
18
- acc.output += usage.output_tokens ?? 0;
19
- acc.cacheRead += usage.cache_read_input_tokens ?? 0;
20
- acc.cacheWrite += usage.cache_creation_input_tokens ?? 0;
21
- byModel.set(model, acc);
22
- }
23
- const models = Object.fromEntries(byModel);
24
- const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
25
- for (const m of byModel.values()) {
26
- totals.input += m.input; totals.output += m.output;
27
- totals.cacheRead += m.cacheRead; totals.cacheWrite += m.cacheWrite;
28
- }
29
- return { models, totals };
30
- }
31
-
32
- // prices: { "<model>": { input, output, cacheRead } } in USD per 1M tokens.
33
- export function costUsd(usage, prices) {
34
- if (!prices) return null;
35
- let usd = 0;
36
- let priced = false;
37
- for (const [model, u] of Object.entries(usage.models)) {
38
- const p = prices[model];
39
- if (!p) continue;
40
- priced = true;
41
- usd += (u.input * (p.input ?? 0)
42
- + u.output * (p.output ?? 0)
43
- + u.cacheRead * (p.cacheRead ?? 0)) / 1_000_000;
44
- }
45
- return priced ? Number(usd.toFixed(4)) : null;
46
- }
1
+ // Transcript-usage parser: Claude Code transcripts are JSONL; assistant
2
+ // entries carry `message.usage` and `message.model`. We sum per model and
3
+ // price via the optional table in sdlc/config.yaml. Never guess: when a
4
+ // price is unknown, cost stays null (reported as n/a).
5
+
6
+ export function parseTranscriptUsage(jsonlText) {
7
+ const byModel = new Map();
8
+ for (const line of (jsonlText ?? '').split('\n')) {
9
+ if (!line.trim()) continue;
10
+ let entry;
11
+ try { entry = JSON.parse(line); } catch { continue; }
12
+ const msg = entry?.message;
13
+ const usage = msg?.usage;
14
+ if (!usage || typeof usage !== 'object') continue;
15
+ const model = msg.model ?? 'unknown';
16
+ const acc = byModel.get(model) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
17
+ acc.input += usage.input_tokens ?? 0;
18
+ acc.output += usage.output_tokens ?? 0;
19
+ acc.cacheRead += usage.cache_read_input_tokens ?? 0;
20
+ acc.cacheWrite += usage.cache_creation_input_tokens ?? 0;
21
+ byModel.set(model, acc);
22
+ }
23
+ const models = Object.fromEntries(byModel);
24
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
25
+ for (const m of byModel.values()) {
26
+ totals.input += m.input; totals.output += m.output;
27
+ totals.cacheRead += m.cacheRead; totals.cacheWrite += m.cacheWrite;
28
+ }
29
+ return { models, totals };
30
+ }
31
+
32
+ // prices: { "<model>": { input, output, cacheRead } } in USD per 1M tokens.
33
+ export function costUsd(usage, prices) {
34
+ if (!prices) return null;
35
+ let usd = 0;
36
+ let priced = false;
37
+ for (const [model, u] of Object.entries(usage.models)) {
38
+ const p = prices[model];
39
+ if (!p) continue;
40
+ priced = true;
41
+ usd += (u.input * (p.input ?? 0)
42
+ + u.output * (p.output ?? 0)
43
+ + u.cacheRead * (p.cacheRead ?? 0)) / 1_000_000;
44
+ }
45
+ return priced ? Number(usd.toFixed(4)) : null;
46
+ }
package/lib/validate.mjs CHANGED
@@ -1,186 +1,186 @@
1
- // Structural validator — the tool-agnostic enforcement floor.
2
- // Used by: CLI (`warnyin-sdlc validate`), CI, and the PostToolUse hook.
3
- // Exit codes: 0 = clean (warnings allowed), 1 = errors found, 2 = usage/setup error.
4
-
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { parseFrontmatter } from './frontmatter.mjs';
8
- import { CAPS, TIERS, STATUSES, countEffectiveLines, capForChange } from './caps.mjs';
9
- import { parseDelta, parseSpec } from './delta.mjs';
10
-
11
- const CLARIFICATION_RE = /\[NEEDS CLARIFICATION/g;
12
-
13
- export function statusRank(status) {
14
- const i = STATUSES.indexOf(status);
15
- return i === -1 ? 0 : i;
16
- }
17
-
18
- function issue(level, where, msg) {
19
- return { level, where, msg };
20
- }
21
-
22
- // ---------- change validation ----------
23
-
24
- export function validateChange(changeDir, { strict = false, specsDir = null } = {}) {
25
- const issues = [];
26
- const id = path.basename(changeDir);
27
- const changePath = path.join(changeDir, 'change.md');
28
- if (!fs.existsSync(changePath)) {
29
- return [issue('error', id, 'change.md is missing')];
30
- }
31
- const text = fs.readFileSync(changePath, 'utf8');
32
- const { data } = parseFrontmatter(text);
33
-
34
- if (!data.id) issues.push(issue('error', id, 'frontmatter: missing id'));
35
- else if (data.id !== id) issues.push(issue('error', id, `frontmatter id "${data.id}" != folder name "${id}"`));
36
- if (!TIERS.includes(data.tier)) issues.push(issue('error', id, `frontmatter: tier must be one of ${TIERS.join('|')}`));
37
- if (!STATUSES.includes(data.status)) issues.push(issue('error', id, `frontmatter: status must be one of ${STATUSES.join('|')}`));
38
-
39
- const tier = TIERS.includes(data.tier) ? data.tier : 'standard';
40
- const status = STATUSES.includes(data.status) ? data.status : 'new';
41
-
42
- const lines = countEffectiveLines(text);
43
- const cap = capForChange(tier);
44
- if (lines > cap) issues.push(issue('error', id, `change.md is ${lines} effective lines (cap for ${tier}: ${cap})`));
45
-
46
- const markers = (text.match(CLARIFICATION_RE) ?? []).length;
47
- if (markers > 0) {
48
- const level = strict || status !== 'new' ? 'error' : 'warn';
49
- issues.push(issue(level, id, `${markers} unresolved [NEEDS CLARIFICATION] marker(s)`));
50
- }
51
-
52
- const { deltas, errors: deltaErrors } = parseDelta(text);
53
- for (const e of deltaErrors) issues.push(issue('error', id, `delta: ${e}`));
54
- if (tier !== 'vibe' && deltas.length === 0) {
55
- issues.push(issue('warn', id, 'no ## Delta section — spec-driven changes should state their behavior delta'));
56
- }
57
-
58
- // MODIFIED/REMOVED must target requirements that exist in living specs.
59
- if (specsDir) {
60
- for (const d of deltas) {
61
- const specPath = path.join(specsDir, d.capability, 'spec.md');
62
- const spec = fs.existsSync(specPath) ? parseSpec(fs.readFileSync(specPath, 'utf8')) : null;
63
- const names = new Set((spec?.requirements ?? []).map((r) => r.name.toLowerCase()));
64
- for (const op of d.ops) {
65
- if ((op.op === 'MODIFIED' || op.op === 'REMOVED') && !names.has(op.name.toLowerCase())) {
66
- issues.push(issue(strict ? 'error' : 'warn', id,
67
- `${op.op} Requirement "${op.name}" not found in specs/${d.capability}/spec.md`));
68
- }
69
- }
70
- }
71
- }
72
-
73
- // Contract requirements by status/tier.
74
- const testsPath = path.join(changeDir, 'contract', 'tests.md');
75
- const evalsPath = path.join(changeDir, 'contract', 'evals.md');
76
- if (tier !== 'vibe' && statusRank(status) >= statusRank('contracted') && !fs.existsSync(testsPath)) {
77
- issues.push(issue('error', id, `status "${status}" requires contract/tests.md`));
78
- }
79
- if (tier === 'deep' && statusRank(status) >= statusRank('contracted') && !fs.existsSync(evalsPath)) {
80
- issues.push(issue('error', id, 'deep tier requires contract/evals.md'));
81
- }
82
- if (fs.existsSync(testsPath)) {
83
- const n = countEffectiveLines(fs.readFileSync(testsPath, 'utf8'));
84
- if (n > CAPS.contractTests) issues.push(issue('error', id, `contract/tests.md is ${n} lines (cap ${CAPS.contractTests})`));
85
- }
86
- if (fs.existsSync(evalsPath)) {
87
- const n = countEffectiveLines(fs.readFileSync(evalsPath, 'utf8'));
88
- if (n > CAPS.contractEvals) issues.push(issue('error', id, `contract/evals.md is ${n} lines (cap ${CAPS.contractEvals})`));
89
- }
90
-
91
- return issues;
92
- }
93
-
94
- // ---------- context / harness validation ----------
95
-
96
- export function validateContext(sdlcRoot) {
97
- const issues = [];
98
- const constitutionPath = path.join(sdlcRoot, 'context', 'constitution.md');
99
- let alwaysLines = 0;
100
-
101
- if (fs.existsSync(constitutionPath)) {
102
- const n = countEffectiveLines(fs.readFileSync(constitutionPath, 'utf8'));
103
- alwaysLines += n;
104
- if (n > CAPS.constitution) {
105
- issues.push(issue('error', 'context', `constitution.md is ${n} lines (cap ${CAPS.constitution})`));
106
- }
107
- } else {
108
- issues.push(issue('warn', 'context', 'constitution.md is missing (run /sdlc:init)'));
109
- }
110
-
111
- const steeringDir = path.join(sdlcRoot, 'context', 'steering');
112
- if (fs.existsSync(steeringDir)) {
113
- for (const f of fs.readdirSync(steeringDir).filter((f) => f.endsWith('.md')).sort()) {
114
- const raw = fs.readFileSync(path.join(steeringDir, f), 'utf8');
115
- const { data } = parseFrontmatter(raw);
116
- const n = countEffectiveLines(raw);
117
- if (n > CAPS.steeringFile) {
118
- issues.push(issue('error', `steering/${f}`, `${n} lines (cap ${CAPS.steeringFile})`));
119
- }
120
- const mode = data.inclusion ?? 'manual';
121
- if (!['always', 'paths', 'manual', 'agent'].includes(mode)) {
122
- issues.push(issue('error', `steering/${f}`, `inclusion "${mode}" must be always|paths|manual|agent`));
123
- }
124
- if (mode === 'paths' && !Array.isArray(data.pathMatch)) {
125
- issues.push(issue('error', `steering/${f}`, 'inclusion: paths requires pathMatch: ["glob", ...]'));
126
- }
127
- if (mode === 'always') alwaysLines += n;
128
- }
129
- }
130
-
131
- if (alwaysLines > CAPS.alwaysBudget) {
132
- issues.push(issue('error', 'context',
133
- `always-loaded budget is ${alwaysLines} lines (cap ${CAPS.alwaysBudget}) — demote steering or distill the constitution`));
134
- }
135
-
136
- const harnessPath = path.join(sdlcRoot, 'harness.md');
137
- if (fs.existsSync(harnessPath)) {
138
- const n = countEffectiveLines(fs.readFileSync(harnessPath, 'utf8'));
139
- if (n > CAPS.harness) issues.push(issue('error', 'harness', `harness.md is ${n} lines (cap ${CAPS.harness})`));
140
- }
141
-
142
- const specsDir = path.join(sdlcRoot, 'specs');
143
- if (fs.existsSync(specsDir)) {
144
- for (const cap of fs.readdirSync(specsDir, { withFileTypes: true }).filter((d) => d.isDirectory())) {
145
- const specPath = path.join(specsDir, cap.name, 'spec.md');
146
- if (!fs.existsSync(specPath)) continue;
147
- const n = countEffectiveLines(fs.readFileSync(specPath, 'utf8'));
148
- if (n > CAPS.spec) {
149
- issues.push(issue('warn', `specs/${cap.name}`, `spec.md is ${n} lines (soft cap ${CAPS.spec}) — consider splitting the capability`));
150
- }
151
- }
152
- }
153
-
154
- return issues;
155
- }
156
-
157
- export function listChangeDirs(sdlcRoot) {
158
- const changesDir = path.join(sdlcRoot, 'changes');
159
- if (!fs.existsSync(changesDir)) return [];
160
- return fs.readdirSync(changesDir, { withFileTypes: true })
161
- .filter((d) => d.isDirectory() && d.name !== 'archive')
162
- .map((d) => path.join(changesDir, d.name))
163
- .sort();
164
- }
165
-
166
- export function validateAll(sdlcRoot, { strict = false, changeId = null } = {}) {
167
- const specsDir = path.join(sdlcRoot, 'specs');
168
- const issues = [...validateContext(sdlcRoot)];
169
- const dirs = changeId
170
- ? [path.join(sdlcRoot, 'changes', changeId)]
171
- : listChangeDirs(sdlcRoot);
172
- for (const dir of dirs) {
173
- if (!fs.existsSync(dir)) {
174
- issues.push(issue('error', path.basename(dir), 'change folder not found'));
175
- continue;
176
- }
177
- issues.push(...validateChange(dir, { strict, specsDir }));
178
- }
179
- return issues;
180
- }
181
-
182
- export function formatIssues(issues) {
183
- return issues
184
- .map((i) => `${i.level === 'error' ? '✖' : '⚠'} [${i.where}] ${i.msg}`)
185
- .join('\n');
186
- }
1
+ // Structural validator — the tool-agnostic enforcement floor.
2
+ // Used by: CLI (`warnyin-sdlc validate`), CI, and the PostToolUse hook.
3
+ // Exit codes: 0 = clean (warnings allowed), 1 = errors found, 2 = usage/setup error.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { parseFrontmatter } from './frontmatter.mjs';
8
+ import { CAPS, TIERS, STATUSES, countEffectiveLines, capForChange } from './caps.mjs';
9
+ import { parseDelta, parseSpec } from './delta.mjs';
10
+
11
+ const CLARIFICATION_RE = /\[NEEDS CLARIFICATION/g;
12
+
13
+ export function statusRank(status) {
14
+ const i = STATUSES.indexOf(status);
15
+ return i === -1 ? 0 : i;
16
+ }
17
+
18
+ function issue(level, where, msg) {
19
+ return { level, where, msg };
20
+ }
21
+
22
+ // ---------- change validation ----------
23
+
24
+ export function validateChange(changeDir, { strict = false, specsDir = null } = {}) {
25
+ const issues = [];
26
+ const id = path.basename(changeDir);
27
+ const changePath = path.join(changeDir, 'change.md');
28
+ if (!fs.existsSync(changePath)) {
29
+ return [issue('error', id, 'change.md is missing')];
30
+ }
31
+ const text = fs.readFileSync(changePath, 'utf8');
32
+ const { data } = parseFrontmatter(text);
33
+
34
+ if (!data.id) issues.push(issue('error', id, 'frontmatter: missing id'));
35
+ else if (data.id !== id) issues.push(issue('error', id, `frontmatter id "${data.id}" != folder name "${id}"`));
36
+ if (!TIERS.includes(data.tier)) issues.push(issue('error', id, `frontmatter: tier must be one of ${TIERS.join('|')}`));
37
+ if (!STATUSES.includes(data.status)) issues.push(issue('error', id, `frontmatter: status must be one of ${STATUSES.join('|')}`));
38
+
39
+ const tier = TIERS.includes(data.tier) ? data.tier : 'standard';
40
+ const status = STATUSES.includes(data.status) ? data.status : 'new';
41
+
42
+ const lines = countEffectiveLines(text);
43
+ const cap = capForChange(tier);
44
+ if (lines > cap) issues.push(issue('error', id, `change.md is ${lines} effective lines (cap for ${tier}: ${cap})`));
45
+
46
+ const markers = (text.match(CLARIFICATION_RE) ?? []).length;
47
+ if (markers > 0) {
48
+ const level = strict || status !== 'new' ? 'error' : 'warn';
49
+ issues.push(issue(level, id, `${markers} unresolved [NEEDS CLARIFICATION] marker(s)`));
50
+ }
51
+
52
+ const { deltas, errors: deltaErrors } = parseDelta(text);
53
+ for (const e of deltaErrors) issues.push(issue('error', id, `delta: ${e}`));
54
+ if (tier !== 'vibe' && deltas.length === 0) {
55
+ issues.push(issue('warn', id, 'no ## Delta section — spec-driven changes should state their behavior delta'));
56
+ }
57
+
58
+ // MODIFIED/REMOVED must target requirements that exist in living specs.
59
+ if (specsDir) {
60
+ for (const d of deltas) {
61
+ const specPath = path.join(specsDir, d.capability, 'spec.md');
62
+ const spec = fs.existsSync(specPath) ? parseSpec(fs.readFileSync(specPath, 'utf8')) : null;
63
+ const names = new Set((spec?.requirements ?? []).map((r) => r.name.toLowerCase()));
64
+ for (const op of d.ops) {
65
+ if ((op.op === 'MODIFIED' || op.op === 'REMOVED') && !names.has(op.name.toLowerCase())) {
66
+ issues.push(issue(strict ? 'error' : 'warn', id,
67
+ `${op.op} Requirement "${op.name}" not found in specs/${d.capability}/spec.md`));
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ // Contract requirements by status/tier.
74
+ const testsPath = path.join(changeDir, 'contract', 'tests.md');
75
+ const evalsPath = path.join(changeDir, 'contract', 'evals.md');
76
+ if (tier !== 'vibe' && statusRank(status) >= statusRank('contracted') && !fs.existsSync(testsPath)) {
77
+ issues.push(issue('error', id, `status "${status}" requires contract/tests.md`));
78
+ }
79
+ if (tier === 'deep' && statusRank(status) >= statusRank('contracted') && !fs.existsSync(evalsPath)) {
80
+ issues.push(issue('error', id, 'deep tier requires contract/evals.md'));
81
+ }
82
+ if (fs.existsSync(testsPath)) {
83
+ const n = countEffectiveLines(fs.readFileSync(testsPath, 'utf8'));
84
+ if (n > CAPS.contractTests) issues.push(issue('error', id, `contract/tests.md is ${n} lines (cap ${CAPS.contractTests})`));
85
+ }
86
+ if (fs.existsSync(evalsPath)) {
87
+ const n = countEffectiveLines(fs.readFileSync(evalsPath, 'utf8'));
88
+ if (n > CAPS.contractEvals) issues.push(issue('error', id, `contract/evals.md is ${n} lines (cap ${CAPS.contractEvals})`));
89
+ }
90
+
91
+ return issues;
92
+ }
93
+
94
+ // ---------- context / harness validation ----------
95
+
96
+ export function validateContext(sdlcRoot) {
97
+ const issues = [];
98
+ const constitutionPath = path.join(sdlcRoot, 'context', 'constitution.md');
99
+ let alwaysLines = 0;
100
+
101
+ if (fs.existsSync(constitutionPath)) {
102
+ const n = countEffectiveLines(fs.readFileSync(constitutionPath, 'utf8'));
103
+ alwaysLines += n;
104
+ if (n > CAPS.constitution) {
105
+ issues.push(issue('error', 'context', `constitution.md is ${n} lines (cap ${CAPS.constitution})`));
106
+ }
107
+ } else {
108
+ issues.push(issue('warn', 'context', 'constitution.md is missing (run /sdlc:init)'));
109
+ }
110
+
111
+ const steeringDir = path.join(sdlcRoot, 'context', 'steering');
112
+ if (fs.existsSync(steeringDir)) {
113
+ for (const f of fs.readdirSync(steeringDir).filter((f) => f.endsWith('.md')).sort()) {
114
+ const raw = fs.readFileSync(path.join(steeringDir, f), 'utf8');
115
+ const { data } = parseFrontmatter(raw);
116
+ const n = countEffectiveLines(raw);
117
+ if (n > CAPS.steeringFile) {
118
+ issues.push(issue('error', `steering/${f}`, `${n} lines (cap ${CAPS.steeringFile})`));
119
+ }
120
+ const mode = data.inclusion ?? 'manual';
121
+ if (!['always', 'paths', 'manual', 'agent'].includes(mode)) {
122
+ issues.push(issue('error', `steering/${f}`, `inclusion "${mode}" must be always|paths|manual|agent`));
123
+ }
124
+ if (mode === 'paths' && !Array.isArray(data.pathMatch)) {
125
+ issues.push(issue('error', `steering/${f}`, 'inclusion: paths requires pathMatch: ["glob", ...]'));
126
+ }
127
+ if (mode === 'always') alwaysLines += n;
128
+ }
129
+ }
130
+
131
+ if (alwaysLines > CAPS.alwaysBudget) {
132
+ issues.push(issue('error', 'context',
133
+ `always-loaded budget is ${alwaysLines} lines (cap ${CAPS.alwaysBudget}) — demote steering or distill the constitution`));
134
+ }
135
+
136
+ const harnessPath = path.join(sdlcRoot, 'harness.md');
137
+ if (fs.existsSync(harnessPath)) {
138
+ const n = countEffectiveLines(fs.readFileSync(harnessPath, 'utf8'));
139
+ if (n > CAPS.harness) issues.push(issue('error', 'harness', `harness.md is ${n} lines (cap ${CAPS.harness})`));
140
+ }
141
+
142
+ const specsDir = path.join(sdlcRoot, 'specs');
143
+ if (fs.existsSync(specsDir)) {
144
+ for (const cap of fs.readdirSync(specsDir, { withFileTypes: true }).filter((d) => d.isDirectory())) {
145
+ const specPath = path.join(specsDir, cap.name, 'spec.md');
146
+ if (!fs.existsSync(specPath)) continue;
147
+ const n = countEffectiveLines(fs.readFileSync(specPath, 'utf8'));
148
+ if (n > CAPS.spec) {
149
+ issues.push(issue('warn', `specs/${cap.name}`, `spec.md is ${n} lines (soft cap ${CAPS.spec}) — consider splitting the capability`));
150
+ }
151
+ }
152
+ }
153
+
154
+ return issues;
155
+ }
156
+
157
+ export function listChangeDirs(sdlcRoot) {
158
+ const changesDir = path.join(sdlcRoot, 'changes');
159
+ if (!fs.existsSync(changesDir)) return [];
160
+ return fs.readdirSync(changesDir, { withFileTypes: true })
161
+ .filter((d) => d.isDirectory() && d.name !== 'archive')
162
+ .map((d) => path.join(changesDir, d.name))
163
+ .sort();
164
+ }
165
+
166
+ export function validateAll(sdlcRoot, { strict = false, changeId = null } = {}) {
167
+ const specsDir = path.join(sdlcRoot, 'specs');
168
+ const issues = [...validateContext(sdlcRoot)];
169
+ const dirs = changeId
170
+ ? [path.join(sdlcRoot, 'changes', changeId)]
171
+ : listChangeDirs(sdlcRoot);
172
+ for (const dir of dirs) {
173
+ if (!fs.existsSync(dir)) {
174
+ issues.push(issue('error', path.basename(dir), 'change folder not found'));
175
+ continue;
176
+ }
177
+ issues.push(...validateChange(dir, { strict, specsDir }));
178
+ }
179
+ return issues;
180
+ }
181
+
182
+ export function formatIssues(issues) {
183
+ return issues
184
+ .map((i) => `${i.level === 'error' ? '✖' : '⚠'} [${i.where}] ${i.msg}`)
185
+ .join('\n');
186
+ }