@warnyin/sdlc 0.8.0 → 0.10.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 (76) hide show
  1. package/CHANGELOG.md +293 -258
  2. package/LICENSE +21 -21
  3. package/README.md +118 -92
  4. package/bin/cli.mjs +694 -682
  5. package/lib/active.mjs +199 -199
  6. package/lib/caps.mjs +46 -46
  7. package/lib/config.mjs +41 -41
  8. package/lib/delta.mjs +227 -227
  9. package/lib/frontmatter.mjs +59 -59
  10. package/lib/glob.mjs +29 -29
  11. package/lib/lenses.mjs +48 -48
  12. package/lib/manifest.mjs +99 -99
  13. package/lib/settings-merge.mjs +63 -63
  14. package/lib/skills.mjs +148 -148
  15. package/lib/update-notice.mjs +42 -0
  16. package/lib/validate.mjs +198 -198
  17. package/lib/version.mjs +23 -0
  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/converge.md +5 -5
  29. package/payload/adapters/claude/commands/sdlc/init.md +4 -4
  30. package/payload/adapters/claude/commands/sdlc/next.md +4 -4
  31. package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
  32. package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
  33. package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
  34. package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +36 -36
  35. package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +30 -30
  36. package/payload/adapters/cline.md +8 -8
  37. package/payload/adapters/copilot.md +8 -8
  38. package/payload/adapters/cursor.mdc +7 -7
  39. package/payload/adapters/gemini.md +8 -8
  40. package/payload/adapters/windsurf.md +4 -4
  41. package/payload/hooks/_shared.mjs +138 -138
  42. package/payload/hooks/_update-notice.mjs +81 -0
  43. package/payload/hooks/check-update.mjs +45 -0
  44. package/payload/hooks/guard-writes.mjs +87 -87
  45. package/payload/hooks/inject-context.mjs +61 -57
  46. package/payload/hooks/journal.mjs +66 -66
  47. package/payload/hooks/session-summary.mjs +52 -52
  48. package/payload/hooks/validate-artifact.mjs +84 -84
  49. package/payload/playbook/README.md +32 -32
  50. package/payload/playbook/context.md +26 -26
  51. package/payload/playbook/contract.md +29 -29
  52. package/payload/playbook/converge.md +19 -19
  53. package/payload/playbook/design.md +28 -28
  54. package/payload/playbook/init.md +22 -22
  55. package/payload/playbook/lenses.md +64 -64
  56. package/payload/playbook/new.md +41 -33
  57. package/payload/playbook/next.md +24 -24
  58. package/payload/playbook/observe.md +20 -20
  59. package/payload/playbook/principles.md +28 -28
  60. package/payload/playbook/review.md +31 -31
  61. package/payload/playbook/routing.md +19 -19
  62. package/payload/playbook/rules-card.md +17 -16
  63. package/payload/playbook/ship.md +35 -35
  64. package/payload/playbook/steer.md +21 -21
  65. package/payload/playbook/verify.md +42 -42
  66. package/payload/templates/change-deep.md +29 -29
  67. package/payload/templates/change-standard.md +28 -28
  68. package/payload/templates/change-vibe.md +19 -19
  69. package/payload/templates/config.yaml +12 -8
  70. package/payload/templates/constitution.md +14 -14
  71. package/payload/templates/contract-evals.md +9 -9
  72. package/payload/templates/contract-tests.md +9 -9
  73. package/payload/templates/harness.md +34 -34
  74. package/payload/templates/spec.md +14 -14
  75. package/payload/templates/steering.md +9 -9
  76. package/scripts/validate.mjs +47 -47
