@nusoft/nuos-build-catalogue 0.32.0 → 0.33.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.
- package/dist/cli.js +34 -0
- package/dist/commands/end-of-session.d.ts +35 -0
- package/dist/commands/end-of-session.js +470 -0
- package/dist/commands/init.d.ts +20 -0
- package/dist/commands/init.js +2 -2
- package/dist/runtime/mis-adapter.js +124 -13
- package/package.json +2 -2
- package/scripts/hooks/pre-commit +1 -2
- package/scripts/install-hooks.sh +58 -0
- package/templates/protocols/build-wu.md +6 -0
package/dist/cli.js
CHANGED
|
@@ -436,6 +436,15 @@ Usage:
|
|
|
436
436
|
nuos-catalogue memory store --value="..." [--wu=wu-007] [--agent=architect] [--key="label"]
|
|
437
437
|
nuos-catalogue memory search --query="..." [--limit=N] [--wu=wu-007] [--agent=architect]
|
|
438
438
|
|
|
439
|
+
nuos-catalogue end-of-session
|
|
440
|
+
(WU 112 — verify-and-gate: checks the nine end-of-session protocol steps
|
|
441
|
+
against disk facts; prints a per-check report; exits non-zero on a blocked
|
|
442
|
+
gate; persists step-state in the workflow store for resumability.
|
|
443
|
+
Options: --active-wu=<handle> --date=YYYY-MM-DD --session-start=<iso>
|
|
444
|
+
--dry-run [--build-root=<dir>] [--workflows=<file>])
|
|
445
|
+
nuos-catalogue start-of-session
|
|
446
|
+
(reserved — body in a follow-up WU)
|
|
447
|
+
|
|
439
448
|
nuos-catalogue help
|
|
440
449
|
|
|
441
450
|
Handles accepted: canonical (wu-111, D046, Q009, P001) or friendly
|
|
@@ -629,6 +638,31 @@ async function main() {
|
|
|
629
638
|
console.error(' memory search --query="..." [--limit=N] [--wu=...] [--agent=...]');
|
|
630
639
|
process.exit(1);
|
|
631
640
|
}
|
|
641
|
+
case 'end-of-session': {
|
|
642
|
+
const buildRoot = resolveBuildRoot(args.flags['build-root']);
|
|
643
|
+
const workflowsPath = resolveWorkflowsPath(buildRoot, args.flags['workflows']);
|
|
644
|
+
const { createBuildCatalogueRuntime } = await import('./runtime/runtime.js');
|
|
645
|
+
const { cmdEndOfSession } = await import('./commands/end-of-session.js');
|
|
646
|
+
const store = await openWorkflowStore(workflowsPath);
|
|
647
|
+
const runtime = createBuildCatalogueRuntime({ store, catalogueRoot: buildRoot });
|
|
648
|
+
const result = await cmdEndOfSession(store, runtime, {
|
|
649
|
+
buildRoot,
|
|
650
|
+
activeWuHandle: args.flags['active-wu'] ? String(args.flags['active-wu']) : undefined,
|
|
651
|
+
sessionDate: args.flags['date'] ? String(args.flags['date']) : undefined,
|
|
652
|
+
sessionStartIso: args.flags['session-start'] ? String(args.flags['session-start']) : undefined,
|
|
653
|
+
dryRun: Boolean(args.flags['dry-run']),
|
|
654
|
+
});
|
|
655
|
+
if (result.output)
|
|
656
|
+
console.log(result.output);
|
|
657
|
+
process.exit(result.exitCode);
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
case 'start-of-session': {
|
|
661
|
+
// Reserved handle — body in a follow-up WU.
|
|
662
|
+
console.error('start-of-session: not yet implemented (WU 112 reserves the handle; body in a follow-up WU).');
|
|
663
|
+
process.exit(2);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
632
666
|
case 'help':
|
|
633
667
|
case '--help':
|
|
634
668
|
case '-h':
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nuos-catalogue end-of-session` — runnable CLI driver for the
|
|
3
|
+
* `end_of_session` verify-and-gate workflow (WU 112 / D130).
|
|
4
|
+
*
|
|
5
|
+
* This command:
|
|
6
|
+
* 1. Gathers `EndOfSessionFacts` from disk (mtimes, file existence, index
|
|
7
|
+
* parity, TODAY's date).
|
|
8
|
+
* 2. Checks for an existing `session.end:<date>` record in the store (resume
|
|
9
|
+
* support — if a prior run left the session incomplete, it continues from
|
|
10
|
+
* the persisted step-state).
|
|
11
|
+
* 3. Drives the workflow lifecycle through the NuFlow runtime.
|
|
12
|
+
* 4. Prints a per-check report (pass/fail per step).
|
|
13
|
+
* 5. Exits non-zero if the gate blocks (any gating check failed).
|
|
14
|
+
*
|
|
15
|
+
* D129/D130-safe: this command writes NO catalogue prose and renders NO
|
|
16
|
+
* markdown from the store. Its only persistent write is the
|
|
17
|
+
* `session.end:<date>` step-state record (via the MIS adapter).
|
|
18
|
+
*
|
|
19
|
+
* Note: the "what this gate cannot do" boundary is printed in the report —
|
|
20
|
+
* a green gate proves presence/structure only, not semantic correctness of
|
|
21
|
+
* STATE.md or the truth of a session-log narrative.
|
|
22
|
+
*/
|
|
23
|
+
import type { NuFlowRuntime } from '@nusoft/nuflow';
|
|
24
|
+
import type { WorkflowStore } from '../migrate/store.js';
|
|
25
|
+
export interface EndOfSessionHandlerResult {
|
|
26
|
+
output: string;
|
|
27
|
+
exitCode: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function cmdEndOfSession(store: WorkflowStore, runtime: NuFlowRuntime, args: {
|
|
30
|
+
buildRoot: string;
|
|
31
|
+
activeWuHandle?: string;
|
|
32
|
+
sessionDate?: string;
|
|
33
|
+
sessionStartIso?: string;
|
|
34
|
+
dryRun?: boolean;
|
|
35
|
+
}): Promise<EndOfSessionHandlerResult>;
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nuos-catalogue end-of-session` — runnable CLI driver for the
|
|
3
|
+
* `end_of_session` verify-and-gate workflow (WU 112 / D130).
|
|
4
|
+
*
|
|
5
|
+
* This command:
|
|
6
|
+
* 1. Gathers `EndOfSessionFacts` from disk (mtimes, file existence, index
|
|
7
|
+
* parity, TODAY's date).
|
|
8
|
+
* 2. Checks for an existing `session.end:<date>` record in the store (resume
|
|
9
|
+
* support — if a prior run left the session incomplete, it continues from
|
|
10
|
+
* the persisted step-state).
|
|
11
|
+
* 3. Drives the workflow lifecycle through the NuFlow runtime.
|
|
12
|
+
* 4. Prints a per-check report (pass/fail per step).
|
|
13
|
+
* 5. Exits non-zero if the gate blocks (any gating check failed).
|
|
14
|
+
*
|
|
15
|
+
* D129/D130-safe: this command writes NO catalogue prose and renders NO
|
|
16
|
+
* markdown from the store. Its only persistent write is the
|
|
17
|
+
* `session.end:<date>` step-state record (via the MIS adapter).
|
|
18
|
+
*
|
|
19
|
+
* Note: the "what this gate cannot do" boundary is printed in the report —
|
|
20
|
+
* a green gate proves presence/structure only, not semantic correctness of
|
|
21
|
+
* STATE.md or the truth of a session-log narrative.
|
|
22
|
+
*/
|
|
23
|
+
import { stat, readdir, readFile } from 'node:fs/promises';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
const BUILD_MAINTAINER = {
|
|
26
|
+
kind: 'staff',
|
|
27
|
+
id: 'build-maintainer',
|
|
28
|
+
role: 'build-maintainer',
|
|
29
|
+
};
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Public entry point
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
export async function cmdEndOfSession(store, runtime, args) {
|
|
34
|
+
const today = args.sessionDate ?? new Date().toISOString().slice(0, 10);
|
|
35
|
+
const sessionStartIso = args.sessionStartIso ?? new Date(today + 'T00:00:00.000Z').toISOString();
|
|
36
|
+
// Resolve the active WU handle — required for step 1 check.
|
|
37
|
+
const activeWuHandle = args.activeWuHandle ?? resolveActiveWuHandle(store);
|
|
38
|
+
if (!activeWuHandle) {
|
|
39
|
+
return {
|
|
40
|
+
output: [
|
|
41
|
+
'end-of-session: cannot determine the active WU handle.',
|
|
42
|
+
'Supply --active-wu=<handle> or run `nuos-catalogue wu start <handle>` first.',
|
|
43
|
+
].join('\n'),
|
|
44
|
+
exitCode: 1,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// Gather disk facts — this is the only place filesystem access happens
|
|
48
|
+
// (the workflow itself is pure).
|
|
49
|
+
const catalogueFacts = await gatherFacts(args.buildRoot, activeWuHandle, sessionStartIso, today);
|
|
50
|
+
// Check for an existing (incomplete) session.end:<date> record.
|
|
51
|
+
const existingHandle = `session.end:${today}`;
|
|
52
|
+
const existingRecord = store.get(existingHandle);
|
|
53
|
+
let resumeFromStep;
|
|
54
|
+
if (existingRecord) {
|
|
55
|
+
try {
|
|
56
|
+
const stored = JSON.parse(existingRecord.rawMarkdown);
|
|
57
|
+
if (stored.completed) {
|
|
58
|
+
return {
|
|
59
|
+
output: [
|
|
60
|
+
`end-of-session: session ${today} was already marked complete in a prior run.`,
|
|
61
|
+
'If you need to re-verify, delete the store record and re-run.',
|
|
62
|
+
].join('\n'),
|
|
63
|
+
exitCode: 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// Find the first step that was not yet passed — resume from there.
|
|
67
|
+
if (stored.steps) {
|
|
68
|
+
const STEP_ORDER = [
|
|
69
|
+
'update_active_wu_notes',
|
|
70
|
+
'capture_decisions',
|
|
71
|
+
'capture_open_questions',
|
|
72
|
+
'capture_risks',
|
|
73
|
+
'update_work_units_index',
|
|
74
|
+
'update_state_md',
|
|
75
|
+
'write_session_log',
|
|
76
|
+
'confirm_no_loss',
|
|
77
|
+
'report',
|
|
78
|
+
];
|
|
79
|
+
for (const stepId of STEP_ORDER) {
|
|
80
|
+
if (stored.steps[stepId]?.status !== 'passed') {
|
|
81
|
+
resumeFromStep = stepId;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// If the existing record is malformed, start fresh.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const metadata = {
|
|
92
|
+
sessionDate: today,
|
|
93
|
+
activeWuHandle,
|
|
94
|
+
sessionStartIso,
|
|
95
|
+
catalogueFacts,
|
|
96
|
+
...(resumeFromStep ? { resumeFromStep } : {}),
|
|
97
|
+
};
|
|
98
|
+
const capture = {
|
|
99
|
+
channel: 'typed_note',
|
|
100
|
+
content: `end-of-session for ${today}`,
|
|
101
|
+
subjects: [{ kind: 'session', id: existingHandle }],
|
|
102
|
+
metadata: metadata,
|
|
103
|
+
};
|
|
104
|
+
// Drive the NuFlow lifecycle: start → confirm → commit.
|
|
105
|
+
const wf = await runtime.startWorkflow('end_of_session', BUILD_MAINTAINER, capture);
|
|
106
|
+
if (!wf.writeIntent) {
|
|
107
|
+
return {
|
|
108
|
+
output: 'end-of-session: workflow did not produce a writeIntent — unexpected runtime state.',
|
|
109
|
+
exitCode: 1,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const payload = wf.writeIntent.payload;
|
|
113
|
+
if (args.dryRun) {
|
|
114
|
+
return {
|
|
115
|
+
output: formatReport(payload, today, resumeFromStep, true),
|
|
116
|
+
exitCode: payload.failingChecks.length > 0 ? 1 : 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Confirm and commit to persist the step-state record.
|
|
120
|
+
const confirmedWf = await runtime.confirmIntent(wf.id, BUILD_MAINTAINER.id);
|
|
121
|
+
await runtime.commitIntent(confirmedWf.id, BUILD_MAINTAINER.id);
|
|
122
|
+
return {
|
|
123
|
+
output: formatReport(payload, today, resumeFromStep, false),
|
|
124
|
+
exitCode: payload.failingChecks.length > 0 ? 1 : 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Disk fact gathering — the only place fs access happens
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
async function gatherFacts(buildRoot, activeWuHandle, sessionStartIso, sessionDate) {
|
|
131
|
+
const sessionStartMs = new Date(sessionStartIso).getTime();
|
|
132
|
+
// Step 1: WU notes
|
|
133
|
+
const { wuNotesTouched, wuNotesHasTodayHeading } = await checkWuNotes(buildRoot, activeWuHandle, sessionStartMs, sessionDate);
|
|
134
|
+
// Steps 2–4: register parity
|
|
135
|
+
const decisionsFileIndexParity = await checkRegisterParity(buildRoot, 'decisions', sessionDate, /^D\d+.*\.md$/i);
|
|
136
|
+
const questionsParity = await checkRegisterParity(buildRoot, 'open-questions', sessionDate, /^Q\d+.*\.md$/i);
|
|
137
|
+
const risksParity = await checkRisksParity(buildRoot);
|
|
138
|
+
// Step 5: work-units index
|
|
139
|
+
const doneMoveOk = await checkWorkUnitsIndex(buildRoot);
|
|
140
|
+
// Step 6: STATE.md
|
|
141
|
+
const { stateMdTouched, stateMdLastUpdated, stateMdLastSessionResolves } = await checkStateMd(buildRoot, sessionStartMs, sessionDate);
|
|
142
|
+
// Step 7: session log
|
|
143
|
+
const { sessionLogExists, sessionLogIndexed } = await checkSessionLog(buildRoot, sessionDate);
|
|
144
|
+
return {
|
|
145
|
+
wuNotesTouched,
|
|
146
|
+
wuNotesHasTodayHeading,
|
|
147
|
+
decisionsFileIndexParity,
|
|
148
|
+
questionsParity,
|
|
149
|
+
risksParity,
|
|
150
|
+
doneMoveOk,
|
|
151
|
+
stateMdTouched,
|
|
152
|
+
stateMdLastUpdated,
|
|
153
|
+
stateMdLastSessionResolves,
|
|
154
|
+
sessionLogExists,
|
|
155
|
+
sessionLogIndexed,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Individual fact checks
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
async function fileMtime(filePath) {
|
|
162
|
+
try {
|
|
163
|
+
const s = await stat(filePath);
|
|
164
|
+
return s.mtime;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Returns the file's birth (creation) time, falling back to mtime when
|
|
171
|
+
// birthtime is unreliable (Linux ext4 without `relatime` reports birthtime as
|
|
172
|
+
// epoch 0 or equal to mtime). On macOS (APFS/HFS+) birthtime is always
|
|
173
|
+
// accurate. The fallback means: on those Linux filesystems a hand-edited file
|
|
174
|
+
// created before today but edited today will still appear as "created today" —
|
|
175
|
+
// a known false-positive. The real guarantee is the index-parity check; this
|
|
176
|
+
// filter is a best-effort "did you add something this session" hint only.
|
|
177
|
+
async function fileBirthtime(filePath) {
|
|
178
|
+
try {
|
|
179
|
+
const s = await stat(filePath);
|
|
180
|
+
// birthtimeMs === 0 signals an unsupported filesystem; fall back to mtime.
|
|
181
|
+
if (s.birthtimeMs === 0)
|
|
182
|
+
return s.mtime;
|
|
183
|
+
return s.birthtime;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function fileContent(filePath) {
|
|
190
|
+
try {
|
|
191
|
+
return await readFile(filePath, 'utf8');
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function listDir(dirPath) {
|
|
198
|
+
try {
|
|
199
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
200
|
+
return entries
|
|
201
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
202
|
+
.map((e) => e.name);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function checkWuNotes(buildRoot, activeWuHandle, sessionStartMs, sessionDate) {
|
|
209
|
+
// Look in work-units/ and work-units/done/ for the WU file.
|
|
210
|
+
const candidates = [
|
|
211
|
+
path.join(buildRoot, 'work-units', `${activeWuHandle}.md`),
|
|
212
|
+
// Handle numeric prefix patterns (e.g. 112-end-of-session-as-nuflow-workflow.md)
|
|
213
|
+
...(await findWuFile(buildRoot, activeWuHandle)),
|
|
214
|
+
];
|
|
215
|
+
for (const candidate of candidates) {
|
|
216
|
+
const mtime = await fileMtime(candidate);
|
|
217
|
+
if (!mtime)
|
|
218
|
+
continue;
|
|
219
|
+
const touched = mtime.getTime() > sessionStartMs;
|
|
220
|
+
const content = await fileContent(candidate);
|
|
221
|
+
const hasTodayHeading = content
|
|
222
|
+
? content.includes(sessionDate) && /##\s*(Notes|notes)/.test(content)
|
|
223
|
+
: false;
|
|
224
|
+
return { wuNotesTouched: touched, wuNotesHasTodayHeading: hasTodayHeading };
|
|
225
|
+
}
|
|
226
|
+
return { wuNotesTouched: false, wuNotesHasTodayHeading: false };
|
|
227
|
+
}
|
|
228
|
+
async function findWuFile(buildRoot, activeWuHandle) {
|
|
229
|
+
const results = [];
|
|
230
|
+
for (const subdir of ['work-units', path.join('work-units', 'done')]) {
|
|
231
|
+
const dirPath = path.join(buildRoot, subdir);
|
|
232
|
+
try {
|
|
233
|
+
const entries = await readdir(dirPath);
|
|
234
|
+
for (const name of entries) {
|
|
235
|
+
if (name.endsWith('.md') && name.includes(activeWuHandle.replace('wu-', ''))) {
|
|
236
|
+
results.push(path.join(dirPath, name));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// Directory may not exist.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return results;
|
|
245
|
+
}
|
|
246
|
+
async function checkRegisterParity(buildRoot, registerDir, sessionDate, filePattern) {
|
|
247
|
+
const dirPath = path.join(buildRoot, registerDir);
|
|
248
|
+
const indexPath = path.join(dirPath, '_index.md');
|
|
249
|
+
// Files created today (by birthtime — see fileBirthtime for platform notes).
|
|
250
|
+
const allFiles = await listDir(dirPath);
|
|
251
|
+
const todayFiles = [];
|
|
252
|
+
for (const name of allFiles) {
|
|
253
|
+
if (!filePattern.test(name))
|
|
254
|
+
continue;
|
|
255
|
+
const btime = await fileBirthtime(path.join(dirPath, name));
|
|
256
|
+
if (btime && btime.toISOString().startsWith(sessionDate)) {
|
|
257
|
+
todayFiles.push(name);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Index rows mentioning today.
|
|
261
|
+
const indexContent = await fileContent(indexPath);
|
|
262
|
+
const todayIndexRows = [];
|
|
263
|
+
if (indexContent) {
|
|
264
|
+
const lines = indexContent.split('\n');
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
if (line.includes(sessionDate)) {
|
|
267
|
+
todayIndexRows.push(line.trim());
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Bijection check: every today file should appear in the index,
|
|
272
|
+
// and every today-dated index row should have a corresponding file.
|
|
273
|
+
const filesWithoutRow = [];
|
|
274
|
+
for (const file of todayFiles) {
|
|
275
|
+
const baseName = file.replace('.md', '');
|
|
276
|
+
const mentioned = todayIndexRows.some((row) => row.includes(baseName) || row.includes(file));
|
|
277
|
+
if (!mentioned) {
|
|
278
|
+
filesWithoutRow.push(file);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const rowsWithoutFile = [];
|
|
282
|
+
for (const row of todayIndexRows) {
|
|
283
|
+
// Try to find any today-file mentioned in the row.
|
|
284
|
+
const mentioned = todayFiles.some((file) => row.includes(file.replace('.md', '')) || row.includes(file));
|
|
285
|
+
if (!mentioned) {
|
|
286
|
+
rowsWithoutFile.push(row);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { filesWithoutRow, rowsWithoutFile };
|
|
290
|
+
}
|
|
291
|
+
async function checkRisksParity(buildRoot) {
|
|
292
|
+
// Risks are stored as index entries only (no individual files), so parity
|
|
293
|
+
// is trivially satisfied — there are no "files" to check against.
|
|
294
|
+
// This check is present for forward-compat when risks get individual files.
|
|
295
|
+
return { filesWithoutRow: [], rowsWithoutFile: [] };
|
|
296
|
+
}
|
|
297
|
+
async function checkWorkUnitsIndex(buildRoot) {
|
|
298
|
+
const indexPath = path.join(buildRoot, 'work-units', '_index.md');
|
|
299
|
+
const content = await fileContent(indexPath);
|
|
300
|
+
if (!content)
|
|
301
|
+
return true; // If no index, no rows to check.
|
|
302
|
+
// Design A (WU 112 fix-pass): operate on table rows only; check status cell only.
|
|
303
|
+
// Row shape after split on '|': ['', id, title, status, dependsOn, ...]
|
|
304
|
+
// (leading empty string from the leading pipe character)
|
|
305
|
+
const lines = content.split('\n');
|
|
306
|
+
for (const line of lines) {
|
|
307
|
+
// Only consider actual table rows (lines starting with '|' after optional whitespace).
|
|
308
|
+
if (!/^\s*\|/.test(line))
|
|
309
|
+
continue;
|
|
310
|
+
const cells = line.split('|');
|
|
311
|
+
// Need at least 5 cells: [empty, id, title, status, dependsOn, ...] (leading/trailing empty from outer pipes)
|
|
312
|
+
if (cells.length < 5)
|
|
313
|
+
continue;
|
|
314
|
+
// Status is the 3rd content cell (index 3 in the split array, after the leading empty).
|
|
315
|
+
const statusCell = cells[3];
|
|
316
|
+
if (!statusCell)
|
|
317
|
+
continue;
|
|
318
|
+
// A row is completed only if its STATUS cell contains ✅.
|
|
319
|
+
// This avoids false-positives from Depends-on column mentions, legend lines, and phase headers.
|
|
320
|
+
if (!statusCell.includes('✅'))
|
|
321
|
+
continue;
|
|
322
|
+
// Completed row: extract the first markdown link from the TITLE cell (index 2).
|
|
323
|
+
const titleCell = cells[2];
|
|
324
|
+
if (!titleCell)
|
|
325
|
+
continue;
|
|
326
|
+
const linkMatch = titleCell.match(/\[.*?\]\((.*?)\)/);
|
|
327
|
+
if (!linkMatch) {
|
|
328
|
+
// No link in the title cell — legacy/sibling WU (lives in a sibling repo, never had a done/ file here).
|
|
329
|
+
// Skip: not verifiable by this gate (presence-only, D130).
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const linkTarget = linkMatch[1];
|
|
333
|
+
// A completed row linking to a top-level NNN-...md (not done/) is drift: the WU was never moved.
|
|
334
|
+
if (!linkTarget.includes('done/')) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
// A completed row whose done/ file is missing is also drift.
|
|
338
|
+
const filePath = path.join(buildRoot, 'work-units', linkTarget);
|
|
339
|
+
const mtime = await fileMtime(filePath);
|
|
340
|
+
if (!mtime) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
async function checkStateMd(buildRoot, sessionStartMs, sessionDate) {
|
|
347
|
+
const stateMdPath = path.join(buildRoot, 'STATE.md');
|
|
348
|
+
const mtime = await fileMtime(stateMdPath);
|
|
349
|
+
const stateMdTouched = mtime ? mtime.getTime() > sessionStartMs : false;
|
|
350
|
+
const content = await fileContent(stateMdPath);
|
|
351
|
+
let stateMdLastUpdated = '';
|
|
352
|
+
let stateMdLastSessionResolves = false;
|
|
353
|
+
if (content) {
|
|
354
|
+
// Fix 1 (WU 112 fix-pass): accept all three "Last updated" shapes:
|
|
355
|
+
// table-row: | Last updated | 2026-05-31 (**Session 115 — ...**) ... |
|
|
356
|
+
// bold-colon: **Last updated:** 2026-05-31
|
|
357
|
+
// plain-colon: Last updated: 2026-05-31
|
|
358
|
+
// Anchor on the label text (colon optional), grab the FIRST YYYY-MM-DD on the same logical line.
|
|
359
|
+
// The [^\n]*? keeps the match within the label's own row.
|
|
360
|
+
const updatedMatch = content.match(/Last updated[^\n]*?(\d{4}-\d{2}-\d{2})/i);
|
|
361
|
+
if (updatedMatch) {
|
|
362
|
+
stateMdLastUpdated = updatedMatch[1];
|
|
363
|
+
}
|
|
364
|
+
// Fix 2 (WU 112 fix-pass): the real "Last session" row is narrative prose with NO markdown link.
|
|
365
|
+
// Real format: | Last session | Session 112 — ...prose... |
|
|
366
|
+
// Assert only that a non-empty "Last session" row/line is present (D130: do not overclaim).
|
|
367
|
+
// Link-resolution is dropped because the real format carries no link to resolve.
|
|
368
|
+
// Session-log existence on disk is independently verified by Step 7 (checkSessionLog).
|
|
369
|
+
const sessionLineMatch = content.match(/Last session[^\n]*/i);
|
|
370
|
+
if (sessionLineMatch) {
|
|
371
|
+
// The row is non-empty if it contains more than just the label itself.
|
|
372
|
+
const rowText = sessionLineMatch[0].replace(/Last session/i, '').replace(/[|:\s]/g, '');
|
|
373
|
+
stateMdLastSessionResolves = rowText.length > 0;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { stateMdTouched, stateMdLastUpdated, stateMdLastSessionResolves };
|
|
377
|
+
}
|
|
378
|
+
async function checkSessionLog(buildRoot, sessionDate) {
|
|
379
|
+
const sessionsDir = path.join(buildRoot, 'sessions');
|
|
380
|
+
const allSessionFiles = await listDir(sessionsDir);
|
|
381
|
+
// Look for sessions/<sessionDate>-*.md
|
|
382
|
+
const sessionLogFile = allSessionFiles.find((name) => name.startsWith(sessionDate + '-'));
|
|
383
|
+
const sessionLogExists = sessionLogFile !== undefined;
|
|
384
|
+
let sessionLogIndexed = false;
|
|
385
|
+
if (sessionLogExists && sessionLogFile) {
|
|
386
|
+
const indexPath = path.join(sessionsDir, '_index.md');
|
|
387
|
+
const indexContent = await fileContent(indexPath);
|
|
388
|
+
if (indexContent) {
|
|
389
|
+
sessionLogIndexed =
|
|
390
|
+
indexContent.includes(sessionLogFile) || indexContent.includes(sessionDate);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return { sessionLogExists, sessionLogIndexed };
|
|
394
|
+
}
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Report formatting
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
function formatReport(payload, today, resumedFrom, dryRun) {
|
|
399
|
+
const lines = [];
|
|
400
|
+
lines.push('');
|
|
401
|
+
lines.push('══════════════════════════════════════════════════════════════════════');
|
|
402
|
+
lines.push(` end-of-session: ${today}${dryRun ? ' (dry run)' : ''}`);
|
|
403
|
+
if (resumedFrom) {
|
|
404
|
+
lines.push(` resumed from step: ${resumedFrom}`);
|
|
405
|
+
}
|
|
406
|
+
lines.push('══════════════════════════════════════════════════════════════════════');
|
|
407
|
+
lines.push('');
|
|
408
|
+
const STEP_LABELS = {
|
|
409
|
+
update_active_wu_notes: 'Step 1 — WU notes updated',
|
|
410
|
+
capture_decisions: 'Step 2 — decisions captured',
|
|
411
|
+
capture_open_questions: 'Step 3 — open questions captured',
|
|
412
|
+
capture_risks: 'Step 4 — risks captured',
|
|
413
|
+
update_work_units_index: 'Step 5 — work-units index updated',
|
|
414
|
+
update_state_md: 'Step 6 — STATE.md updated',
|
|
415
|
+
write_session_log: 'Step 7 — session log written',
|
|
416
|
+
confirm_no_loss: 'Step 8 — confirm-no-loss gate',
|
|
417
|
+
report: 'Step 9 — report',
|
|
418
|
+
};
|
|
419
|
+
for (const [stepId, state] of Object.entries(payload.steps)) {
|
|
420
|
+
const label = STEP_LABELS[stepId] ?? stepId;
|
|
421
|
+
const icon = state.status === 'passed' ? ' [PASS]' : state.status === 'failed' ? ' [FAIL]' : ' [....] ';
|
|
422
|
+
lines.push(`${icon} ${label}`);
|
|
423
|
+
if (state.status === 'failed' && state.evidence) {
|
|
424
|
+
// Indent the failure detail.
|
|
425
|
+
for (const evidenceLine of state.evidence.split('\n')) {
|
|
426
|
+
lines.push(` ${evidenceLine}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
lines.push('');
|
|
431
|
+
if (payload.completed) {
|
|
432
|
+
lines.push(' GATE: PASSED — session marked complete.');
|
|
433
|
+
lines.push('');
|
|
434
|
+
lines.push(' NOTE: this gate verifies presence and structure only. It does NOT');
|
|
435
|
+
lines.push(' assert that STATE.md is factually correct or that session-log');
|
|
436
|
+
lines.push(' narrative is true. That judgement stays with the AI and operator.');
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
lines.push(` GATE: BLOCKED — ${payload.failingChecks.length} check(s) failed:`);
|
|
440
|
+
lines.push('');
|
|
441
|
+
for (const check of payload.failingChecks) {
|
|
442
|
+
for (const checkLine of check.split('\n')) {
|
|
443
|
+
lines.push(` ${checkLine}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
lines.push('');
|
|
447
|
+
lines.push(' Fix the failing checks (make the artefacts present and well-formed),');
|
|
448
|
+
lines.push(' then re-run `nuos-catalogue end-of-session` to continue / resume.');
|
|
449
|
+
}
|
|
450
|
+
lines.push('══════════════════════════════════════════════════════════════════════');
|
|
451
|
+
lines.push('');
|
|
452
|
+
return lines.join('\n');
|
|
453
|
+
}
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// Helpers
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
function resolveActiveWuHandle(store) {
|
|
458
|
+
// Try to read the active WU from the wu-active file (WU 136 mechanism).
|
|
459
|
+
// The wu-active command writes a marker file; we can't read it here without
|
|
460
|
+
// knowing the project root. Fall back to the most-recently-modified WU record.
|
|
461
|
+
const wuRecords = store.list().filter((r) => r.register === 'work_unit');
|
|
462
|
+
if (wuRecords.length === 0)
|
|
463
|
+
return null;
|
|
464
|
+
// Return the most recently modified in_progress WU, or any in_progress.
|
|
465
|
+
const inProgress = wuRecords.filter((r) => (r.status ?? '').includes('in_progress'));
|
|
466
|
+
if (inProgress.length > 0) {
|
|
467
|
+
return inProgress.sort((a, b) => (b.fileModifiedAt ?? '').localeCompare(a.fileModifiedAt ?? ''))[0].handle;
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -59,8 +59,28 @@ export interface InitResult {
|
|
|
59
59
|
output: string;
|
|
60
60
|
exitCode: number;
|
|
61
61
|
}
|
|
62
|
+
export declare const PROTOCOL_FILES: readonly ["start-of-session.md", "end-of-session.md", "wu-new.md", "persona-new.md", "plan-orientation.md", "plan-architecture.md", "plan-uiux.md", "plan-maps.md", "plan-initial-wu.md", "plan-review.md", "build-wu.md"];
|
|
63
|
+
/**
|
|
64
|
+
* The three AI coding tools the catalogue supports. Each tool reads
|
|
65
|
+
* project-level commands from a different path with a slightly different
|
|
66
|
+
* frontmatter shape. The body itself is identical across all three.
|
|
67
|
+
*
|
|
68
|
+
* The bundled `templates/protocols/<slug>.md` files are raw bodies (no
|
|
69
|
+
* frontmatter); `renderForTool` adds tool-appropriate frontmatter.
|
|
70
|
+
*/
|
|
71
|
+
type ToolSlug = 'claude' | 'opencode' | 'codex';
|
|
72
|
+
interface ToolTarget {
|
|
73
|
+
/** Tool's display name (for logs). */
|
|
74
|
+
label: string;
|
|
75
|
+
/** Path relative to project root, given a protocol slug. */
|
|
76
|
+
destPath: (slug: string) => string;
|
|
77
|
+
/** Render the file content given the slug and the raw body. */
|
|
78
|
+
render: (slug: string, body: string) => string;
|
|
79
|
+
}
|
|
80
|
+
export declare const TOOLS: Record<ToolSlug, ToolTarget>;
|
|
62
81
|
export declare function cmdInit(prompt: Prompt, options?: InitOptions): Promise<InitResult>;
|
|
63
82
|
export interface InstallProtocolsOptions {
|
|
64
83
|
cwd?: string;
|
|
65
84
|
}
|
|
66
85
|
export declare function cmdInstallProtocols(prompt: Prompt, options?: InstallProtocolsOptions): Promise<InitResult>;
|
|
86
|
+
export {};
|
package/dist/commands/init.js
CHANGED
|
@@ -33,7 +33,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
33
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
34
|
const PACKAGE_ROOT = path.resolve(path.dirname(__filename), '..', '..');
|
|
35
35
|
const TEMPLATES_ROOT = path.resolve(PACKAGE_ROOT, 'templates');
|
|
36
|
-
const PROTOCOL_FILES = [
|
|
36
|
+
export const PROTOCOL_FILES = [
|
|
37
37
|
'start-of-session.md',
|
|
38
38
|
'end-of-session.md',
|
|
39
39
|
'wu-new.md',
|
|
@@ -64,7 +64,7 @@ const PROTOCOL_DESCRIPTIONS = {
|
|
|
64
64
|
'plan-review': 'End-to-end planning review — surfaces gaps, inconsistencies, and optimisations before building starts',
|
|
65
65
|
'build-wu': 'Orchestrate a swarm of agents to build one work unit end-to-end',
|
|
66
66
|
};
|
|
67
|
-
const TOOLS = {
|
|
67
|
+
export const TOOLS = {
|
|
68
68
|
claude: {
|
|
69
69
|
label: 'Claude Code',
|
|
70
70
|
destPath: (slug) => path.join('.claude', 'commands', `${slug}.md`),
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* (`active → resolved by D-NNN`) and append a history entry to the
|
|
26
26
|
* resolving decision.
|
|
27
27
|
*/
|
|
28
|
-
import { writeFile, mkdir } from 'node:fs/promises';
|
|
28
|
+
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
|
29
29
|
import path from 'node:path';
|
|
30
30
|
import { replaceStatusLine, insertStatusLine, appendChangeLog, } from './markdown-edit.js';
|
|
31
31
|
import { tickAcceptanceCriterion, parseAcceptanceCriteria } from './ac-parse.js';
|
|
@@ -78,6 +78,12 @@ export function createBuildCatalogueMisAdapter(config) {
|
|
|
78
78
|
case 'open_question.resolve':
|
|
79
79
|
await commitResolveQuestion(store, catalogueRoot, intent);
|
|
80
80
|
break;
|
|
81
|
+
case 'end_of_session.step_verified':
|
|
82
|
+
case 'end_of_session.completed':
|
|
83
|
+
// D130/D129: write ONLY the session.end:<date> step-state record in
|
|
84
|
+
// the store. No catalogue prose is written; no markdown is rendered.
|
|
85
|
+
commitEndOfSessionRecord(store, intent);
|
|
86
|
+
break;
|
|
81
87
|
default:
|
|
82
88
|
throw new Error(`BuildCatalogueMisAdapter: intent type ${intent.type} is not handled by the build-catalogue pack`);
|
|
83
89
|
}
|
|
@@ -98,7 +104,12 @@ function isCreateIntent(intentType) {
|
|
|
98
104
|
return (intentType === 'work_unit.create' ||
|
|
99
105
|
intentType === 'decision.create' ||
|
|
100
106
|
intentType === 'open_question.create' ||
|
|
101
|
-
intentType === 'persona.create'
|
|
107
|
+
intentType === 'persona.create' ||
|
|
108
|
+
// end_of_session intents create/update the session.end:<date> record;
|
|
109
|
+
// the subject is not a catalogue artefact in the store, so skip the
|
|
110
|
+
// existence check.
|
|
111
|
+
intentType === 'end_of_session.step_verified' ||
|
|
112
|
+
intentType === 'end_of_session.completed');
|
|
102
113
|
}
|
|
103
114
|
// ---------------------------------------------------------------------------
|
|
104
115
|
// Per-intent commit handlers
|
|
@@ -199,10 +210,14 @@ async function commitAdvanceStatus(store, catalogueRoot, intent) {
|
|
|
199
210
|
}
|
|
200
211
|
// Map workflow status names to user-facing markdown status text.
|
|
201
212
|
const statusEmoji = mapStatusToEmojiText(payload.toStatus);
|
|
202
|
-
|
|
213
|
+
// D129: read the current on-disk file as the edit base so that hand-edits
|
|
214
|
+
// made after the last CLI write are preserved, not overwritten by the stale
|
|
215
|
+
// store snapshot in record.rawMarkdown.
|
|
216
|
+
const diskBase = await readDiskBase(catalogueRoot, record);
|
|
217
|
+
const replaced = replaceStatusLine(diskBase, statusEmoji);
|
|
203
218
|
let updatedMarkdown = replaced.replaced
|
|
204
219
|
? replaced.updated
|
|
205
|
-
: insertStatusLine(
|
|
220
|
+
: insertStatusLine(diskBase, statusEmoji);
|
|
206
221
|
updatedMarkdown = appendChangeLog(updatedMarkdown, {
|
|
207
222
|
isoTimestamp: new Date().toISOString(),
|
|
208
223
|
summary: `Status advanced ${payload.fromStatus} → ${payload.toStatus}.`,
|
|
@@ -219,14 +234,18 @@ async function commitTickAC(store, catalogueRoot, intent) {
|
|
|
219
234
|
if (!record) {
|
|
220
235
|
throw new Error(`commitTickAC: no record for ${payload.targetHandle}`);
|
|
221
236
|
}
|
|
237
|
+
// D129: read the current on-disk file as the edit base so that hand-edits
|
|
238
|
+
// made after the last CLI write are preserved, not overwritten by the stale
|
|
239
|
+
// store snapshot in record.rawMarkdown.
|
|
240
|
+
const diskBase = await readDiskBase(catalogueRoot, record);
|
|
222
241
|
// Try to flip the AC line in the markdown. If parsing succeeds we get
|
|
223
242
|
// the structural tick; otherwise we fall back to the audit-log-only
|
|
224
243
|
// approach (older/atypical AC shapes the parser doesn't recognise).
|
|
225
|
-
let workingMarkdown =
|
|
244
|
+
let workingMarkdown = diskBase;
|
|
226
245
|
let acText = payload.criterionText;
|
|
227
246
|
let structuralTick = false;
|
|
228
247
|
try {
|
|
229
|
-
const acs = parseAcceptanceCriteria(
|
|
248
|
+
const acs = parseAcceptanceCriteria(diskBase);
|
|
230
249
|
if (acs.length === 0) {
|
|
231
250
|
// No AC section recognised — audit-log-only.
|
|
232
251
|
}
|
|
@@ -235,7 +254,7 @@ async function commitTickAC(store, catalogueRoot, intent) {
|
|
|
235
254
|
}
|
|
236
255
|
else {
|
|
237
256
|
acText = acText ?? acs[payload.criterionIndex].text;
|
|
238
|
-
workingMarkdown = tickAcceptanceCriterion(
|
|
257
|
+
workingMarkdown = tickAcceptanceCriterion(diskBase, payload.criterionIndex);
|
|
239
258
|
structuralTick = true;
|
|
240
259
|
}
|
|
241
260
|
}
|
|
@@ -270,12 +289,15 @@ async function commitSupersede(store, catalogueRoot, intent) {
|
|
|
270
289
|
if (!superseding) {
|
|
271
290
|
throw new Error(`commitSupersede: no record for superseding ${payload.supersedingHandle}`);
|
|
272
291
|
}
|
|
292
|
+
// D129: read current on-disk files as the edit bases.
|
|
293
|
+
const targetDiskBase = await readDiskBase(catalogueRoot, target);
|
|
294
|
+
const supersedingDiskBase = await readDiskBase(catalogueRoot, superseding);
|
|
273
295
|
// Target: status accepted → superseded by D-NNN
|
|
274
296
|
const targetStatus = `superseded by ${payload.supersedingHandle}`;
|
|
275
|
-
const targetReplaced = replaceStatusLine(
|
|
297
|
+
const targetReplaced = replaceStatusLine(targetDiskBase, targetStatus);
|
|
276
298
|
let targetMarkdown = targetReplaced.replaced
|
|
277
299
|
? targetReplaced.updated
|
|
278
|
-
: insertStatusLine(
|
|
300
|
+
: insertStatusLine(targetDiskBase, targetStatus);
|
|
279
301
|
targetMarkdown = appendChangeLog(targetMarkdown, {
|
|
280
302
|
isoTimestamp: new Date().toISOString(),
|
|
281
303
|
summary: `Superseded by ${payload.supersedingHandle}.`,
|
|
@@ -284,7 +306,7 @@ async function commitSupersede(store, catalogueRoot, intent) {
|
|
|
284
306
|
});
|
|
285
307
|
await persist(store, catalogueRoot, target, targetMarkdown, { status: targetStatus });
|
|
286
308
|
// Superseding: append a Build catalogue history entry naming what it supersedes.
|
|
287
|
-
const supersedingMarkdown = appendChangeLog(
|
|
309
|
+
const supersedingMarkdown = appendChangeLog(supersedingDiskBase, {
|
|
288
310
|
isoTimestamp: new Date().toISOString(),
|
|
289
311
|
summary: `Supersedes ${payload.targetHandle}.`,
|
|
290
312
|
details: payload.reason,
|
|
@@ -302,11 +324,14 @@ async function commitResolveQuestion(store, catalogueRoot, intent) {
|
|
|
302
324
|
if (!decision) {
|
|
303
325
|
throw new Error(`commitResolveQuestion: no record for resolving decision ${payload.resolvingDecisionHandle}`);
|
|
304
326
|
}
|
|
327
|
+
// D129: read current on-disk files as the edit bases.
|
|
328
|
+
const questionDiskBase = await readDiskBase(catalogueRoot, question);
|
|
329
|
+
const decisionDiskBase = await readDiskBase(catalogueRoot, decision);
|
|
305
330
|
const questionStatus = `resolved by ${payload.resolvingDecisionHandle}`;
|
|
306
|
-
const questionReplaced = replaceStatusLine(
|
|
331
|
+
const questionReplaced = replaceStatusLine(questionDiskBase, questionStatus);
|
|
307
332
|
let questionMarkdown = questionReplaced.replaced
|
|
308
333
|
? questionReplaced.updated
|
|
309
|
-
: insertStatusLine(
|
|
334
|
+
: insertStatusLine(questionDiskBase, questionStatus);
|
|
310
335
|
questionMarkdown = appendChangeLog(questionMarkdown, {
|
|
311
336
|
isoTimestamp: new Date().toISOString(),
|
|
312
337
|
summary: `Resolved by ${payload.resolvingDecisionHandle}.`,
|
|
@@ -314,7 +339,7 @@ async function commitResolveQuestion(store, catalogueRoot, intent) {
|
|
|
314
339
|
reference: `intent ${intent.intentId}`,
|
|
315
340
|
});
|
|
316
341
|
await persist(store, catalogueRoot, question, questionMarkdown, { status: questionStatus });
|
|
317
|
-
const decisionMarkdown = appendChangeLog(
|
|
342
|
+
const decisionMarkdown = appendChangeLog(decisionDiskBase, {
|
|
318
343
|
isoTimestamp: new Date().toISOString(),
|
|
319
344
|
summary: `Resolves ${payload.targetHandle}.`,
|
|
320
345
|
details: payload.reason,
|
|
@@ -325,6 +350,35 @@ async function commitResolveQuestion(store, catalogueRoot, intent) {
|
|
|
325
350
|
// ---------------------------------------------------------------------------
|
|
326
351
|
// Helpers
|
|
327
352
|
// ---------------------------------------------------------------------------
|
|
353
|
+
/**
|
|
354
|
+
* Read the current on-disk content of `record`'s source file as the edit
|
|
355
|
+
* base for write commands (D129: disk is canonical in Mode 1).
|
|
356
|
+
*
|
|
357
|
+
* Falls back to `record.rawMarkdown` only when the file has never been
|
|
358
|
+
* written to disk (i.e. `fileExistsMustBeTrue` is false — used for the
|
|
359
|
+
* create path where the file is written in the same operation). For all
|
|
360
|
+
* mutation paths (tick, advance, supersede, resolve) the file must already
|
|
361
|
+
* exist on disk; if it is missing the command refuses with a clear message
|
|
362
|
+
* rather than silently clobbering with a stale store snapshot.
|
|
363
|
+
*/
|
|
364
|
+
async function readDiskBase(catalogueRoot, record, options = { mustExist: true }) {
|
|
365
|
+
const sourceAbsolute = path.join(catalogueRoot, record.sourcePath);
|
|
366
|
+
try {
|
|
367
|
+
return await readFile(sourceAbsolute, 'utf8');
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
if (err.code === 'ENOENT') {
|
|
371
|
+
if (options.mustExist) {
|
|
372
|
+
throw new Error(`Store-coherence error: the on-disk file for ${record.handle} ` +
|
|
373
|
+
`(${record.sourcePath}) no longer exists. ` +
|
|
374
|
+
`Run 'nuos-catalogue migrate' to re-sync the workflow store before writing.`);
|
|
375
|
+
}
|
|
376
|
+
// File not yet on disk (create path) — fall back to the store snapshot.
|
|
377
|
+
return record.rawMarkdown;
|
|
378
|
+
}
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
328
382
|
async function persist(store, catalogueRoot, record, newRawMarkdown, fieldUpdates = {}) {
|
|
329
383
|
const sourceAbsolute = path.join(catalogueRoot, record.sourcePath);
|
|
330
384
|
await writeFile(sourceAbsolute, newRawMarkdown, 'utf8');
|
|
@@ -338,6 +392,63 @@ async function persist(store, catalogueRoot, record, newRawMarkdown, fieldUpdate
|
|
|
338
392
|
};
|
|
339
393
|
store.put(updatedRecord);
|
|
340
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Persist the `session.end:<date>` step-state record in the workflow store.
|
|
397
|
+
*
|
|
398
|
+
* D130/D129: this function writes NO catalogue prose and renders NO markdown.
|
|
399
|
+
* Its only write is this record in the JSON workflow store — the workflow's
|
|
400
|
+
* own bookkeeping. The record is never rendered to a catalogue markdown file.
|
|
401
|
+
*
|
|
402
|
+
* The store key is `session.end:<sessionDate>` (the intent's subject id).
|
|
403
|
+
* The rawMarkdown field carries the step-state JSON (not markdown prose)
|
|
404
|
+
* so the existing MigratedRecord shape can be reused without schema changes.
|
|
405
|
+
*/
|
|
406
|
+
function commitEndOfSessionRecord(store, intent) {
|
|
407
|
+
const payload = intent.payload;
|
|
408
|
+
const sessionDate = payload.sessionDate;
|
|
409
|
+
const handle = `session.end:${sessionDate}`;
|
|
410
|
+
const now = new Date().toISOString();
|
|
411
|
+
// Use the existing record if it exists (preserve startedAt).
|
|
412
|
+
const existing = store.get(handle);
|
|
413
|
+
let startedAt = now;
|
|
414
|
+
if (existing) {
|
|
415
|
+
try {
|
|
416
|
+
const parsed = JSON.parse(existing.rawMarkdown);
|
|
417
|
+
startedAt = parsed.startedAt ?? now;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// If the existing record's JSON is malformed, reset.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const stepStateWithStarted = JSON.stringify({
|
|
424
|
+
handle,
|
|
425
|
+
sessionDate,
|
|
426
|
+
activeWuHandle: payload.activeWuHandle,
|
|
427
|
+
steps: payload.steps,
|
|
428
|
+
completed: payload.completed,
|
|
429
|
+
failingChecks: payload.failingChecks,
|
|
430
|
+
startedAt,
|
|
431
|
+
...(payload.completed ? { completedAt: now } : {}),
|
|
432
|
+
}, null, 2);
|
|
433
|
+
// Store as MigratedRecord with register='session' (cast needed because
|
|
434
|
+
// Register type doesn't include 'session' — this record is the workflow's
|
|
435
|
+
// own bookkeeping, not a catalogue artefact, and is never scanned by
|
|
436
|
+
// the migration runner).
|
|
437
|
+
const record = {
|
|
438
|
+
handle,
|
|
439
|
+
number: 0,
|
|
440
|
+
register: 'session',
|
|
441
|
+
title: `End-of-session: ${sessionDate}`,
|
|
442
|
+
status: payload.completed ? 'completed' : 'in_progress',
|
|
443
|
+
slug: `end-${sessionDate}`,
|
|
444
|
+
sourcePath: `.nuos-catalogue/session.end.${sessionDate}.json`,
|
|
445
|
+
rawMarkdown: stepStateWithStarted,
|
|
446
|
+
fileModifiedAt: now,
|
|
447
|
+
migratedAt: existing?.migratedAt ?? now,
|
|
448
|
+
migratedFrom: 'markdown',
|
|
449
|
+
};
|
|
450
|
+
store.put(record);
|
|
451
|
+
}
|
|
341
452
|
function mapStatusToEmojiText(workflowStatus) {
|
|
342
453
|
// The build-catalogue pack uses internal status enum strings
|
|
343
454
|
// (proposed/ready/in_progress/in_review/completed/superseded/...).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nusoft/nuos-build-catalogue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "NuOS build-catalogue tooling: semantic search (WU 110) + migration runner that lifts markdown artefacts into JSON-backed workflow records (WU 111, Phase G).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"build": "rm -rf dist && tsc && chmod +x dist/cli.js",
|
|
20
20
|
"prepublishOnly": "npm run build",
|
|
21
21
|
"verify-storage": "tsx scripts/verify-persistence.ts",
|
|
22
|
-
"test": "tsx --test tests/chunk.test.ts tests/metadata.test.ts tests/crawl.test.ts tests/migrate.test.ts tests/commands-read.test.ts tests/regenerate.test.ts tests/commands-write.test.ts tests/ac-parse.test.ts tests/create.test.ts tests/init.test.ts tests/wu-111-soak-findings.test.ts tests/plan.test.ts tests/mode.test.ts tests/render.test.ts tests/swarm.test.ts tests/setup-progress-bar.test.ts tests/setup-ollama-pull.test.ts tests/setup-run-llm-setup.test.ts tests/wu-active.test.ts tests/install-claude-hooks.test.ts",
|
|
22
|
+
"test": "tsx --test tests/chunk.test.ts tests/metadata.test.ts tests/crawl.test.ts tests/migrate.test.ts tests/commands-read.test.ts tests/regenerate.test.ts tests/commands-write.test.ts tests/ac-parse.test.ts tests/create.test.ts tests/init.test.ts tests/wu-111-soak-findings.test.ts tests/plan.test.ts tests/mode.test.ts tests/render.test.ts tests/swarm.test.ts tests/setup-progress-bar.test.ts tests/setup-ollama-pull.test.ts tests/setup-run-llm-setup.test.ts tests/wu-active.test.ts tests/install-claude-hooks.test.ts tests/protocols-in-sync.test.ts tests/end-of-session.test.ts",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"index": "tsx src/cli.ts index",
|
|
25
25
|
"search": "tsx src/cli.ts search"
|
package/scripts/hooks/pre-commit
CHANGED
|
@@ -68,8 +68,7 @@ check_index_drift() {
|
|
|
68
68
|
ids_in_tree=$(cd "$REPO_ROOT/$dir" && {
|
|
69
69
|
find . -maxdepth 2 -type f -name "*.md" \
|
|
70
70
|
-not -name "_index.md" \
|
|
71
|
-
-not -name "*-template.md" \
|
|
72
|
-
-not -name "*-template-*.md" 2>/dev/null \
|
|
71
|
+
-not -name "*-template.md" 2>/dev/null \
|
|
73
72
|
| sed -nE "$file_regex" \
|
|
74
73
|
| sort -u
|
|
75
74
|
})
|
package/scripts/install-hooks.sh
CHANGED
|
@@ -15,6 +15,64 @@
|
|
|
15
15
|
|
|
16
16
|
set -euo pipefail
|
|
17
17
|
|
|
18
|
+
# ---- Ensure nuos-catalogue CLI is installed --------------------------------
|
|
19
|
+
#
|
|
20
|
+
# The CLI is a global npm tool with no presence in any package.json — it
|
|
21
|
+
# disappears silently when global packages are cleared. Install it here so
|
|
22
|
+
# the build memory system is always ready after a post-clone setup run.
|
|
23
|
+
|
|
24
|
+
if ! command -v nuos-catalogue &>/dev/null; then
|
|
25
|
+
echo "▶ nuos-catalogue not found — installing @nusoft/nuos-build-catalogue globally..."
|
|
26
|
+
npm install -g @nusoft/nuos-build-catalogue
|
|
27
|
+
echo "✓ nuos-catalogue installed"
|
|
28
|
+
else
|
|
29
|
+
echo "✓ nuos-catalogue present ($(nuos-catalogue --version 2>/dev/null | head -1 || echo 'version unknown'))"
|
|
30
|
+
fi
|
|
31
|
+
echo
|
|
32
|
+
|
|
33
|
+
# ---- Patch ~/.claude/settings.json with the Playwright singleton hook ------
|
|
34
|
+
#
|
|
35
|
+
# Each VS Code Claude Code window spawns its own playwright-mcp process. They
|
|
36
|
+
# all share one Chrome profile, and Chrome's singleton lock means only the first
|
|
37
|
+
# one to open Chrome succeeds — every later session gets "browser already in use".
|
|
38
|
+
# This PreToolUse hook kills the locked Chrome before each browser_navigate so
|
|
39
|
+
# the current session always gets a clean launch.
|
|
40
|
+
|
|
41
|
+
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
|
|
42
|
+
PLAYWRIGHT_HOOK_MARKER="mcp__plugin_playwright_playwright__browser_navigate"
|
|
43
|
+
|
|
44
|
+
if [[ -f "$CLAUDE_SETTINGS" ]] && ! grep -q "$PLAYWRIGHT_HOOK_MARKER" "$CLAUDE_SETTINGS"; then
|
|
45
|
+
echo "▶ Patching ~/.claude/settings.json with Playwright singleton hook..."
|
|
46
|
+
node -e "
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const path = '$CLAUDE_SETTINGS';
|
|
49
|
+
const settings = JSON.parse(fs.readFileSync(path, 'utf8'));
|
|
50
|
+
settings.hooks = settings.hooks || {};
|
|
51
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse || [];
|
|
52
|
+
const alreadySet = settings.hooks.PreToolUse.some(h => h.matcher === '$PLAYWRIGHT_HOOK_MARKER');
|
|
53
|
+
if (!alreadySet) {
|
|
54
|
+
settings.hooks.PreToolUse.push({
|
|
55
|
+
matcher: '$PLAYWRIGHT_HOOK_MARKER',
|
|
56
|
+
hooks: [{
|
|
57
|
+
type: 'command',
|
|
58
|
+
command: \"pkill -f 'user-data-dir.*mcp-chrome' 2>/dev/null; sleep 0.5; exit 0\",
|
|
59
|
+
timeout: 5,
|
|
60
|
+
statusMessage: 'Clearing stale Playwright browser...'
|
|
61
|
+
}]
|
|
62
|
+
});
|
|
63
|
+
fs.writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
|
|
64
|
+
console.log('✓ Playwright singleton hook added');
|
|
65
|
+
} else {
|
|
66
|
+
console.log('✓ Playwright singleton hook already present');
|
|
67
|
+
}
|
|
68
|
+
"
|
|
69
|
+
elif [[ ! -f "$CLAUDE_SETTINGS" ]]; then
|
|
70
|
+
echo "⚠ ~/.claude/settings.json not found — skipping Playwright hook patch (Claude Code not installed?)"
|
|
71
|
+
else
|
|
72
|
+
echo "✓ Playwright singleton hook already present in ~/.claude/settings.json"
|
|
73
|
+
fi
|
|
74
|
+
echo
|
|
75
|
+
|
|
18
76
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
19
77
|
SOURCE="$REPO_ROOT/scripts/hooks"
|
|
20
78
|
TARGET="$REPO_ROOT/.git/hooks"
|
|
@@ -257,6 +257,12 @@ Tell the operator in plain English:
|
|
|
257
257
|
|
|
258
258
|
---
|
|
259
259
|
|
|
260
|
+
## Cost guidance
|
|
261
|
+
|
|
262
|
+
A typical full-feature swarm — architect (Opus, ~30 min) + coder (Sonnet, ~1 hr) + tester (Sonnet, ~30 min) + reviewer (Sonnet, ~15 min) — consumes substantially less of the operator's coding-tool plan budget than running the same work as a continuous Opus conversation. The 80/20 split — heavy reasoning for design and debugging only, lighter models for implementation and verification — is the lever. If a single work unit's swarm is consuming an unusual share of the day's plan budget, surface that to the operator before continuing; the WU is probably bigger than scoped.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
260
266
|
## Drift discipline
|
|
261
267
|
|
|
262
268
|
Every decision made by any agent during the swarm MUST land in the catalogue before the swarm closes — either as a decision file (if it's a project-wide commitment), in the work unit's notes (if scoped to this work), in the swarm audit entry (if it's about how the swarm ran). Decisions made inside agent conversations that don't reach the catalogue are drift.
|