@warnyin/sdlc 0.1.2 → 0.2.1

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 +66 -19
  2. package/LICENSE +21 -21
  3. package/README.md +83 -74
  4. package/bin/cli.mjs +103 -30
  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 -154
  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 -47
@@ -0,0 +1,147 @@
1
+ // A searchable multi-select checkbox prompt in ~120 lines of node:readline.
2
+ // The state machine is a pure reducer so the whole interaction is testable
3
+ // without a TTY; only `multiSelect()` touches the terminal.
4
+ // CLI-only: this must NOT move into lib/, which is copied verbatim into every
5
+ // installed project as sdlc/.hooks/lib/.
6
+
7
+ import readline from 'node:readline';
8
+ import { ESC, SYMBOLS } from './ui.mjs';
9
+
10
+ const MIN_ERROR = 'Select at least one tool — space toggles, enter confirms';
11
+
12
+ const freeze = (state) => Object.freeze(state);
13
+
14
+ export function initState(choices) {
15
+ return freeze({
16
+ choices,
17
+ selected: choices.filter((c) => c.preSelected).map((c) => c.value),
18
+ cursor: 0,
19
+ filter: '',
20
+ error: null,
21
+ status: 'idle',
22
+ });
23
+ }
24
+
25
+ export function visibleChoices(state) {
26
+ const query = state.filter.trim().toLowerCase();
27
+ if (!query) return state.choices;
28
+ return state.choices.filter(
29
+ (c) => c.name.toLowerCase().includes(query) || c.value.toLowerCase().includes(query),
30
+ );
31
+ }
32
+
33
+ // Every transition returns a new object — the caller compares by identity to
34
+ // decide whether a repaint is needed.
35
+ const next = (state, patch) => freeze({ ...state, ...patch, error: null });
36
+
37
+ function toggle(selected, value) {
38
+ return selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value];
39
+ }
40
+
41
+ function isPrintable(key) {
42
+ return !key.ctrl && !key.meta && typeof key.sequence === 'string'
43
+ && key.sequence.length === 1 && key.sequence >= ' ' && key.sequence.charCodeAt(0) !== 127;
44
+ }
45
+
46
+ export function reduceKey(state, key = {}) {
47
+ if (state.status !== 'idle') return state;
48
+ const visible = visibleChoices(state);
49
+ const count = visible.length;
50
+
51
+ if (key.ctrl && key.name === 'c') return freeze({ ...state, status: 'cancelled' });
52
+
53
+ // ctrl+a acts on what is on screen, so it composes with the filter rather
54
+ // than silently selecting hidden rows.
55
+ if (key.ctrl && key.name === 'a') {
56
+ if (!count) return state;
57
+ const values = visible.map((c) => c.value);
58
+ const allOn = values.every((v) => state.selected.includes(v));
59
+ const selected = allOn
60
+ ? state.selected.filter((v) => !values.includes(v))
61
+ : [...state.selected, ...values.filter((v) => !state.selected.includes(v))];
62
+ return next(state, { selected });
63
+ }
64
+
65
+ switch (key.name) {
66
+ case 'up':
67
+ return count ? next(state, { cursor: (state.cursor - 1 + count) % count }) : state;
68
+ case 'down':
69
+ return count ? next(state, { cursor: (state.cursor + 1) % count }) : state;
70
+ case 'space':
71
+ return count ? next(state, { selected: toggle(state.selected, visible[state.cursor].value) }) : state;
72
+ case 'return':
73
+ if (!state.selected.length) return freeze({ ...state, error: MIN_ERROR });
74
+ return freeze({ ...state, status: 'done', error: null });
75
+ case 'backspace':
76
+ return state.filter ? next(state, { filter: state.filter.slice(0, -1), cursor: 0 }) : state;
77
+ case 'escape':
78
+ return state.filter ? next(state, { filter: '', cursor: 0 }) : state;
79
+ default:
80
+ break;
81
+ }
82
+
83
+ if (isPrintable(key)) return next(state, { filter: state.filter + key.sequence, cursor: 0 });
84
+ return state;
85
+ }
86
+
87
+ export function renderLines(state, style, symbols = SYMBOLS.unicode) {
88
+ const visible = visibleChoices(state);
89
+ const head = state.filter
90
+ ? `${style.cyan('?')} ${style.bold('Select tools to set up')} ${style.dim(`filter: ${state.filter}`)}`
91
+ : `${style.cyan('?')} ${style.bold('Select tools to set up')}`;
92
+
93
+ // Notes line up in their own column; a row without one keeps no trailing space.
94
+ const width = visible.reduce((w, c) => Math.max(w, c.name.length), 0);
95
+ const rows = visible.map((choice, i) => {
96
+ const focused = i === state.cursor;
97
+ const box = state.selected.includes(choice.value) ? style.green(symbols.on) : symbols.off;
98
+ const note = choice.note ? `${' '.repeat(width - choice.name.length + 2)}${style.dim(choice.note)}` : '';
99
+ const label = focused ? style.cyan(choice.name) : choice.name;
100
+ return `${focused ? style.cyan(symbols.pointer) : ' '} ${box} ${label}${note}`;
101
+ });
102
+ if (!rows.length) rows.push(` ${style.dim('no tool matches this filter')}`);
103
+
104
+ const lines = [head, ...rows, style.dim(' space toggle · ctrl+a all · type to filter · enter confirm')];
105
+ if (state.error) lines.push(style.yellow(` ${state.error}`));
106
+ return lines;
107
+ }
108
+
109
+ // Resolves to the selected values, or null when the user cancels.
110
+ export function multiSelect({ choices, style, symbols, input = process.stdin, output = process.stdout }) {
111
+ return new Promise((resolve) => {
112
+ let state = initState(choices);
113
+ let height = 0;
114
+
115
+ const erase = () => { if (height) output.write(`${ESC}[${height}A${ESC}[0J`); };
116
+ const draw = () => {
117
+ const lines = renderLines(state, style, symbols);
118
+ erase();
119
+ output.write(`${lines.join('\n')}\n`);
120
+ height = lines.length;
121
+ };
122
+
123
+ const finish = (result) => {
124
+ input.off('keypress', onKey);
125
+ if (input.isTTY) input.setRawMode(false);
126
+ input.pause();
127
+ erase();
128
+ output.write(`${ESC}[?25h`);
129
+ resolve(result);
130
+ };
131
+
132
+ const onKey = (_str, key) => {
133
+ const updated = reduceKey(state, key ?? {});
134
+ if (updated === state) return;
135
+ state = updated;
136
+ if (state.status === 'idle') { draw(); return; }
137
+ finish(state.status === 'done' ? state.selected : null);
138
+ };
139
+
140
+ readline.emitKeypressEvents(input);
141
+ if (input.isTTY) input.setRawMode(true);
142
+ input.resume();
143
+ input.on('keypress', onKey);
144
+ output.write(`${ESC}[?25l`);
145
+ draw();
146
+ });
147
+ }
package/bin/ui.mjs ADDED
@@ -0,0 +1,78 @@
1
+ // Terminal presentation for the installer: colour gating, glyphs, and the
2
+ // post-install summary. Zero dependencies — the ANSI we need is a dozen codes.
3
+ // CLI-only: this must NOT move into lib/, which is copied verbatim into every
4
+ // installed project as sdlc/.hooks/lib/.
5
+
6
+ import { ADAPTER_PATHS, toolName } from './detect.mjs';
7
+
8
+ export const ESC = String.fromCharCode(27);
9
+
10
+ const CODES = Object.freeze({
11
+ bold: '1', dim: '2', red: '31', green: '32', yellow: '33', cyan: '36',
12
+ });
13
+
14
+ // NO_COLOR wins over FORCE_COLOR: opting out of colour is never overridden by
15
+ // something else in the environment (no-color.org).
16
+ export function colorEnabled(env = process.env, isTty = Boolean(process.stdout.isTTY)) {
17
+ if (env.NO_COLOR) return false;
18
+ if (env.FORCE_COLOR) return true;
19
+ return Boolean(isTty);
20
+ }
21
+
22
+ export function createStyle(enabled) {
23
+ const wrap = (code) => (text) => (enabled ? `${ESC}[${code}m${text}${ESC}[0m` : String(text));
24
+ return Object.freeze(Object.fromEntries(Object.entries(CODES).map(([name, code]) => [name, wrap(code)])));
25
+ }
26
+
27
+ export const SYMBOLS = Object.freeze({
28
+ unicode: Object.freeze({ on: '◉', off: '◯', pointer: '❯', tick: '✔', warn: '!' }),
29
+ ascii: Object.freeze({ on: '[x]', off: '[ ]', pointer: '>', tick: 'v', warn: '!' }),
30
+ });
31
+
32
+ // Legacy Windows consoles render box-drawing glyphs as mojibake; Windows
33
+ // Terminal and every unix terminal set one of these.
34
+ export function symbolsFor(env = process.env, platform = process.platform) {
35
+ if (platform !== 'win32') return SYMBOLS.unicode;
36
+ return env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuANSI ? SYMBOLS.unicode : SYMBOLS.ascii;
37
+ }
38
+
39
+ // ---------- post-install summary ----------
40
+
41
+ const under = (keys, prefix) => keys.filter((k) => k.startsWith(prefix));
42
+
43
+ export function summarizeInstall(manifestKeys, tools) {
44
+ const keys = [...manifestKeys];
45
+ const hookFiles = under(keys, 'sdlc/.hooks/').filter((k) => !k.startsWith('sdlc/.hooks/lib/'));
46
+ const templates = under(keys, 'sdlc/.playbook/templates/');
47
+ const playbook = under(keys, 'sdlc/.playbook/').filter((k) => !k.startsWith('sdlc/.playbook/templates/'));
48
+ const skillDirs = new Set(under(keys, '.claude/skills/').map((k) => k.split('/')[2]));
49
+
50
+ return Object.freeze({
51
+ commands: under(keys, '.claude/commands/').length,
52
+ skills: skillDirs.size,
53
+ agents: under(keys, '.claude/agents/').length,
54
+ hooks: hookFiles.length,
55
+ playbook: playbook.length,
56
+ templates: templates.length,
57
+ adapters: tools.map((tool) => ({ tool, path: ADAPTER_PATHS[tool] })),
58
+ });
59
+ }
60
+
61
+ // What to actually type next, per tool. Tools without a slash-command surface
62
+ // get a prose instruction instead of a command that would not resolve.
63
+ export function startHints(tools) {
64
+ const hints = [];
65
+ if (tools.includes('claude')) {
66
+ hints.push('Run /sdlc:init in Claude Code — it writes your constitution and harness');
67
+ hints.push('Then start your first change: /sdlc:new "your idea"');
68
+ }
69
+ const rest = tools.filter((t) => t !== 'claude');
70
+ for (const tool of rest) {
71
+ hints.push(`Open ${toolName(tool)} and ask it to follow sdlc/.playbook/init.md, then sdlc/.playbook/new.md`);
72
+ }
73
+ if (!hints.length) {
74
+ hints.push('Read sdlc/.playbook/README.md — the stage doctrine is tool-independent');
75
+ hints.push('Add an agent tool any time: npx @warnyin/sdlc update --tool claude');
76
+ }
77
+ return hints;
78
+ }
package/lib/caps.mjs CHANGED
@@ -1,45 +1,45 @@
1
- // Canonical line-cap table — the single source of truth for artifact
2
- // residency budgets. Templates quote these numbers in HTML comments and
3
- // tests/caps-sync.test.mjs asserts both stay identical.
4
- //
5
- // Rationale (Day-1 economics): every resident line is paid for in every
6
- // turn. Caps make "learning = distilling" structural, not disciplinary.
7
-
8
- import { parseFrontmatter } from './frontmatter.mjs';
9
-
10
- export const CAPS = Object.freeze({
11
- constitution: 30, // sdlc/context/constitution.md — always loaded
12
- steeringFile: 40, // each sdlc/context/steering/*.md
13
- alwaysBudget: 60, // constitution + all `inclusion: always` steering, combined
14
- harness: 60, // sdlc/harness.md
15
- spec: 150, // sdlc/specs/<capability>/spec.md (soft — split capability beyond)
16
- change: Object.freeze({ vibe: 40, standard: 100, deep: 150 }),
17
- contractTests: 60, // changes/<id>/contract/tests.md
18
- contractEvals: 40, // changes/<id>/contract/evals.md
19
- });
20
-
21
- export const TIERS = Object.freeze(['vibe', 'standard', 'deep']);
22
-
23
- export const STATUSES = Object.freeze([
24
- 'new', 'contracted', 'building', 'verified', 'shipped',
25
- ]);
26
-
27
- // Effective lines = body lines after frontmatter, excluding blanks and
28
- // single-line HTML comments. Caps meter prose the model must carry, not
29
- // machine metadata or annotation comments.
30
- export function countEffectiveLines(text) {
31
- const { body } = parseFrontmatter(text ?? '');
32
- return body
33
- .split(/\r?\n/)
34
- .filter((line) => {
35
- const t = line.trim();
36
- if (t === '') return false;
37
- if (t.startsWith('<!--') && t.endsWith('-->')) return false;
38
- return true;
39
- })
40
- .length;
41
- }
42
-
43
- export function capForChange(tier) {
44
- return CAPS.change[tier] ?? CAPS.change.standard;
45
- }
1
+ // Canonical line-cap table — the single source of truth for artifact
2
+ // residency budgets. Templates quote these numbers in HTML comments and
3
+ // tests/caps-sync.test.mjs asserts both stay identical.
4
+ //
5
+ // Rationale (Day-1 economics): every resident line is paid for in every
6
+ // turn. Caps make "learning = distilling" structural, not disciplinary.
7
+
8
+ import { parseFrontmatter } from './frontmatter.mjs';
9
+
10
+ export const CAPS = Object.freeze({
11
+ constitution: 30, // sdlc/context/constitution.md — always loaded
12
+ steeringFile: 40, // each sdlc/context/steering/*.md
13
+ alwaysBudget: 60, // constitution + all `inclusion: always` steering, combined
14
+ harness: 60, // sdlc/harness.md
15
+ spec: 150, // sdlc/specs/<capability>/spec.md (soft — split capability beyond)
16
+ change: Object.freeze({ vibe: 40, standard: 100, deep: 150 }),
17
+ contractTests: 60, // changes/<id>/contract/tests.md
18
+ contractEvals: 40, // changes/<id>/contract/evals.md
19
+ });
20
+
21
+ export const TIERS = Object.freeze(['vibe', 'standard', 'deep']);
22
+
23
+ export const STATUSES = Object.freeze([
24
+ 'new', 'contracted', 'building', 'verified', 'shipped',
25
+ ]);
26
+
27
+ // Effective lines = body lines after frontmatter, excluding blanks and
28
+ // single-line HTML comments. Caps meter prose the model must carry, not
29
+ // machine metadata or annotation comments.
30
+ export function countEffectiveLines(text) {
31
+ const { body } = parseFrontmatter(text ?? '');
32
+ return body
33
+ .split(/\r?\n/)
34
+ .filter((line) => {
35
+ const t = line.trim();
36
+ if (t === '') return false;
37
+ if (t.startsWith('<!--') && t.endsWith('-->')) return false;
38
+ return true;
39
+ })
40
+ .length;
41
+ }
42
+
43
+ export function capForChange(tier) {
44
+ return CAPS.change[tier] ?? CAPS.change.standard;
45
+ }
package/lib/config.mjs CHANGED
@@ -1,41 +1,41 @@
1
- // Minimal reader for sdlc/config.yaml — supports exactly what the template
2
- // documents: scalar keys, inline arrays, and a `prices:` block of inline
3
- // objects. Anything else is ignored (never crash a hook on config).
4
-
5
- export function parseConfig(text) {
6
- const config = { language: 'en', tools: [], prices: null };
7
- const lines = (text ?? '').split(/\r?\n/);
8
- let inPrices = false;
9
- for (const raw of lines) {
10
- if (!raw.trim() || raw.trim().startsWith('#')) continue;
11
- const isIndented = /^\s/.test(raw);
12
- if (!isIndented) inPrices = false;
13
-
14
- if (inPrices) {
15
- const m = raw.match(/^\s+([^:#]+):\s*\{(.*)\}\s*$/);
16
- if (!m) continue;
17
- const model = m[1].trim();
18
- const obj = {};
19
- for (const part of m[2].split(',')) {
20
- const kv = part.split(':');
21
- if (kv.length !== 2) continue;
22
- const num = Number(kv[1].trim());
23
- if (!Number.isNaN(num)) obj[kv[0].trim()] = num;
24
- }
25
- config.prices = { ...(config.prices ?? {}), [model]: obj };
26
- continue;
27
- }
28
-
29
- const kv = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
30
- if (!kv) continue;
31
- const [, key, valueRaw] = kv;
32
- const value = valueRaw.replace(/\s+#.*$/, '').trim();
33
- if (key === 'prices' && value === '') { inPrices = true; continue; }
34
- if (value.startsWith('[') && value.endsWith(']')) {
35
- config[key] = value.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
36
- } else if (value !== '') {
37
- config[key] = value;
38
- }
39
- }
40
- return config;
41
- }
1
+ // Minimal reader for sdlc/config.yaml — supports exactly what the template
2
+ // documents: scalar keys, inline arrays, and a `prices:` block of inline
3
+ // objects. Anything else is ignored (never crash a hook on config).
4
+
5
+ export function parseConfig(text) {
6
+ const config = { language: 'en', tools: [], prices: null };
7
+ const lines = (text ?? '').split(/\r?\n/);
8
+ let inPrices = false;
9
+ for (const raw of lines) {
10
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
11
+ const isIndented = /^\s/.test(raw);
12
+ if (!isIndented) inPrices = false;
13
+
14
+ if (inPrices) {
15
+ const m = raw.match(/^\s+([^:#]+):\s*\{(.*)\}\s*$/);
16
+ if (!m) continue;
17
+ const model = m[1].trim();
18
+ const obj = {};
19
+ for (const part of m[2].split(',')) {
20
+ const kv = part.split(':');
21
+ if (kv.length !== 2) continue;
22
+ const num = Number(kv[1].trim());
23
+ if (!Number.isNaN(num)) obj[kv[0].trim()] = num;
24
+ }
25
+ config.prices = { ...(config.prices ?? {}), [model]: obj };
26
+ continue;
27
+ }
28
+
29
+ const kv = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
30
+ if (!kv) continue;
31
+ const [, key, valueRaw] = kv;
32
+ const value = valueRaw.replace(/\s+#.*$/, '').trim();
33
+ if (key === 'prices' && value === '') { inPrices = true; continue; }
34
+ if (value.startsWith('[') && value.endsWith(']')) {
35
+ config[key] = value.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
36
+ } else if (value !== '') {
37
+ config[key] = value;
38
+ }
39
+ }
40
+ return config;
41
+ }