@warnyin/sdlc 0.6.0 → 0.7.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/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.7.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,29 @@
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`.
20
+ Steering: `inclusion: always|paths|manual|agent` (+ `pathMatch` for paths).
21
+
22
+ Hard rules (hook-enforced):
23
+ - `sdlc/specs/**` + `changes/archive/**`: writable only during an open ship gate.
24
+ - `constitution.md`: writable only during an open steer gate.
25
+ - `journal.ndjson` + `.state/**`: machine-owned, never hand-edit.
26
+
27
+ Gates: `node sdlc/.hooks/journal.mjs open-ship <id> | open-steer | close |
28
+ set-active <id> (sets this session's pointer + the project fallback) | note <name> [k=v]`.
29
+ 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
@@ -14,11 +14,12 @@ import path from 'node:path';
14
14
  import {
15
15
  resolveRoots, readStdinJson, readPhase, activeChange, appendJournal, toPosixRel, lexicalPosixRel,
16
16
  } from './_shared.mjs';
17
+ import { pickSessionId } from './lib/active.mjs';
17
18
 
18
19
  const { sdlcRoot, projectRoot } = resolveRoots(import.meta.url);
19
20
 
20
- function deny(reason, rel) {
21
- appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: 'guard', action: 'deny', path: rel, reason });
21
+ function deny(reason, rel, sessionId) {
22
+ appendJournal(sdlcRoot, activeChange(sdlcRoot, sessionId), { event: 'guard', action: 'deny', path: rel, reason });
22
23
  console.log(JSON.stringify({
23
24
  hookSpecificOutput: {
24
25
  hookEventName: 'PreToolUse',
@@ -31,9 +32,9 @@ function deny(reason, rel) {
31
32
  // Evaluate the lock rules against ONE view of the path. Returns true when a
32
33
  // deny was emitted. Rules must hold for BOTH the lexical (claimed) and the
33
34
  // realpath-resolved view — a symlink must never weaken a lock.
34
- function guard(rel, phase) {
35
+ function guard(rel, phase, sessionId) {
35
36
  if (rel.startsWith('sdlc/.state/') || rel.endsWith('journal.ndjson')) {
36
- deny(`"${rel}" is machine-owned (hooks/CLI write it) — never edit it by hand.`, rel);
37
+ deny(`"${rel}" is machine-owned (hooks/CLI write it) — never edit it by hand.`, rel, sessionId);
37
38
  return true;
38
39
  }
39
40
  if (rel.startsWith('sdlc/specs/') || rel.startsWith('sdlc/changes/archive/')) {
@@ -42,6 +43,7 @@ function guard(rel, phase) {
42
43
  `"${rel}" is write-locked outside ship. Living specs change only by merging a change's Delta: `
43
44
  + 'run `warnyin-sdlc archive <id>` (or `node sdlc/.hooks/journal.mjs open-ship <id>` first if you must edit).',
44
45
  rel,
46
+ sessionId,
45
47
  );
46
48
  return true;
47
49
  }
@@ -51,6 +53,7 @@ function guard(rel, phase) {
51
53
  'The constitution is always-loaded context — edits go through /sdlc:steer '
52
54
  + '(`node sdlc/.hooks/journal.mjs open-steer` opens the gate).',
53
55
  rel,
56
+ sessionId,
54
57
  );
55
58
  return true;
56
59
  }
@@ -62,6 +65,7 @@ async function main() {
62
65
  const filePath = input?.tool_input?.file_path ?? input?.tool_input?.notebook_path;
63
66
  if (!filePath || !fs.existsSync(sdlcRoot)) return;
64
67
 
68
+ const sessionId = pickSessionId(input?.session_id, process.env.CLAUDE_CODE_SESSION_ID);
65
69
  const abs = path.resolve(projectRoot, filePath);
66
70
  const relLexical = lexicalPosixRel(projectRoot, abs);
67
71
  const relReal = toPosixRel(projectRoot, abs);
@@ -70,13 +74,13 @@ async function main() {
70
74
  // the project) went through a symlink — deny conservatively; a symlink must
71
75
  // never disable the write-lock.
72
76
  if (relLexical?.startsWith('sdlc/') && relReal !== relLexical) {
73
- deny(`"${relLexical}" resolves through a symlink to "${relReal ?? 'outside the project'}" — refusing to touch it.`, relLexical);
77
+ deny(`"${relLexical}" resolves through a symlink to "${relReal ?? 'outside the project'}" — refusing to touch it.`, relLexical, sessionId);
74
78
  return;
75
79
  }
76
80
 
77
81
  const phase = readPhase(sdlcRoot);
78
82
  for (const rel of new Set([relLexical, relReal].filter(Boolean))) {
79
- if (rel.startsWith('sdlc/') && guard(rel, phase)) return;
83
+ if (rel.startsWith('sdlc/') && guard(rel, phase, sessionId)) return;
80
84
  }
81
85
  }
82
86
 
@@ -10,12 +10,14 @@ import process from 'node:process';
10
10
  import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_shared.mjs';
11
11
  import { parseFrontmatter } from './lib/frontmatter.mjs';
12
12
  import { CAPS } from './lib/caps.mjs';
13
+ import { pickSessionId } from './lib/active.mjs';
13
14
 
14
15
  const { sdlcRoot } = resolveRoots(import.meta.url);
15
16
 
16
17
  async function main() {
17
- await readStdinJson(); // drain; content not needed
18
+ const input = await readStdinJson();
18
19
  if (!fs.existsSync(sdlcRoot)) return;
20
+ const sessionId = pickSessionId(input?.session_id, process.env.CLAUDE_CODE_SESSION_ID);
19
21
 
20
22
  const injected = [];
21
23
  const out = [];
@@ -37,7 +39,7 @@ async function main() {
37
39
  }
38
40
  }
39
41
 
40
- const active = activeChange(sdlcRoot);
42
+ const active = activeChange(sdlcRoot, sessionId);
41
43
  if (active) out.push(`Active change: sdlc/changes/${active}/change.md — run /sdlc:next for status.`);
42
44
 
43
45
  if (!out.length) return;
@@ -5,15 +5,15 @@
5
5
  // node sdlc/.hooks/journal.mjs open-ship <change-id> unlock specs/archive writes (TTL 30m)
6
6
  // node sdlc/.hooks/journal.mjs open-steer unlock constitution edits (TTL 30m)
7
7
  // node sdlc/.hooks/journal.mjs close close any open gate
8
- // node sdlc/.hooks/journal.mjs set-active <change-id> attribute sessions/events to a change
8
+ // node sdlc/.hooks/journal.mjs set-active <change-id> attribute this session's (and the
9
+ // project's) events to a change
9
10
  // node sdlc/.hooks/journal.mjs note <name> [k=v ...] append a journal event
10
11
 
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
12
  import process from 'node:process';
14
13
  import {
15
14
  resolveRoots, readStdinJson, writePhase, clearPhase, activeChange, appendJournal,
16
15
  } from './_shared.mjs';
16
+ import { isOpenChange, pickSessionId, writeActive } from './lib/active.mjs';
17
17
 
18
18
  const { sdlcRoot } = resolveRoots(import.meta.url);
19
19
 
@@ -36,19 +36,27 @@ async function main() {
36
36
  } else if (cmd === 'set-active') {
37
37
  const change = rest[0];
38
38
  if (!change) { console.error('usage: journal.mjs set-active <change-id>'); process.exit(2); }
39
- fs.mkdirSync(path.join(sdlcRoot, '.state'), { recursive: true });
40
- fs.writeFileSync(path.join(sdlcRoot, '.state', 'active.json'), JSON.stringify({ change }));
41
- console.log(`active change: ${change}`);
39
+ // A pointer the resolver would ignore must not be written and reported as done.
40
+ if (!isOpenChange(sdlcRoot, change)) {
41
+ console.error(`usage: journal.mjs set-active <change-id> — "${change}" is not an open change under sdlc/changes/`);
42
+ process.exit(2);
43
+ }
44
+ const written = writeActive(sdlcRoot, change, { sessionId: process.env.CLAUDE_CODE_SESSION_ID });
45
+ // A refused write (a planted link under .state/) must be visible, not reported as done.
46
+ if (written.project) console.log(`active change: ${change}`);
47
+ else console.error(`[sdlc] active change "${change}" not recorded: sdlc/.state does not resolve inside this project`);
42
48
  } else if (cmd === 'note') {
43
49
  // When used as a hook, drain stdin so the harness never blocks on us.
44
- if (!process.stdin.isTTY) await readStdinJson();
50
+ let stdinInput = null;
51
+ if (!process.stdin.isTTY) stdinInput = await readStdinJson();
52
+ const sessionId = pickSessionId(stdinInput?.session_id, process.env.CLAUDE_CODE_SESSION_ID);
45
53
  const name = rest[0] ?? 'note';
46
54
  const extra = {};
47
55
  for (const kv of rest.slice(1)) {
48
56
  const [k, ...v] = kv.split('=');
49
57
  if (k && v.length) extra[k] = v.join('=');
50
58
  }
51
- appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: name, ...extra });
59
+ appendJournal(sdlcRoot, activeChange(sdlcRoot, sessionId), { event: name, ...extra });
52
60
  } else {
53
61
  console.error('usage: journal.mjs open-ship|open-steer|close|set-active|note ...');
54
62
  process.exit(2);
@@ -10,6 +10,7 @@ import process from 'node:process';
10
10
  import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_shared.mjs';
11
11
  import { parseTranscriptUsage, costUsd } from './lib/usage.mjs';
12
12
  import { parseConfig } from './lib/config.mjs';
13
+ import { pickSessionId } from './lib/active.mjs';
13
14
 
14
15
  const { sdlcRoot } = resolveRoots(import.meta.url);
15
16
 
@@ -30,7 +31,8 @@ async function main() {
30
31
  } catch { /* no config, no cost */ }