package/lib/active.mjs CHANGED
@@ -1,199 +1,199 @@
1
- // Active change — which change a session (and the project as a whole) is working on.
2
- //
3
- // Two pointers exist so two sessions never overwrite each other's focus: a per-session
4
- // file under `.state/sessions/<id>.json`, and a project-wide fallback at `.state/active.json`
5
- // for tools (and past sessions) that never set one. Resolution tries session, then
6
- // project, then the most-recently-edited open change — never an error: a pointer that
7
- // is missing, stale, or malformed just falls through to the next source.
8
- //
9
- // Shared by the CLI and the installed hooks, so `node:*` only.
10
-
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import { isSafeChangeId } from './journal.mjs';
14
-
15
- // A session id reaches us from `CLAUDE_CODE_SESSION_ID` (shell) or stdin `session_id`
16
- // (hooks) — both outside our control. It becomes a filename, so it is held to the same
17
- // single-safe-segment rule as a change id rather than a separate one: two id kinds, one
18
- // hazard (path traversal / device names / ADS colons), one rule to keep in sync.
19
- export function isSafeSessionId(id) {
20
- return isSafeChangeId(id);
21
- }
22
-
23
- // stdin is the documented, per-invocation channel; the env var is inherited from a
24
- // parent process and may be stale or belong to someone else. No safety check here —
25
- // this is a raw pick, the caller validates before turning it into a path.
26
- export function pickSessionId(stdinSessionId, envSessionId) {
27
- if (typeof stdinSessionId === 'string' && stdinSessionId.length > 0) return stdinSessionId;
28
- if (typeof envSessionId === 'string' && envSessionId.length > 0) return envSessionId;
29
- return null;
30
- }
31
-
32
- // `null` for an unsafe id, so callers fall back to the project pointer instead of
33
- // writing or reading somewhere surprising.
34
- export function sessionPointerPath(sdlcRoot, sessionId) {
35
- if (!isSafeSessionId(sessionId)) return null;
36
- return path.join(sdlcRoot, '.state', 'sessions', `${sessionId}.json`);
37
- }
38
-
39
- export function projectPointerPath(sdlcRoot) {
40
- return path.join(sdlcRoot, '.state', 'active.json');
41
- }
42
-
43
- // One rule for "this id names an open change", used both when a pointer is read and before
44
- // one is written: a single safe segment, not the archive folder in any case (`ARCHIVE` opens
45
- // it on Windows and macOS), and a folder that exists.
46
- export function isOpenChange(sdlcRoot, change) {
47
- if (typeof change !== 'string' || change.toLowerCase() === 'archive' || !isSafeChangeId(change)) return false;
48
- try {
49
- return fs.statSync(path.join(sdlcRoot, 'changes', change)).isDirectory();
50
- } catch {
51
- return false;
52
- }
53
- }
54
-
55
- // A pointer path is trusted only when its real location is the one it claims. `.state/` is
56
- // gitignored but not unwritable — a repo can ship a link there — and a planted link at
57
- // `.state`, `.state/sessions` or the pointer file itself would otherwise carry a read or a
58
- // write out of the project. Missing paths fail too; callers only ask about existing ones.
59
- function isRealPathInside(sdlcRoot, target) {
60
- try {
61
- const expected = path.join(fs.realpathSync.native(sdlcRoot), path.relative(sdlcRoot, target));
62
- return fs.realpathSync.native(target) === expected;
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
- // Reads a pointer file and returns the change id it names, or null if the file is
69
- // missing, malformed, names something unsafe/archived, the change folder is gone, or the
70
- // file is not really where it claims to be. A stale, corrupt or redirected pointer must
71
- // never throw — it is just evidence the caller ignores.
72
- function readPointerChange(sdlcRoot, filePath) {
73
- if (!filePath || !isRealPathInside(sdlcRoot, filePath)) return null;
74
- try {
75
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
76
- return isOpenChange(sdlcRoot, data?.change) ? data.change : null;
77
- } catch {
78
- return null;
79
- }
80
- }
81
-
82
- // The last-resort source: no pointer answers, so fall back to whichever open change was
83
- // touched most recently. Never reported as this session's or the project's own choice.
84
- function mostRecentOpenChange(sdlcRoot) {
85
- const changesDir = path.join(sdlcRoot, 'changes');
86
- let entries;
87
- try {
88
- entries = fs.readdirSync(changesDir, { withFileTypes: true });
89
- } catch {
90
- return null;
91
- }
92
- let best = null;
93
- for (const d of entries) {
94
- if (!d.isDirectory() || d.name === 'archive') continue;
95
- const p = path.join(changesDir, d.name, 'change.md');
96
- let mtime;
97
- try {
98
- mtime = fs.statSync(p).mtimeMs;
99
- } catch {
100
- continue;
101
- }
102
- if (!best || mtime > best.mtime) best = { change: d.name, mtime };
103
- }
104
- return best?.change ?? null;
105
- }
106
-
107
- // Session pointer, then project pointer, then the most-recently-edited open change.
108
- // Fails open at every step: a thrown error anywhere resolves to null rather than
109
- // surfacing to a hook, which must never block a session over a state-file glitch.
110
- export function resolveActive(sdlcRoot, { sessionId } = {}) {
111
- try {
112
- if (isSafeSessionId(sessionId)) {
113
- const change = readPointerChange(sdlcRoot, sessionPointerPath(sdlcRoot, sessionId));
114
- if (change) return { change, source: 'session' };
115
- }
116
- const projectChange = readPointerChange(sdlcRoot, projectPointerPath(sdlcRoot));
117
- if (projectChange) return { change: projectChange, source: 'project' };
118
- const recent = mostRecentOpenChange(sdlcRoot);
119
- if (recent) return { change: recent, source: 'recent' };
120
- return null;
121
- } catch {
122
- return null;
123
- }
124
- }
125
-
126
- // Creates `dir` one segment at a time below `sdlcRoot`, checking each segment before the
127
- // next is made: a recursive mkdir would first create `sessions/` inside whatever a planted
128
- // `.state` link points at, and only then could the check notice.
129
- function ensureRealDir(sdlcRoot, dir) {
130
- let cur = sdlcRoot;
131
- for (const seg of path.relative(sdlcRoot, dir).split(path.sep)) {
132
- cur = path.join(cur, seg);
133
- if (!hasEntry(cur)) fs.mkdirSync(cur);
134
- if (!isRealPathInside(sdlcRoot, cur)) return false;
135
- }
136
- return true;
137
- }
138
-
139
- // Whether ANY directory entry sits at `p`, dangling links included. `existsSync` follows the
140
- // link and reports a dangling one as absent — and `writeFileSync` would then create the
141
- // link's target, wherever it points.
142
- function hasEntry(p) {
143
- try {
144
- fs.lstatSync(p);
145
- return true;
146
- } catch {
147
- return false;
148
- }
149
- }
150
-
151
- // Writes one pointer, refusing when its directory — or any entry already at the path,
152
- // dangling link included — is really somewhere else. `writeFileSync` follows links, so the
153
- // check has to come first.
154
- function writePointer(sdlcRoot, filePath, change) {
155
- try {
156
- if (!ensureRealDir(sdlcRoot, path.dirname(filePath))) return false;
157
- if (hasEntry(filePath) && !isRealPathInside(sdlcRoot, filePath)) return false;
158
- fs.writeFileSync(filePath, JSON.stringify({ change }));
159
- return true;
160
- } catch {
161
- return false;
162
- }
163
- }
164
-
165
- // Writes the project-wide fallback, and the session pointer only when the id is safe, so
166
- // an unsafe id can never cause a file to be created anywhere. A redirected `.state` or
167
- // `.state/sessions` makes the matching write a no-op rather than a write out of the project.
168
- export function writeActive(sdlcRoot, change, { sessionId } = {}) {
169
- const project = writePointer(sdlcRoot, projectPointerPath(sdlcRoot), change);
170
- const sessionPath = sessionPointerPath(sdlcRoot, sessionId);
171
- const session = sessionPath ? writePointer(sdlcRoot, sessionPath, change) : false;
172
- return { project, session };
173
- }
174
-
175
- // Releases every pointer naming `changeId`. Called at ship, after the folder has moved, so the
176
- // id no longer resolves and is matched by name instead. Only real files inside `.state/` are
177
- // read or removed — a planted link is left alone, never followed. Never throws: the ship has
178
- // already happened by the time this runs.
179
- export function clearPointersFor(sdlcRoot, changeId) {
180
- const candidates = [projectPointerPath(sdlcRoot)];
181
- const sessionsDir = path.join(sdlcRoot, '.state', 'sessions');
182
- if (isRealPathInside(sdlcRoot, sessionsDir)) {
183
- try {
184
- for (const f of fs.readdirSync(sessionsDir)) {
185
- if (f.endsWith('.json')) candidates.push(path.join(sessionsDir, f));
186
- }
187
- } catch { /* nothing to release */ }
188
- }
189
- let released = 0;
190
- for (const p of candidates) {
191
- try {
192
- if (!isRealPathInside(sdlcRoot, p)) continue;
193
- if (JSON.parse(fs.readFileSync(p, 'utf8'))?.change !== changeId) continue;
194
- fs.rmSync(p);
195
- released += 1;
196
- } catch { /* missing, malformed or vanished — leave it */ }
197
- }
198
- return released;
199
- }
1
+ // Active change — which change a session (and the project as a whole) is working on.
2
+ //
3
+ // Two pointers exist so two sessions never overwrite each other's focus: a per-session
4
+ // file under `.state/sessions/<id>.json`, and a project-wide fallback at `.state/active.json`
5
+ // for tools (and past sessions) that never set one. Resolution tries session, then
6
+ // project, then the most-recently-edited open change — never an error: a pointer that
7
+ // is missing, stale, or malformed just falls through to the next source.
8
+ //
9
+ // Shared by the CLI and the installed hooks, so `node:*` only.
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { isSafeChangeId } from './journal.mjs';
14
+
15
+ // A session id reaches us from `CLAUDE_CODE_SESSION_ID` (shell) or stdin `session_id`
16
+ // (hooks) — both outside our control. It becomes a filename, so it is held to the same
17
+ // single-safe-segment rule as a change id rather than a separate one: two id kinds, one
18
+ // hazard (path traversal / device names / ADS colons), one rule to keep in sync.
19
+ export function isSafeSessionId(id) {
20
+ return isSafeChangeId(id);
21
+ }
22
+
23
+ // stdin is the documented, per-invocation channel; the env var is inherited from a
24
+ // parent process and may be stale or belong to someone else. No safety check here —
25
+ // this is a raw pick, the caller validates before turning it into a path.
26
+ export function pickSessionId(stdinSessionId, envSessionId) {
27
+ if (typeof stdinSessionId === 'string' && stdinSessionId.length > 0) return stdinSessionId;
28
+ if (typeof envSessionId === 'string' && envSessionId.length > 0) return envSessionId;
29
+ return null;
30
+ }
31
+
32
+ // `null` for an unsafe id, so callers fall back to the project pointer instead of
33
+ // writing or reading somewhere surprising.
34
+ export function sessionPointerPath(sdlcRoot, sessionId) {
35
+ if (!isSafeSessionId(sessionId)) return null;
36
+ return path.join(sdlcRoot, '.state', 'sessions', `${sessionId}.json`);
37
+ }
38
+
39
+ export function projectPointerPath(sdlcRoot) {
40
+ return path.join(sdlcRoot, '.state', 'active.json');
41
+ }
42
+
43
+ // One rule for "this id names an open change", used both when a pointer is read and before
44
+ // one is written: a single safe segment, not the archive folder in any case (`ARCHIVE` opens
45
+ // it on Windows and macOS), and a folder that exists.
46
+ export function isOpenChange(sdlcRoot, change) {
47
+ if (typeof change !== 'string' || change.toLowerCase() === 'archive' || !isSafeChangeId(change)) return false;
48
+ try {
49
+ return fs.statSync(path.join(sdlcRoot, 'changes', change)).isDirectory();
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ // A pointer path is trusted only when its real location is the one it claims. `.state/` is
56
+ // gitignored but not unwritable — a repo can ship a link there — and a planted link at
57
+ // `.state`, `.state/sessions` or the pointer file itself would otherwise carry a read or a
58
+ // write out of the project. Missing paths fail too; callers only ask about existing ones.
59
+ function isRealPathInside(sdlcRoot, target) {
60
+ try {
61
+ const expected = path.join(fs.realpathSync.native(sdlcRoot), path.relative(sdlcRoot, target));
62
+ return fs.realpathSync.native(target) === expected;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Reads a pointer file and returns the change id it names, or null if the file is
69
+ // missing, malformed, names something unsafe/archived, the change folder is gone, or the
70
+ // file is not really where it claims to be. A stale, corrupt or redirected pointer must
71
+ // never throw — it is just evidence the caller ignores.
72
+ function readPointerChange(sdlcRoot, filePath) {
73
+ if (!filePath || !isRealPathInside(sdlcRoot, filePath)) return null;
74
+ try {
75
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
76
+ return isOpenChange(sdlcRoot, data?.change) ? data.change : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ // The last-resort source: no pointer answers, so fall back to whichever open change was
83
+ // touched most recently. Never reported as this session's or the project's own choice.
84
+ function mostRecentOpenChange(sdlcRoot) {
85
+ const changesDir = path.join(sdlcRoot, 'changes');
86
+ let entries;
87
+ try {
88
+ entries = fs.readdirSync(changesDir, { withFileTypes: true });
89
+ } catch {
90
+ return null;
91
+ }
92
+ let best = null;
93
+ for (const d of entries) {
94
+ if (!d.isDirectory() || d.name === 'archive') continue;
95
+ const p = path.join(changesDir, d.name, 'change.md');
96
+ let mtime;
97
+ try {
98
+ mtime = fs.statSync(p).mtimeMs;
99
+ } catch {
100
+ continue;
101
+ }
102
+ if (!best || mtime > best.mtime) best = { change: d.name, mtime };
103
+ }
104
+ return best?.change ?? null;
105
+ }
106
+
107
+ // Session pointer, then project pointer, then the most-recently-edited open change.
108
+ // Fails open at every step: a thrown error anywhere resolves to null rather than
109
+ // surfacing to a hook, which must never block a session over a state-file glitch.
110
+ export function resolveActive(sdlcRoot, { sessionId } = {}) {
111
+ try {
112
+ if (isSafeSessionId(sessionId)) {
113
+ const change = readPointerChange(sdlcRoot, sessionPointerPath(sdlcRoot, sessionId));
114
+ if (change) return { change, source: 'session' };
115
+ }
116
+ const projectChange = readPointerChange(sdlcRoot, projectPointerPath(sdlcRoot));
117
+ if (projectChange) return { change: projectChange, source: 'project' };
118
+ const recent = mostRecentOpenChange(sdlcRoot);
119
+ if (recent) return { change: recent, source: 'recent' };
120
+ return null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ // Creates `dir` one segment at a time below `sdlcRoot`, checking each segment before the
127
+ // next is made: a recursive mkdir would first create `sessions/` inside whatever a planted
128
+ // `.state` link points at, and only then could the check notice.
129
+ function ensureRealDir(sdlcRoot, dir) {
130
+ let cur = sdlcRoot;
131
+ for (const seg of path.relative(sdlcRoot, dir).split(path.sep)) {
132
+ cur = path.join(cur, seg);
133
+ if (!hasEntry(cur)) fs.mkdirSync(cur);
134
+ if (!isRealPathInside(sdlcRoot, cur)) return false;
135
+ }
136
+ return true;
137
+ }
138
+
139
+ // Whether ANY directory entry sits at `p`, dangling links included. `existsSync` follows the
140
+ // link and reports a dangling one as absent — and `writeFileSync` would then create the
141
+ // link's target, wherever it points.
142
+ function hasEntry(p) {
143
+ try {
144
+ fs.lstatSync(p);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ // Writes one pointer, refusing when its directory — or any entry already at the path,
152
+ // dangling link included — is really somewhere else. `writeFileSync` follows links, so the
153
+ // check has to come first.
154
+ function writePointer(sdlcRoot, filePath, change) {
155
+ try {
156
+ if (!ensureRealDir(sdlcRoot, path.dirname(filePath))) return false;
157
+ if (hasEntry(filePath) && !isRealPathInside(sdlcRoot, filePath)) return false;
158
+ fs.writeFileSync(filePath, JSON.stringify({ change }));
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ // Writes the project-wide fallback, and the session pointer only when the id is safe, so
166
+ // an unsafe id can never cause a file to be created anywhere. A redirected `.state` or
167
+ // `.state/sessions` makes the matching write a no-op rather than a write out of the project.
168
+ export function writeActive(sdlcRoot, change, { sessionId } = {}) {
169
+ const project = writePointer(sdlcRoot, projectPointerPath(sdlcRoot), change);
170
+ const sessionPath = sessionPointerPath(sdlcRoot, sessionId);
171
+ const session = sessionPath ? writePointer(sdlcRoot, sessionPath, change) : false;
172
+ return { project, session };
173
+ }
174
+
175
+ // Releases every pointer naming `changeId`. Called at ship, after the folder has moved, so the
176
+ // id no longer resolves and is matched by name instead. Only real files inside `.state/` are
177
+ // read or removed — a planted link is left alone, never followed. Never throws: the ship has
178
+ // already happened by the time this runs.
179
+ export function clearPointersFor(sdlcRoot, changeId) {
180
+ const candidates = [projectPointerPath(sdlcRoot)];
181
+ const sessionsDir = path.join(sdlcRoot, '.state', 'sessions');
182
+ if (isRealPathInside(sdlcRoot, sessionsDir)) {
183
+ try {
184
+ for (const f of fs.readdirSync(sessionsDir)) {
185
+ if (f.endsWith('.json')) candidates.push(path.join(sessionsDir, f));
186
+ }
187
+ } catch { /* nothing to release */ }
188
+ }
189
+ let released = 0;
190
+ for (const p of candidates) {
191
+ try {
192
+ if (!isRealPathInside(sdlcRoot, p)) continue;
193
+ if (JSON.parse(fs.readFileSync(p, 'utf8'))?.change !== changeId) continue;
194
+ fs.rmSync(p);
195
+ released += 1;
196
+ } catch { /* missing, malformed or vanished — leave it */ }
197
+ }
198
+ return released;
199
+ }
package/lib/caps.mjs CHANGED
@@ -1,46 +1,46 @@
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
- lensCatalog: 60, // payload/playbook/lenses.md — read by /sdlc:new on every change
20
- });
21
-
22
- export const TIERS = Object.freeze(['vibe', 'standard', 'deep']);
23
-
24
- export const STATUSES = Object.freeze([
25
- 'new', 'contracted', 'building', 'verified', 'shipped',
26
- ]);
27
-
28
- // Effective lines = body lines after frontmatter, excluding blanks and
29
- // single-line HTML comments. Caps meter prose the model must carry, not
30
- // machine metadata or annotation comments.
31
- export function countEffectiveLines(text) {
32
- const { body } = parseFrontmatter(text ?? '');
33
- return body
34
- .split(/\r?\n/)
35
- .filter((line) => {
36
- const t = line.trim();
37
- if (t === '') return false;
38
- if (t.startsWith('<!--') && t.endsWith('-->')) return false;
39
- return true;
40
- })
41
- .length;
42
- }
43
-
44
- export function capForChange(tier) {
45
- return CAPS.change[tier] ?? CAPS.change.standard;
46
- }
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
+ lensCatalog: 60, // payload/playbook/lenses.md — read by /sdlc:new on every change
20
+ });
21
+
22
+ export const TIERS = Object.freeze(['vibe', 'standard', 'deep']);
23
+
24
+ export const STATUSES = Object.freeze([
25
+ 'new', 'contracted', 'building', 'verified', 'shipped',
26
+ ]);
27
+
28
+ // Effective lines = body lines after frontmatter, excluding blanks and
29
+ // single-line HTML comments. Caps meter prose the model must carry, not
30
+ // machine metadata or annotation comments.
31
+ export function countEffectiveLines(text) {
32
+ const { body } = parseFrontmatter(text ?? '');
33
+ return body
34
+ .split(/\r?\n/)
35
+ .filter((line) => {
36
+ const t = line.trim();
37
+ if (t === '') return false;
38
+ if (t.startsWith('<!--') && t.endsWith('-->')) return false;
39
+ return true;
40
+ })
41
+ .length;
42
+ }
43
+
44
+ export function capForChange(tier) {
45
+ return CAPS.change[tier] ?? CAPS.change.standard;
46
+ }
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
+ }