@warnyin/sdlc 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/active.mjs ADDED
@@ -0,0 +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
+ }
package/lib/caps.mjs CHANGED
@@ -16,6 +16,7 @@ export const CAPS = Object.freeze({
16
16
  change: Object.freeze({ vibe: 40, standard: 100, deep: 150 }),
17
17
  contractTests: 60, // changes/<id>/contract/tests.md
18
18
  contractEvals: 40, // changes/<id>/contract/evals.md
19
+ lensCatalog: 60, // payload/playbook/lenses.md — read by /sdlc:new on every change
19
20
  });
20
21
 
21
22
  export const TIERS = Object.freeze(['vibe', 'standard', 'deep']);
package/lib/lenses.mjs ADDED
@@ -0,0 +1,48 @@
1
+ // Canonical lens list — the single source of truth for which expert lenses a change may
2
+ // record. `payload/playbook/lenses.md` describes each one under `## Lens: <name>`, and
3
+ // tests/lenses.test.mjs fails the build if the two drift.
4
+ //
5
+ // A change records lenses in frontmatter as `<lens>@builtin`, `<lens>@project:<skill>` or
6
+ // `<lens>@user:<skill>`; the skill name uses the inventory's own name rule, so whatever
7
+ // `warnyin-sdlc skills` lists is exactly what a change can record.
8
+
9
+ // Names are only ever added: removing or renaming one turns open changes that recorded it
10
+ // into validation errors, so that needs a migration, not an edit here.
11
+ //
12
+ // SKILL_NAME_RE is owned here (not by the inventory) so the validator, which hooks load on
13
+ // every write, does not pull in filesystem scanning. `.` and `..` are refused so a recorded
14
+ // name can never be a path hop.
15
+ export const SKILL_NAME_RE = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,64}$/;
16
+
17
+ export const LENSES = Object.freeze(['ux-ui', 'api', 'data']);
18
+
19
+ const ENTRY_RE = /^([^@\s]+)@(builtin|project:(.*)|user:(.*))$/;
20
+
21
+ // Returns error strings, each naming the offending entry. An absent or empty list is valid:
22
+ // no lens means no stage loads one.
23
+ export function lensErrors(value) {
24
+ if (value === undefined) return [];
25
+ if (!Array.isArray(value)) return [`lenses must be a list, got "${value}"`];
26
+ const errors = [];
27
+ const seen = new Set();
28
+ for (const raw of value) {
29
+ const entry = String(raw);
30
+ const m = entry.match(ENTRY_RE);
31
+ if (!m) {
32
+ errors.push(`lens entry "${entry}" must be <lens>@builtin, <lens>@project:<skill> or <lens>@user:<skill>`);
33
+ continue;
34
+ }
35
+ const [, lens, , projectSkill, userSkill] = m;
36
+ const skill = projectSkill ?? userSkill;
37
+ if (!LENSES.includes(lens)) {
38
+ errors.push(`lens entry "${entry}" names unknown lens "${lens}" (catalog: ${LENSES.join('|')})`);
39
+ } else if (seen.has(lens)) {
40
+ errors.push(`lens "${lens}" is recorded more than once — keep one source per lens`);
41
+ }
42
+ if (skill !== undefined && !SKILL_NAME_RE.test(skill)) {
43
+ errors.push(`lens entry "${entry}" has an invalid skill name (allowed: ${SKILL_NAME_RE.source})`);
44
+ }
45
+ seen.add(lens);
46
+ }
47
+ return errors;
48
+ }
package/lib/skills.mjs ADDED
@@ -0,0 +1,148 @@
1
+ // Skill/agent inventory — what expertise is already installed for this project and user.
2
+ // Used by: CLI (`warnyin-sdlc skills`), which the opening playbook reads to resolve lenses.
3
+ //
4
+ // Skill files are third-party content, so the inventory is deliberately shallow: it reads a
5
+ // bounded prefix of each file, keeps only frontmatter `name` + `description`, and never
6
+ // emits body text. Project entries whose real location leaves the project are skipped (a
7
+ // repo can ship a link); the user's own home may link wherever the user put their skills.
8
+
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { parseFrontmatter } from './frontmatter.mjs';
13
+ import { containedIn } from './manifest.mjs';
14
+ import { SKILL_NAME_RE } from './lenses.mjs';
15
+
16
+ export { SKILL_NAME_RE };
17
+ export const INVENTORY_CEILING = 200;
18
+ // Per-directory bound on how many candidates are opened at all: a checked-out repo can plant
19
+ // thousands of folders, and the ceiling alone would still pay to read every one of them.
20
+ export const SCAN_LIMIT_PER_DIR = 1000;
21
+ export const DESCRIPTION_MAX = 160;
22
+ export const READ_PREFIX_BYTES = 8 * 1024;
23
+
24
+ const KINDS = Object.freeze(['skill', 'agent']);
25
+ // Built from code points so the source file itself carries no invisible characters: C0, DEL,
26
+ // C1, zero-width and bidi marks/overrides, line/paragraph separators, BOM — anything that
27
+ // can move a terminal cursor or visually reorder the line a human or model reads.
28
+ const cp = (n) => String.fromCharCode(n);
29
+ const CONTROL_CHARS = new RegExp(
30
+ `[${cp(0)}-${cp(0x1f)}${cp(0x7f)}-${cp(0x9f)}${cp(0x200b)}-${cp(0x200f)}${cp(0x2028)}-${cp(0x202e)}${cp(0x2066)}-${cp(0x2069)}${cp(0xfeff)}]+`,
31
+ 'g');
32
+ const LEADING_BOM = new RegExp(`^${String.fromCharCode(0xfeff)}`);
33
+
34
+ // Reads at most READ_PREFIX_BYTES; a frontmatter that does not close inside that prefix is
35
+ // treated as absent rather than read further. The open is non-blocking where the platform
36
+ // has it, and the descriptor itself must be a regular file, so a FIFO or device swapped in
37
+ // after the earlier checks can neither hang nor feed the read.
38
+ function readPrefix(file) {
39
+ let fd;
40
+ try {
41
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
42
+ if (!fs.fstatSync(fd).isFile()) return null;
43
+ const buf = Buffer.alloc(READ_PREFIX_BYTES);
44
+ const n = fs.readSync(fd, buf, 0, READ_PREFIX_BYTES, 0);
45
+ return buf.subarray(0, n).toString('utf8').replace(LEADING_BOM, '');
46
+ } catch {
47
+ return null;
48
+ } finally {
49
+ if (fd !== undefined) fs.closeSync(fd);
50
+ }
51
+ }
52
+
53
+ // One line, no control characters (terminal escapes included), cut with a visible marker.
54
+ export function cleanDescription(raw) {
55
+ const flat = String(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
56
+ if (flat.length <= DESCRIPTION_MAX) return { description: flat, truncated: false };
57
+ return { description: `${flat.slice(0, DESCRIPTION_MAX - 1).trimEnd()}…`, truncated: true };
58
+ }
59
+
60
+ function readEntry(file) {
61
+ const text = readPrefix(file);
62
+ if (text === null) return null;
63
+ const { data } = parseFrontmatter(text);
64
+ const name = typeof data.name === 'string' ? data.name.trim() : '';
65
+ if (!SKILL_NAME_RE.test(name)) return null;
66
+ if (typeof data.description !== 'string' || data.description.trim() === '') return null;
67
+ return { name, ...cleanDescription(data.description) };
68
+ }
69
+
70
+ function isInside(rootReal, target) {
71
+ try {
72
+ return containedIn(rootReal, fs.realpathSync.native(target));
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ // The first SCAN_LIMIT_PER_DIR names (sorted), and how many were left unopened.
79
+ function listDir(dir) {
80
+ let names;
81
+ try {
82
+ names = fs.readdirSync(dir).sort();
83
+ } catch {
84
+ return { names: [], unscanned: 0 };
85
+ }
86
+ return { names: names.slice(0, SCAN_LIMIT_PER_DIR), unscanned: Math.max(0, names.length - SCAN_LIMIT_PER_DIR) };
87
+ }
88
+
89
+ function isFile(p) {
90
+ try { return fs.statSync(p).isFile(); } catch { return false; }
91
+ }
92
+
93
+ // Candidate files for one `.claude` root, each paired with every path that must stay contained.
94
+ function candidates(claudeDir, kind) {
95
+ if (kind === 'skill') {
96
+ const skillsDir = path.join(claudeDir, 'skills');
97
+ const { names, unscanned } = listDir(skillsDir);
98
+ const items = names.map((d) => {
99
+ const folder = path.join(skillsDir, d);
100
+ const file = path.join(folder, 'SKILL.md');
101
+ return { file, checks: [folder, file] };
102
+ });
103
+ return { items, unscanned };
104
+ }
105
+ const agentsDir = path.join(claudeDir, 'agents');
106
+ const { names, unscanned } = listDir(agentsDir);
107
+ const items = names
108
+ .filter((f) => f.endsWith('.md'))
109
+ .map((f) => ({ file: path.join(agentsDir, f), checks: [path.join(agentsDir, f)] }));
110
+ return { items, unscanned };
111
+ }
112
+
113
+ function scanRoot(claudeDir, source, containRoot) {
114
+ const entries = [];
115
+ let unscanned = 0;
116
+ for (const kind of KINDS) {
117
+ const found = candidates(claudeDir, kind);
118
+ unscanned += found.unscanned;
119
+ for (const { file, checks } of found.items) {
120
+ if (containRoot && !checks.every((c) => isInside(containRoot, c))) continue;
121
+ if (!isFile(file)) continue;
122
+ const entry = readEntry(file);
123
+ if (entry) entries.push({ source, kind, ...entry });
124
+ }
125
+ }
126
+ entries.sort((a, b) => KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind) || a.name.localeCompare(b.name));
127
+ return { entries, unscanned };
128
+ }
129
+
130
+ export function scanInventory(projectRoot, { home = os.homedir() } = {}) {
131
+ let projectReal = null;
132
+ try { projectReal = fs.realpathSync.native(projectRoot); } catch { /* unreadable root → no project entries */ }
133
+ const none = { entries: [], unscanned: 0 };
134
+ const project = projectReal ? scanRoot(path.join(projectRoot, '.claude'), 'project', projectReal) : none;
135
+ const user = home ? scanRoot(path.join(home, '.claude'), 'user', null) : none;
136
+ const all = [...project.entries, ...user.entries];
137
+ // `omitted` = valid entries past the ceiling + candidates never opened (scan limit).
138
+ return {
139
+ entries: all.slice(0, INVENTORY_CEILING),
140
+ omitted: Math.max(0, all.length - INVENTORY_CEILING) + project.unscanned + user.unscanned,
141
+ };
142
+ }
143
+
144
+ export function renderInventory({ entries, omitted }) {
145
+ const lines = entries.map((e) => `${e.source} ${e.kind} ${e.name} ${e.description}`);
146
+ if (omitted > 0) lines.push(`… ${omitted} more not listed`);
147
+ return lines.join('\n');
148
+ }
package/lib/validate.mjs CHANGED
@@ -7,6 +7,7 @@ import path from 'node:path';
7
7
  import { parseFrontmatter } from './frontmatter.mjs';
8
8
  import { CAPS, TIERS, STATUSES, countEffectiveLines, capForChange } from './caps.mjs';
9
9
  import { parseDelta, parseSpec, scenarioDrift, describeDrift } from './delta.mjs';
10
+ import { lensErrors } from './lenses.mjs';
10
11
 
11
12
  const CLARIFICATION_RE = /\[NEEDS CLARIFICATION/g;
12
13
 
@@ -35,6 +36,7 @@ export function validateChange(changeDir, { strict = false, specsDir = null } =
35
36
  else if (data.id !== id) issues.push(issue('error', id, `frontmatter id "${data.id}" != folder name "${id}"`));
36
37
  if (!TIERS.includes(data.tier)) issues.push(issue('error', id, `frontmatter: tier must be one of ${TIERS.join('|')}`));
37
38
  if (!STATUSES.includes(data.status)) issues.push(issue('error', id, `frontmatter: status must be one of ${STATUSES.join('|')}`));
39
+ for (const msg of lensErrors(data.lenses)) issues.push(issue('error', id, `frontmatter: ${msg}`));
38
40
 
39
41
  const tier = TIERS.includes(data.tier) ? data.tier : 'standard';
40
42
  const status = STATUSES.includes(data.status) ? data.status : 'new';
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
- {
2
- "name": "@warnyin/sdlc",
3
- "version": "0.6.0",
4
- "description": "Spec-driven, AI-driven SDLC framework — token-lean specs, contract-first changes, autonomous pipeline with managed hooks. Operationalizes the Day-1 'New SDLC with Vibe Coding' work process.",
5
- "type": "module",
6
- "bin": {
7
- "warnyin-sdlc": "bin/cli.mjs"
8
- },
9
- "files": [
10
- "bin",
11
- "lib",
12
- "scripts",
13
- "payload",
14
- "README.md",
15
- "CHANGELOG.md",
16
- "LICENSE"
17
- ],
18
- "scripts": {
19
- "test": "node --test",
20
- "setup:dogfood": "node bin/cli.mjs init --tool claude && node bin/cli.mjs update"
21
- },
22
- "engines": {
23
- "node": ">=20"
24
- },
25
- "publishConfig": {
26
- "access": "public"
27
- },
28
- "repository": {
29
- "type": "git",
30
- "url": "git+https://github.com/warnyin/warnyin-sdlc.git"
31
- },
32
- "keywords": [
33
- "sdlc",
34
- "spec-driven",
35
- "ai",
36
- "agents",
37
- "claude-code",
38
- "context-engineering"
39
- ],
40
- "author": "warnyin",
41
- "license": "MIT"
42
- }
1
+ {
2
+ "name": "@warnyin/sdlc",
3
+ "version": "0.8.0",
4
+ "description": "Spec-driven, AI-driven SDLC framework — token-lean specs, contract-first changes, autonomous pipeline with managed hooks. Operationalizes the Day-1 'New SDLC with Vibe Coding' work process.",
5
+ "type": "module",
6
+ "bin": {
7
+ "warnyin-sdlc": "bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "scripts",
13
+ "payload",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test",
20
+ "setup:dogfood": "node bin/cli.mjs init --tool claude && node bin/cli.mjs update"
21
+ },
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/warnyin/warnyin-sdlc.git"
31
+ },
32
+ "keywords": [
33
+ "sdlc",
34
+ "spec-driven",
35
+ "ai",
36
+ "agents",
37
+ "claude-code",
38
+ "context-engineering"
39
+ ],
40
+ "author": "warnyin",
41
+ "license": "MIT"
42
+ }
@@ -1,27 +1,30 @@
1
- ---
2
- name: sdlc-conventions
3
- description: Cheat-sheet for the sdlc/ artifact layout, line caps, statuses, gates, and machine-owned files. Load when working with any file under sdlc/.
4
- user-invocable: false
5
- ---
6
- # sdlc/ conventions
7
-
8
- Layout: `config.yaml` · `context/{constitution.md, steering/*.md}` · `harness.md`
9
- · `specs/<capability>/spec.md` · `changes/<id>/{change.md, contract/}`
10
- · telemetry: `.state/journal/<id>.ndjson` while open, sealed into the archived folder at ship
11
- · `changes/archive/<date>-<id>/` · `evals/<capability>/rubric.md` · `.state/` (machine).
12
-
13
- Line caps (validator-enforced; count = non-blank, non-comment body lines):
14
- constitution 30 · steering 40 each · always-budget 60 total · harness 60 ·
15
- change vibe/standard/deep 40/100/150 · tests.md 60 · evals.md 40 · spec soft 150.
16
-
17
- Frontmatter: `id` (= folder name) · `tier: vibe|standard|deep` ·
18
- `status: new|contracted|building|verified|shipped`.
19
- Steering: `inclusion: always|paths|manual|agent` (+ `pathMatch` for paths).
20
-
21
- Hard rules (hook-enforced):
22
- - `sdlc/specs/**` + `changes/archive/**`: writable only during an open ship gate.
23
- - `constitution.md`: writable only during an open steer gate.
24
- - `journal.ndjson` + `.state/**`: machine-owned, never hand-edit.
25
-
26
- Gates: `node sdlc/.hooks/journal.mjs open-ship <id> | open-steer | close | set-active <id> | note <name> [k=v]`.
27
- Validation: `npx @warnyin/sdlc validate [id] [--strict]` — red = the gate did not pass.
1
+ ---
2
+ name: sdlc-conventions
3
+ description: Cheat-sheet for the sdlc/ artifact layout, line caps, statuses, gates, and machine-owned files. Load when working with any file under sdlc/.
4
+ user-invocable: false
5
+ ---
6
+ # sdlc/ conventions
7
+
8
+ Layout: `config.yaml` · `context/{constitution.md, steering/*.md}` · `harness.md`
9
+ · `specs/<capability>/spec.md` · `changes/<id>/{change.md, contract/}`
10
+ · telemetry: `.state/journal/<id>.ndjson` while open, sealed into the archived folder at ship
11
+ · `changes/archive/<date>-<id>/` · `evals/<capability>/rubric.md` · `.state/` (machine)
12
+ · `.state/sessions/<sid>.json` (this session's active-change pointer; `.state/active.json` is the project-wide fallback).
13
+
14
+ Line caps (validator-enforced; count = non-blank, non-comment body lines):
15
+ constitution 30 · steering 40 each · always-budget 60 total · harness 60 ·
16
+ change vibe/standard/deep 40/100/150 · tests.md 60 · evals.md 40 · spec soft 150.
17
+
18
+ Frontmatter: `id` (= folder name) · `tier: vibe|standard|deep` ·
19
+ `status: new|contracted|building|verified|shipped` · optional
20
+ `lenses: [<ux-ui|api|data>@builtin | @project:<skill> | @user:<skill>]` (see `.playbook/lenses.md`).
21
+ Steering: `inclusion: always|paths|manual|agent` (+ `pathMatch` for paths).
22
+
23
+ Hard rules (hook-enforced):
24
+ - `sdlc/specs/**` + `changes/archive/**`: writable only during an open ship gate.
25
+ - `constitution.md`: writable only during an open steer gate.
26
+ - `journal.ndjson` + `.state/**`: machine-owned, never hand-edit.
27
+
28
+ Gates: `node sdlc/.hooks/journal.mjs open-ship <id> | open-steer | close |
29
+ set-active <id> (sets this session's pointer + the project fallback) | note <name> [k=v]`.
30
+ Validation: `npx @warnyin/sdlc validate [id] [--strict]` — red = the gate did not pass.
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import process from 'node:process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { liveJournalPath, globalJournalPath, appendEvent } from './lib/journal.mjs';
11
+ import { resolveActive } from './lib/active.mjs';
11
12
 
12
13
  export function resolveRoots(importMetaUrl) {
13
14
  const hooksDir = path.dirname(fileURLToPath(importMetaUrl));
@@ -114,26 +115,13 @@ export function clearPhase(sdlcRoot) {
114
115
  fs.rmSync(path.join(sdlcRoot, '.state', 'phase.json'), { force: true });
115
116
  }
116
117
 
117
- // Active change: explicit .state/active.json first, else the most recently
118
- // modified changes/*/change.md.
119
- export function activeChange(sdlcRoot) {
120
- try {
121
- const explicit = JSON.parse(fs.readFileSync(path.join(sdlcRoot, '.state', 'active.json'), 'utf8'));
122
- if (explicit?.change && fs.existsSync(path.join(sdlcRoot, 'changes', explicit.change))) {
123
- return explicit.change;
124
- }
125
- } catch { /* fall through */ }
126
- const changesDir = path.join(sdlcRoot, 'changes');
127
- if (!fs.existsSync(changesDir)) return null;
128
- let best = null;
129
- for (const d of fs.readdirSync(changesDir, { withFileTypes: true })) {
130
- if (!d.isDirectory() || d.name === 'archive') continue;
131
- const p = path.join(changesDir, d.name, 'change.md');
132
- if (!fs.existsSync(p)) continue;
133
- const mtime = fs.statSync(p).mtimeMs;
134
- if (!best || mtime > best.mtime) best = { change: d.name, mtime };
135
- }
136
- return best?.change ?? null;
118
+ // Active change: session pointer, then project pointer, then the most recently
119
+ // modified changes/*/change.md — resolution lives in lib/active.mjs so the CLI's
120
+ // `status` answers the same question the hooks do. Callers still get just the id
121
+ // (the `recent` fallback still attributes hook events, it just isn't reported as
122
+ // confirmed by `status`).
123
+ export function activeChange(sdlcRoot, sessionId = null) {
124
+ return resolveActive(sdlcRoot, { sessionId })?.change ?? null;
137
125
  }
138
126
 
139
127
  // Journal: per-change ndjson when a change is active, else a global one — both under