31
32
  const usd = costUsd(usage, prices);
32
33
 
33
- const change = activeChange(sdlcRoot);
34
+ const sessionId = pickSessionId(input?.session_id, process.env.CLAUDE_CODE_SESSION_ID);
35
+ const change = activeChange(sdlcRoot, sessionId);
34
36
  appendJournal(sdlcRoot, change, {
35
37
  event: 'session',
36
38
  session: input?.session_id ?? null,
@@ -14,6 +14,7 @@ import { resolveRoots, readStdinJson, activeChange, appendJournal, toPosixRel }
14
14
  import { parseFrontmatter } from './lib/frontmatter.mjs';
15
15
  import { matchGlob } from './lib/glob.mjs';
16
16
  import { validateChange, validateContext, formatIssues } from './lib/validate.mjs';
17
+ import { isSafeSessionId, pickSessionId } from './lib/active.mjs';
17
18
 
18
19
  const { sdlcRoot, projectRoot } = resolveRoots(import.meta.url);
19
20
 
@@ -42,7 +43,9 @@ function steeringPointer(rel, sessionId) {
42
43
  const steeringDir = path.join(sdlcRoot, 'context', 'steering');
43
44
  if (!fs.existsSync(steeringDir)) return;
44
45
 
45
- const safeSession = String(sessionId ?? '').replace(/[^A-Za-z0-9_-]/g, '') || 'nosession';
46
+ // Refused, not stripped: stripping aliases `a/b` onto `ab` and would hand one session's
47
+ // seen-steering list to another. Same single-safe-segment rule as the active pointers.
48
+ const safeSession = isSafeSessionId(sessionId) ? sessionId : 'nosession';
46
49
  const seenPath = path.join(sdlcRoot, '.state', `pointers-${safeSession}.json`);
47
50
  let seen = [];
48
51
  try { seen = JSON.parse(fs.readFileSync(seenPath, 'utf8')); } catch { /* first hit */ }
@@ -52,7 +55,7 @@ function steeringPointer(rel, sessionId) {
52
55
  const { data } = parseFrontmatter(fs.readFileSync(path.join(steeringDir, f), 'utf8'));
53
56
  if (data.inclusion !== 'paths' || !Array.isArray(data.pathMatch)) continue;
54
57
  if (!matchGlob(rel, data.pathMatch)) continue;
55
- appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: 'pointer', steering: f, file: rel });
58
+ appendJournal(sdlcRoot, activeChange(sdlcRoot, sessionId), { event: 'pointer', steering: f, file: rel });
56
59
  if (!seen.includes(f)) hits.push(f);
57
60
  }
58
61
  if (!hits.length) return;
@@ -73,8 +76,9 @@ async function main() {
73
76
  const rel = toPosixRel(projectRoot, path.resolve(projectRoot, filePath));
74
77
  if (!rel) return;
75
78
 
79
+ const sessionId = pickSessionId(input?.session_id, process.env.CLAUDE_CODE_SESSION_ID);
76
80
  if (rel.startsWith('sdlc/')) validateSdlcWrite(rel);
77
- else steeringPointer(rel, input?.session_id);
81
+ else steeringPointer(rel, sessionId);
78
82
  }
79
83
 
80
84
  main().catch(() => process.exit(0)); // fail open
@@ -1,14 +1,24 @@
1
- # /sdlc:next — where am I, what now (read-only)
2
-
3
- 1. Run `npx @warnyin/sdlc status`.
4
- 2. For each active change map status next command:
5
- - `new` + markers unresolved resolve questions (playbook new.md §5)
6
- - `new` (clean) /sdlc:design (deep/signal) or /sdlc:contract
7
- - `contracted` /sdlc:build
8
- - `building` → /sdlc:build (finish open tasks)
9
- - `verified` → /sdlc:review (if signals) or /sdlc:ship
10
- 3. If nothing is active: suggest /sdlc:new, or /sdlc:observe if archived changes
11
- have unread digests.
12
- 4. Answer in ≤5 lines. Create or modify nothing.
13
- 5. When the remaining path is more than one stage, add one line: the same command
14
- with `--auto` confirms once and runs to ship.
1
+ # /sdlc:next — where am I, what now (read-only)
2
+
3
+ 1. Run `npx @warnyin/sdlc status`.
4
+ 2. Answer for the current change first: the line marked `← this session`, or —
5
+ if none carries that marker the line marked `← last set for project` (say
6
+ plainly it was last set for the project, not this session, and confirm
7
+ before acting on it as this session's work). Map that change's status to
8
+ the next command:
9
+ - `new` + markers unresolved resolve questions (playbook new.md §5)
10
+ - `new` (clean) /sdlc:design (deep/signal) or /sdlc:contract
11
+ - `contracted` → /sdlc:build
12
+ - `building` /sdlc:build (finish open tasks)
13
+ - `verified` /sdlc:review (if signals) or /sdlc:ship
14
+ Changes marked `(not this session)` are context only — mention them in at
15
+ most one line, never as this session's next command, never picked up. If the
16
+ human says this session is on a different change, their answer wins — use it,
17
+ and give them `node sdlc/.hooks/journal.mjs set-active <id>` so the next status agrees. If no
18
+ line carries any marker, list the open changes and ask which one this
19
+ session is on; do not choose for the human.
20
+ 3. If nothing is active: suggest /sdlc:new, or /sdlc:observe if archived changes
21
+ have unread digests.
22
+ 4. Answer in ≤5 lines. Create or modify nothing.
23
+ 5. When the remaining path is more than one stage, add one line: the same command
24
+ with `--auto` confirms once and runs to ship.