@stravica-ai/digital-operator-pa 0.1.67 → 0.1.69
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/engineering-pa-cli/docs/coordinator-hook.md +14 -1
- package/dist/engineering-pa-cli/src/agent/cli.mjs +18 -8
- package/dist/engineering-pa-cli/src/agent/probes.mjs +94 -0
- package/dist/engineering-pa-cli/src/pa/board-check.mjs +633 -0
- package/dist/engineering-pa-cli/src/pa/cli.mjs +44 -7
- package/dist/engineering-pa-cli/src/pa/dispatch-cli.mjs +655 -0
- package/dist/engineering-pa-cli/src/pa/repo-root.mjs +29 -10
- package/dist/engineering-pa-cli/src/pa/session.mjs +13 -16
- package/dist/engineering-pa-cli/src/pa/status-board.mjs +5 -1
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/delegation-modes.md +1 -1
- package/dist/engineering-pa-core/knowledge/operator-interface/session-boot-and-end.md +4 -3
- package/dist/engineering-pa-core/knowledge/scaffold/agent-baseline.json +26 -1
- package/dist/engineering-pa-core/knowledge/scaffold/agent-baseline.schema.json +40 -1
- package/dist/engineering-pa-core/knowledge/work-management/spawn-log-lock.md +167 -0
- package/dist/engineering-pa-core/knowledge/work-management/spawn-log.md +34 -6
- package/dist/engineering-pa-core/knowledge/work-management/status-board.md +32 -2
- package/dist/engineering-pa-core/src/agent-baseline/manifest.mjs +2 -0
- package/dist/engineering-pa-core/src/dispatch/label.mjs +23 -0
- package/dist/engineering-pa-core/src/spawn-log/read.mjs +355 -0
- package/dist/engineering-pa-core/src/spawn-log/write.mjs +763 -0
- package/dist/engineering-pa-scaffold/src/generator.mjs +178 -9
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +4 -1
- package/dist/engineering-pa-workspace/README.md +31 -0
- package/dist/engineering-pa-workspace/app/delegations.html +21 -9
- package/dist/engineering-pa-workspace/app/home.html +1 -1
- package/dist/engineering-pa-workspace/app/notes.html +57 -2
- package/dist/engineering-pa-workspace/app/status-board.html +37 -0
- package/dist/engineering-pa-workspace/src/api.mjs +232 -61
- package/package.json +1 -1
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
// board-check.mjs - `pa board check [--json] [--hook-envelope] [--agent <name>]`.
|
|
2
|
+
//
|
|
3
|
+
// Read-only. Compares the PA's daily status board
|
|
4
|
+
// (agents/<agent>/workspace/status-board.json) with its spawn-log
|
|
5
|
+
// (agents/<agent>/spawn-log.json, every shape, through the core reader) and,
|
|
6
|
+
// when the instance keeps initiatives records, with the open questions under
|
|
7
|
+
// initiatives/<id>/questions/. It writes nothing anywhere: no cache, no lock.
|
|
8
|
+
// It reads the spawn-log's lock file (agents/<agent>/spawn-log.json.lock) and
|
|
9
|
+
// never writes, breaks or sweeps it.
|
|
10
|
+
//
|
|
11
|
+
// One problem line each for:
|
|
12
|
+
// - the board's updatedAt 90 minutes old or more (the Status page's red
|
|
13
|
+
// threshold) while a dispatch is open, or no board at all while one is;
|
|
14
|
+
// - an open dispatch with no board item (an item in groups[].items[] whose
|
|
15
|
+
// ids holds the dispatch id; a mention in a title does not count);
|
|
16
|
+
// - an open dispatch that has been open 24 to 48 hours (close it or check it);
|
|
17
|
+
// - the spawn-log lock held for longer than the stale window
|
|
18
|
+
// (LOCK_STALE_AFTER_MS, one minute): the line leads with
|
|
19
|
+
// `pa dispatch unlock` and the holder's pid, and its host and
|
|
20
|
+
// acquiredAt are capped for display (core capForDisplay), so the
|
|
21
|
+
// envelope's 360-character cut never reaches them; writers wait 10 s on
|
|
22
|
+
// it and then fail;
|
|
23
|
+
// - with initiatives records only: an open dispatch with no `initiative`,
|
|
24
|
+
// an open tagged dispatch with no `stage` (a stage is a non-empty string
|
|
25
|
+
// or an object with a numeric `n` and `m`), and an open question
|
|
26
|
+
// (status "open") whose id is in no onMe[].ids.
|
|
27
|
+
// "Open" is the core rule (isOpenDispatch) inside the 48 hour window: live
|
|
28
|
+
// under 24 hours, stale from 24 to 48.
|
|
29
|
+
// Status or outcome words on a dispatch launched in the last 48 hours that
|
|
30
|
+
// begin with neither a package OPEN word nor a known closed word are
|
|
31
|
+
// `unrecognised status` warnings: the human output gives one line per word
|
|
32
|
+
// with its count and at most five ids, `--json` lists every one. They never
|
|
33
|
+
// change the exit code.
|
|
34
|
+
//
|
|
35
|
+
// Exit codes: 0 clean | 1 usage error | 2 one or more problems |
|
|
36
|
+
// 3 the check could not run (no repo root, the spawn-log or the board
|
|
37
|
+
// unreadable or not valid JSON, or the board failing the schema).
|
|
38
|
+
// `--hook-envelope` prints one SessionStart hook envelope line and exits 0 on
|
|
39
|
+
// every path, so a session boot never sees a hook error.
|
|
40
|
+
//
|
|
41
|
+
// The logic is the pure `checkBoard`, so the workspace Status page banner
|
|
42
|
+
// and this command cannot disagree.
|
|
43
|
+
|
|
44
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
|
|
47
|
+
import {
|
|
48
|
+
SpawnLogError,
|
|
49
|
+
describeDispatch,
|
|
50
|
+
openDispatches,
|
|
51
|
+
readSpawnLogSync,
|
|
52
|
+
unrecognisedWords,
|
|
53
|
+
windowFor,
|
|
54
|
+
} from '../../../engineering-pa-core/src/spawn-log/read.mjs';
|
|
55
|
+
import {
|
|
56
|
+
LOCK_STALE_AFTER_MS,
|
|
57
|
+
describeLockHolder,
|
|
58
|
+
readSpawnLogLock,
|
|
59
|
+
} from '../../../engineering-pa-core/src/spawn-log/write.mjs';
|
|
60
|
+
|
|
61
|
+
import { getRepoRoot } from './repo-root.mjs';
|
|
62
|
+
import {
|
|
63
|
+
boardPath,
|
|
64
|
+
boardRelPath,
|
|
65
|
+
isValidAgentName,
|
|
66
|
+
readBoard,
|
|
67
|
+
resolveAgentName,
|
|
68
|
+
} from './status-board.mjs';
|
|
69
|
+
|
|
70
|
+
export const BOARD_STALE_MINUTES = 90;
|
|
71
|
+
export const HOOK_ENVELOPE_MAX_CHARS = 2000;
|
|
72
|
+
export const SUBVERBS = ['check'];
|
|
73
|
+
|
|
74
|
+
const USAGE = `pa board - check the daily status board against the spawn-log
|
|
75
|
+
|
|
76
|
+
Usage:
|
|
77
|
+
pa board check [--json] [--hook-envelope] [--agent <name>]
|
|
78
|
+
pa board check --help
|
|
79
|
+
|
|
80
|
+
Reports, one line each:
|
|
81
|
+
- the board not updated for ${BOARD_STALE_MINUTES} minutes (or missing) while a dispatch is open
|
|
82
|
+
- an open dispatch with no board item (an item whose ids hold the dispatch id)
|
|
83
|
+
- a dispatch open 24 to 48 hours (close it or check it)
|
|
84
|
+
- the spawn-log lock held for more than ${LOCK_STALE_AFTER_MS / 60000} minute, naming its holder and
|
|
85
|
+
pa dispatch unlock (writers wait on it and then fail)
|
|
86
|
+
- with initiatives records: an open dispatch with no initiative, a tagged
|
|
87
|
+
dispatch with no stage, and an open question with no on-me entry
|
|
88
|
+
Unrecognised status or outcome words are warnings (exit 0): one line per word,
|
|
89
|
+
with its count and at most 5 ids; --json lists every one.
|
|
90
|
+
|
|
91
|
+
Flags:
|
|
92
|
+
--json One JSON object on stdout
|
|
93
|
+
--hook-envelope A Claude Code SessionStart hook envelope on stdout; always exits 0
|
|
94
|
+
--agent <name> The agent folder (default: preferences paName, else the
|
|
95
|
+
single agents/* folder holding next-session.md)
|
|
96
|
+
|
|
97
|
+
Read-only: never writes a file, and never breaks or removes the spawn-log lock.
|
|
98
|
+
|
|
99
|
+
Exit codes:
|
|
100
|
+
0 clean | 1 usage error | 2 problems found | 3 the check could not run
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
class UsageError extends Error {
|
|
104
|
+
constructor(msg) { super(msg); this.exitCode = 1; }
|
|
105
|
+
}
|
|
106
|
+
class PreconditionError extends Error {
|
|
107
|
+
constructor(msg) { super(msg); this.exitCode = 3; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isPlainObject(v) {
|
|
111
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toMs(now) {
|
|
115
|
+
if (now === undefined) return Date.now();
|
|
116
|
+
if (now instanceof Date) return now.getTime();
|
|
117
|
+
if (typeof now === 'function') return toMs(now());
|
|
118
|
+
return Number(now);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nonEmptyString(v) {
|
|
122
|
+
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A dispatch's stage, or null when it has none. A stage is present as a
|
|
127
|
+
* non-empty string (returned trimmed) or as an object with a finite numeric
|
|
128
|
+
* `n` and `m` (returned as `{ n, m }`, plus `label` when that is a
|
|
129
|
+
* non-empty string). Anything else is no stage.
|
|
130
|
+
*/
|
|
131
|
+
export function normaliseStage(v) {
|
|
132
|
+
const text = nonEmptyString(v);
|
|
133
|
+
if (text !== null) return text;
|
|
134
|
+
if (!isPlainObject(v) || !Number.isFinite(v.n) || !Number.isFinite(v.m)) return null;
|
|
135
|
+
const label = nonEmptyString(v.label);
|
|
136
|
+
return label === null ? { n: v.n, m: v.m } : { n: v.n, m: v.m, label };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function plural(n, one, many) {
|
|
140
|
+
return n + ' ' + (n === 1 ? one : many);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** "6 h", "6 h 5 min", "12 min", "2 d 3 h". */
|
|
144
|
+
export function formatAge(minutes) {
|
|
145
|
+
if (minutes === null || minutes === undefined || !Number.isFinite(minutes)) return 'an unknown time';
|
|
146
|
+
const m = Math.max(0, Math.floor(minutes));
|
|
147
|
+
if (m < 60) return m + ' min';
|
|
148
|
+
const h = Math.floor(m / 60);
|
|
149
|
+
if (h < 24) return h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '');
|
|
150
|
+
const d = Math.floor(h / 24);
|
|
151
|
+
return d + ' d' + (h % 24 ? ' ' + (h % 24) + ' h' : '');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function summarise(item, state) {
|
|
155
|
+
const rec = item.record;
|
|
156
|
+
return {
|
|
157
|
+
id: item.id,
|
|
158
|
+
description: describeDispatch(rec),
|
|
159
|
+
launchedAt: item.launchedAtMs === null ? null : new Date(item.launchedAtMs).toISOString(),
|
|
160
|
+
initiative: nonEmptyString(rec.initiative),
|
|
161
|
+
stage: normaliseStage(rec.stage),
|
|
162
|
+
state,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function boardIdSet(board, key) {
|
|
167
|
+
const ids = new Set();
|
|
168
|
+
if (!isPlainObject(board)) return ids;
|
|
169
|
+
const add = (list) => {
|
|
170
|
+
for (const it of Array.isArray(list) ? list : []) {
|
|
171
|
+
if (isPlainObject(it) && Array.isArray(it.ids)) for (const id of it.ids) if (typeof id === 'string') ids.add(id);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
if (key === 'groups') {
|
|
175
|
+
for (const g of Array.isArray(board.groups) ? board.groups : []) if (isPlainObject(g)) add(g.items);
|
|
176
|
+
} else {
|
|
177
|
+
add(board.onMe);
|
|
178
|
+
}
|
|
179
|
+
return ids;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The check itself. Pure: no IO, injected clock, never throws on malformed
|
|
184
|
+
* input.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} input
|
|
187
|
+
* @param {object|null} input.board the normalised board, or null when there is none
|
|
188
|
+
* @param {Array} input.dispatches merged spawn-log items from the core reader
|
|
189
|
+
* @param {Array<{id: string, initiative?: string}>} [input.questions] open questions
|
|
190
|
+
* @param {boolean} [input.hasInitiatives] at least one initiatives/<id>/initiative.json exists
|
|
191
|
+
* @param {object|null} [input.lock] the spawn-log lock as read (readSpawnLogLock),
|
|
192
|
+
* or null/absent when there is none or it was not read
|
|
193
|
+
* @param {string} [input.lockRelPath] the lock's path for the message
|
|
194
|
+
* @param {Date|number|Function} [input.now]
|
|
195
|
+
*/
|
|
196
|
+
export function checkBoard(input = {}) {
|
|
197
|
+
const nowMs = toMs(input.now);
|
|
198
|
+
const board = isPlainObject(input.board) ? input.board : null;
|
|
199
|
+
const dispatches = Array.isArray(input.dispatches) ? input.dispatches : [];
|
|
200
|
+
const questions = Array.isArray(input.questions) ? input.questions : [];
|
|
201
|
+
const hasInitiatives = input.hasInitiatives === true;
|
|
202
|
+
|
|
203
|
+
const { live, stale } = openDispatches(dispatches, { now: nowMs });
|
|
204
|
+
const open = [...live.map((d) => summarise(d, 'live')), ...stale.map((d) => summarise(d, 'stale'))];
|
|
205
|
+
|
|
206
|
+
const updatedAtMs = board ? Date.parse(board.updatedAt) : NaN;
|
|
207
|
+
const ageMinutes = Number.isFinite(updatedAtMs) ? Math.max(0, Math.floor((nowMs - updatedAtMs) / 60000)) : null;
|
|
208
|
+
const onBoard = boardIdSet(board, 'groups');
|
|
209
|
+
const onMe = boardIdSet(board, 'onMe');
|
|
210
|
+
|
|
211
|
+
const problems = [];
|
|
212
|
+
const n = open.length;
|
|
213
|
+
if (n > 0) {
|
|
214
|
+
const openText = plural(n, 'dispatch is', 'dispatches are') + ' open';
|
|
215
|
+
if (!board) {
|
|
216
|
+
problems.push({ kind: 'board-missing', message: 'no board yet while ' + openText });
|
|
217
|
+
} else if (ageMinutes === null || ageMinutes >= BOARD_STALE_MINUTES) {
|
|
218
|
+
problems.push({
|
|
219
|
+
kind: 'board-stale',
|
|
220
|
+
message: 'board last updated ' + formatAge(ageMinutes) + ' ago (' + board.updatedAt + ') while ' + openText,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const openNotOnBoard = [];
|
|
226
|
+
for (const d of open) {
|
|
227
|
+
if (d.id !== null && onBoard.has(d.id)) continue;
|
|
228
|
+
openNotOnBoard.push({ id: d.id, description: d.description, launchedAt: d.launchedAt, state: d.state });
|
|
229
|
+
problems.push({
|
|
230
|
+
kind: 'not-on-board',
|
|
231
|
+
dispatchId: d.id,
|
|
232
|
+
message: (d.id ?? 'a dispatch with no id') + ' is open and not on the board: ' + d.description,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
for (const d of open) {
|
|
236
|
+
if (d.state !== 'stale') continue;
|
|
237
|
+
problems.push({
|
|
238
|
+
kind: 'stale-open',
|
|
239
|
+
dispatchId: d.id,
|
|
240
|
+
message: (d.id ?? 'a dispatch with no id') + ' has been open 24 to 48 hours: close it or check it',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (hasInitiatives) {
|
|
244
|
+
for (const d of open) {
|
|
245
|
+
if (d.initiative === null) {
|
|
246
|
+
problems.push({ kind: 'untagged', dispatchId: d.id, message: (d.id ?? 'a dispatch with no id') + ' is open with no initiative' });
|
|
247
|
+
} else if (d.stage === null) {
|
|
248
|
+
problems.push({
|
|
249
|
+
kind: 'no-stage',
|
|
250
|
+
dispatchId: d.id,
|
|
251
|
+
message: (d.id ?? 'a dispatch with no id') + ' is tagged ' + d.initiative + ' and has no stage',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const q of questions) {
|
|
256
|
+
if (!isPlainObject(q) || typeof q.id !== 'string') continue;
|
|
257
|
+
if (onMe.has(q.id)) continue;
|
|
258
|
+
problems.push({ kind: 'question-not-on-me', questionId: q.id, message: q.id + ' is an open question with no on-me entry' });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const lock = isPlainObject(input.lock) && typeof input.lock.text === 'string' && Number.isFinite(input.lock.mtimeMs) ? input.lock : null;
|
|
263
|
+
if (lock) {
|
|
264
|
+
const holder = describeLockHolder(lock, nowMs);
|
|
265
|
+
if (holder.ageMs > LOCK_STALE_AFTER_MS) {
|
|
266
|
+
const rel = typeof input.lockRelPath === 'string' ? input.lockRelPath : 'the spawn-log lock';
|
|
267
|
+
problems.push({
|
|
268
|
+
kind: 'lock-held',
|
|
269
|
+
lockPath: rel,
|
|
270
|
+
holder: { state: holder.state, pid: holder.pid, hostname: holder.hostname, acquiredAt: holder.acquiredAt, ageMinutes: Math.floor(holder.ageMs / 60000) },
|
|
271
|
+
// The command and the holder's pid lead; every variable part is
|
|
272
|
+
// bounded (a pid, capped names, an age), so the envelope's
|
|
273
|
+
// 360-character cut can reach only the end of the --force sentence.
|
|
274
|
+
// lockPath and holder stay whole.
|
|
275
|
+
message: 'run `pa dispatch unlock` for the spawn-log lock' + (holder.pid !== null ? ' held by ' : ', ') + holder.text
|
|
276
|
+
+ '; writers wait 10 s, then fail'
|
|
277
|
+
+ (holder.provablyDead ? '' : '. If it refuses, use `--force` only once the holder\'s process is gone or stuck, never while it is still running')
|
|
278
|
+
+ '.',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const unrecognisedStatuses = [];
|
|
284
|
+
for (const d of dispatches) {
|
|
285
|
+
if (!isPlainObject(d) || !isPlainObject(d.record)) continue;
|
|
286
|
+
if (windowFor(d.launchedAtMs, nowMs) === null) continue;
|
|
287
|
+
for (const word of unrecognisedWords(d.record)) unrecognisedStatuses.push({ id: d.id ?? null, word });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const strip = ({ state, ...rest }) => rest;
|
|
291
|
+
return {
|
|
292
|
+
ok: problems.length === 0,
|
|
293
|
+
exitCode: problems.length === 0 ? 0 : 2,
|
|
294
|
+
board: {
|
|
295
|
+
present: board !== null,
|
|
296
|
+
updatedAt: board ? (board.updatedAt ?? null) : null,
|
|
297
|
+
ageMinutes,
|
|
298
|
+
},
|
|
299
|
+
open: {
|
|
300
|
+
live: open.filter((d) => d.state === 'live').map(strip),
|
|
301
|
+
stale: open.filter((d) => d.state === 'stale').map(strip),
|
|
302
|
+
},
|
|
303
|
+
openNotOnBoard,
|
|
304
|
+
problems,
|
|
305
|
+
unrecognisedStatuses,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Read the instance's initiatives records, read-only. `hasRecords` is true
|
|
311
|
+
* when at least one initiatives/<id>/initiative.json exists; `questions`
|
|
312
|
+
* lists every questions/*.json record whose status is "open".
|
|
313
|
+
*/
|
|
314
|
+
export function readInitiativesSync(repoRoot) {
|
|
315
|
+
const dir = join(repoRoot, 'initiatives');
|
|
316
|
+
let entries;
|
|
317
|
+
try {
|
|
318
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
319
|
+
} catch {
|
|
320
|
+
return { hasRecords: false, questions: [] };
|
|
321
|
+
}
|
|
322
|
+
let hasRecords = false;
|
|
323
|
+
const questions = [];
|
|
324
|
+
for (const e of entries) {
|
|
325
|
+
if (!e.isDirectory()) continue;
|
|
326
|
+
if (existsSync(join(dir, e.name, 'initiative.json'))) hasRecords = true;
|
|
327
|
+
const qdir = join(dir, e.name, 'questions');
|
|
328
|
+
let files;
|
|
329
|
+
try { files = readdirSync(qdir); } catch { continue; }
|
|
330
|
+
for (const f of files.sort()) {
|
|
331
|
+
if (!f.endsWith('.json')) continue;
|
|
332
|
+
let q;
|
|
333
|
+
try { q = JSON.parse(readFileSync(join(qdir, f), 'utf8')); } catch { continue; }
|
|
334
|
+
if (!isPlainObject(q) || typeof q.status !== 'string' || q.status.trim().toLowerCase() !== 'open') continue;
|
|
335
|
+
const id = nonEmptyString(q.id) ?? f.slice(0, -'.json'.length);
|
|
336
|
+
questions.push({ id, initiative: e.name });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { hasRecords, questions };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Spawn-log path of an agent, repo-relative. */
|
|
343
|
+
export function spawnLogRelPath(agent) {
|
|
344
|
+
return 'agents/' + agent + '/spawn-log.json';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// -- rendering --------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
function summaryLine(agent, result) {
|
|
350
|
+
const parts = [];
|
|
351
|
+
const live = result.open.live.length;
|
|
352
|
+
const stale = result.open.stale.length;
|
|
353
|
+
const nProblems = result.problems.length;
|
|
354
|
+
const nWarnings = result.unrecognisedStatuses.length;
|
|
355
|
+
const head = nProblems === 0
|
|
356
|
+
? 'board check: clean for ' + agent
|
|
357
|
+
: 'board check: ' + plural(nProblems, 'problem', 'problems') + ' for ' + agent;
|
|
358
|
+
parts.push(head + (nWarnings ? ', ' + plural(nWarnings, 'warning', 'warnings') : ''));
|
|
359
|
+
parts.push(result.board.present ? 'board updated ' + formatAge(result.board.ageMinutes) + ' ago' : 'no board yet');
|
|
360
|
+
const notOn = result.openNotOnBoard.length;
|
|
361
|
+
parts.push((live + stale) + ' open (' + live + ' live, ' + stale + ' stale), '
|
|
362
|
+
+ (notOn === 0 ? 'all on the board' : notOn + ' not on the board'));
|
|
363
|
+
return parts.join('; ');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Ids shown per unrecognised word in the human output; `--json` lists all. */
|
|
367
|
+
export const WARNING_IDS_SHOWN = 5;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* One warning line per unrecognised word (the trimmed word as written, in
|
|
371
|
+
* first-seen order): its count, then at most WARNING_IDS_SHOWN ids, then
|
|
372
|
+
* "+N more" when there are more.
|
|
373
|
+
*/
|
|
374
|
+
export function warningLines(unrecognisedStatuses) {
|
|
375
|
+
const byWord = new Map();
|
|
376
|
+
for (const w of Array.isArray(unrecognisedStatuses) ? unrecognisedStatuses : []) {
|
|
377
|
+
if (!isPlainObject(w)) continue;
|
|
378
|
+
const word = String(w.word ?? '').trim();
|
|
379
|
+
if (!byWord.has(word)) byWord.set(word, []);
|
|
380
|
+
byWord.get(word).push(w.id ?? '(no id)');
|
|
381
|
+
}
|
|
382
|
+
const lines = [];
|
|
383
|
+
for (const [word, ids] of byWord) {
|
|
384
|
+
const more = ids.length - WARNING_IDS_SHOWN;
|
|
385
|
+
lines.push('warning: unrecognised status "' + word + '" on ' + plural(ids.length, 'dispatch', 'dispatches') + ': '
|
|
386
|
+
+ ids.slice(0, WARNING_IDS_SHOWN).join(', ') + (more > 0 ? ' +' + more + ' more' : ''));
|
|
387
|
+
}
|
|
388
|
+
return lines;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Human lines: one per problem, one per unrecognised word (a count and at
|
|
393
|
+
* most five ids each), then the summary line.
|
|
394
|
+
*/
|
|
395
|
+
export function renderHuman(agent, result) {
|
|
396
|
+
const lines = [];
|
|
397
|
+
for (const p of result.problems) lines.push('problem: ' + p.message);
|
|
398
|
+
lines.push(...warningLines(result.unrecognisedStatuses));
|
|
399
|
+
lines.push(summaryLine(agent, result));
|
|
400
|
+
return lines.join('\n') + '\n';
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function oneLine(text, max) {
|
|
404
|
+
const flat = String(text).replace(/\s+/g, ' ').trim();
|
|
405
|
+
const cps = Array.from(flat);
|
|
406
|
+
return cps.length > max ? cps.slice(0, max).join('') + '...' : flat;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function envelope(context) {
|
|
410
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context } });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function fitSections(fixed, sections, footer, budget) {
|
|
414
|
+
const MORE_RESERVE = '- +99999 more'.length + 1;
|
|
415
|
+
const out = [...fixed];
|
|
416
|
+
let used = fixed.reduce((s, l) => s + l.length + 1, 0) + footer.reduce((s, l) => s + l.length + 1, 0);
|
|
417
|
+
for (const sec of sections) {
|
|
418
|
+
if (!sec.lines.length) continue;
|
|
419
|
+
if (used + sec.heading.length + 1 + MORE_RESERVE > budget) {
|
|
420
|
+
out.push('- +' + sec.lines.length + ' more ' + sec.unit);
|
|
421
|
+
used += MORE_RESERVE;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
out.push(sec.heading);
|
|
425
|
+
used += sec.heading.length + 1;
|
|
426
|
+
for (let i = 0; i < sec.lines.length; i++) {
|
|
427
|
+
const line = sec.lines[i];
|
|
428
|
+
const rest = sec.lines.length - i - 1;
|
|
429
|
+
const reserve = rest > 0 ? MORE_RESERVE : 0;
|
|
430
|
+
if (used + line.length + 1 + reserve > budget) {
|
|
431
|
+
out.push('- +' + (sec.lines.length - i) + ' more');
|
|
432
|
+
used += MORE_RESERVE;
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
out.push(line);
|
|
436
|
+
used += line.length + 1;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return [...out, ...footer].join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The SessionStart additionalContext for a finished check: the board's age,
|
|
444
|
+
* the open dispatches, the untagged ones and those with no board item, then
|
|
445
|
+
* the same problem lines as the human output. Lists are cut with a
|
|
446
|
+
* "+N more" line so the whole envelope stays under 2,000 characters; counts
|
|
447
|
+
* are never cut.
|
|
448
|
+
*/
|
|
449
|
+
export function buildHookEnvelope(agent, result, hasInitiatives) {
|
|
450
|
+
const live = result.open.live.length;
|
|
451
|
+
const stale = result.open.stale.length;
|
|
452
|
+
const untagged = result.problems.filter((p) => p.kind === 'untagged').length;
|
|
453
|
+
const fixed = [
|
|
454
|
+
'## Board check (live), agent ' + agent,
|
|
455
|
+
result.board.present
|
|
456
|
+
? 'Board: updated ' + formatAge(result.board.ageMinutes) + ' ago (' + result.board.updatedAt + ').'
|
|
457
|
+
: 'Board: none yet.',
|
|
458
|
+
'Open dispatches: ' + (live + stale) + ' (' + live + ' live, ' + stale + ' stale). Not on the board: '
|
|
459
|
+
+ result.openNotOnBoard.length + '. Untagged: ' + (hasInitiatives ? untagged : 'not checked (no initiatives records)') + '.',
|
|
460
|
+
];
|
|
461
|
+
const openLines = [...result.open.live.map((d) => ({ ...d, state: 'live' })), ...result.open.stale.map((d) => ({ ...d, state: 'stale' }))]
|
|
462
|
+
.map((d) => {
|
|
463
|
+
const flags = [d.state];
|
|
464
|
+
if (result.openNotOnBoard.some((x) => x.id === d.id)) flags.push('not on board');
|
|
465
|
+
if (hasInitiatives && d.initiative === null) flags.push('untagged');
|
|
466
|
+
return '- ' + (d.id ?? '(no id)') + ' [' + flags.join(', ') + '] ' + oneLine(d.description, 90);
|
|
467
|
+
});
|
|
468
|
+
const problemLines = result.problems.map((p) => '- ' + oneLine(p.message, p.kind === 'lock-held' ? 360 : 160));
|
|
469
|
+
const footer = [];
|
|
470
|
+
if (result.unrecognisedStatuses.length) {
|
|
471
|
+
footer.push('Unrecognised status words: ' + result.unrecognisedStatuses.length + ' (warnings; `pa board check` lists them).');
|
|
472
|
+
}
|
|
473
|
+
footer.push(result.problems.length
|
|
474
|
+
? 'Result: ' + plural(result.problems.length, 'problem', 'problems') + '. Put every open dispatch on the board, refresh it, and run `pa board check` until it passes.'
|
|
475
|
+
: 'Result: clean.');
|
|
476
|
+
|
|
477
|
+
let budget = HOOK_ENVELOPE_MAX_CHARS - 150;
|
|
478
|
+
for (;;) {
|
|
479
|
+
const context = fitSections(fixed, [
|
|
480
|
+
{ heading: 'Problems (' + problemLines.length + '):', lines: problemLines, unit: 'problems' },
|
|
481
|
+
{ heading: 'Open dispatches (' + openLines.length + '):', lines: openLines, unit: 'open dispatches' },
|
|
482
|
+
], footer, budget);
|
|
483
|
+
const text = envelope(context);
|
|
484
|
+
if (text.length < HOOK_ENVELOPE_MAX_CHARS || budget <= 300) return text;
|
|
485
|
+
budget -= 100;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** An envelope for a check that could not run or was called wrongly. */
|
|
490
|
+
export function buildFailureEnvelope(message) {
|
|
491
|
+
const context = '## Board check (live)\nThe board check could not run: ' + oneLine(message, 400)
|
|
492
|
+
+ '\nRun `pa board check` in a terminal to see why, and fix the board before the next action.';
|
|
493
|
+
return envelope(context);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// -- CLI --------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
const ALLOWED = ['json', 'hook-envelope', 'agent', 'help'];
|
|
499
|
+
const BOOLEAN = new Set(['json', 'hook-envelope', 'help']);
|
|
500
|
+
|
|
501
|
+
function parseFlags(args) {
|
|
502
|
+
const flags = {};
|
|
503
|
+
for (let i = 0; i < args.length; i++) {
|
|
504
|
+
const a = args[i];
|
|
505
|
+
if (a === '-h') { flags.help = true; continue; }
|
|
506
|
+
if (!a.startsWith('--')) throw new UsageError('unexpected argument: ' + a);
|
|
507
|
+
const eq = a.indexOf('=');
|
|
508
|
+
const key = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
509
|
+
if (!ALLOWED.includes(key)) throw new UsageError('unknown flag: --' + key);
|
|
510
|
+
if (BOOLEAN.has(key)) {
|
|
511
|
+
if (eq !== -1) throw new UsageError('--' + key + ' takes no value');
|
|
512
|
+
flags[key] = true;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (eq !== -1) { flags[key] = a.slice(eq + 1); continue; }
|
|
516
|
+
const v = args[i + 1];
|
|
517
|
+
if (v === undefined || v.startsWith('--')) throw new UsageError('--' + key + ' needs a value');
|
|
518
|
+
flags[key] = v;
|
|
519
|
+
i += 1;
|
|
520
|
+
}
|
|
521
|
+
if (flags.json && flags['hook-envelope']) throw new UsageError('give --json or --hook-envelope, not both');
|
|
522
|
+
return flags;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function gather(repoRoot, agent) {
|
|
526
|
+
const spawnLogFile = join(repoRoot, 'agents', agent, 'spawn-log.json');
|
|
527
|
+
let log;
|
|
528
|
+
try {
|
|
529
|
+
log = readSpawnLogSync(spawnLogFile);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
if (e instanceof SpawnLogError) {
|
|
532
|
+
throw new PreconditionError(e.message.replace(spawnLogFile, spawnLogRelPath(agent)));
|
|
533
|
+
}
|
|
534
|
+
throw e;
|
|
535
|
+
}
|
|
536
|
+
let parsed;
|
|
537
|
+
try {
|
|
538
|
+
parsed = await readBoard(boardPath(repoRoot, agent), { strictStatus: false });
|
|
539
|
+
} catch (e) {
|
|
540
|
+
if (e instanceof SyntaxError) throw new PreconditionError('board at ' + boardRelPath(agent) + ' is not valid JSON: ' + e.message);
|
|
541
|
+
throw new PreconditionError('could not read the board at ' + boardRelPath(agent) + ': ' + (e.message || e));
|
|
542
|
+
}
|
|
543
|
+
if (parsed && parsed.problems.length) {
|
|
544
|
+
throw new PreconditionError('board at ' + boardRelPath(agent) + ' does not match the schema: ' + parsed.problems.join('; '));
|
|
545
|
+
}
|
|
546
|
+
const initiatives = readInitiativesSync(repoRoot);
|
|
547
|
+
// Read-only: the lock file is read, never written, broken or swept.
|
|
548
|
+
let lock = null;
|
|
549
|
+
try { lock = await readSpawnLogLock(spawnLogFile); } catch { lock = null; }
|
|
550
|
+
return {
|
|
551
|
+
board: parsed ? parsed.board : null,
|
|
552
|
+
dispatches: log.dispatches,
|
|
553
|
+
questions: initiatives.questions,
|
|
554
|
+
hasInitiatives: initiatives.hasRecords,
|
|
555
|
+
lock,
|
|
556
|
+
lockRelPath: spawnLogRelPath(agent) + '.lock',
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @param {string[]} argv argv after `board` (subverb first)
|
|
562
|
+
* @param {object} [opts] test seams: repoRoot, now, stdout, stderr
|
|
563
|
+
*/
|
|
564
|
+
export async function run(argv, opts = {}) {
|
|
565
|
+
const out = opts.stdout ?? process.stdout;
|
|
566
|
+
const err = opts.stderr ?? process.stderr;
|
|
567
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
568
|
+
const hookMode = args.includes('--hook-envelope');
|
|
569
|
+
try {
|
|
570
|
+
const verb = args[0];
|
|
571
|
+
if ((verb === '-h' || verb === '--help') && !hookMode) { out.write(USAGE); return 0; }
|
|
572
|
+
if (verb === undefined) throw new UsageError('pa board needs a subverb: check');
|
|
573
|
+
if (!SUBVERBS.includes(verb)) throw new UsageError('unknown subverb: ' + verb);
|
|
574
|
+
const flags = parseFlags(args.slice(1));
|
|
575
|
+
if (flags.help && !hookMode) { out.write(USAGE); return 0; }
|
|
576
|
+
if (flags.agent !== undefined && !isValidAgentName(flags.agent)) {
|
|
577
|
+
throw new UsageError('--agent must be a lowercase agent folder name, got "' + flags.agent + '"');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let repoRoot = opts.repoRoot;
|
|
581
|
+
if (!repoRoot) {
|
|
582
|
+
try {
|
|
583
|
+
repoRoot = await getRepoRoot();
|
|
584
|
+
} catch (e) {
|
|
585
|
+
throw new PreconditionError(e.message || String(e));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const agent = flags.agent ?? resolveAgentName(repoRoot);
|
|
589
|
+
let input;
|
|
590
|
+
try {
|
|
591
|
+
input = await gather(repoRoot, agent);
|
|
592
|
+
} catch (e) {
|
|
593
|
+
if (e instanceof PreconditionError && flags.json) {
|
|
594
|
+
out.write(JSON.stringify({ ok: false, exitCode: 3, agent, error: e.message }, null, 2) + '\n');
|
|
595
|
+
}
|
|
596
|
+
throw e;
|
|
597
|
+
}
|
|
598
|
+
const result = checkBoard({ ...input, now: opts.now });
|
|
599
|
+
|
|
600
|
+
if (hookMode) {
|
|
601
|
+
out.write(buildHookEnvelope(agent, result, input.hasInitiatives) + '\n');
|
|
602
|
+
return 0;
|
|
603
|
+
}
|
|
604
|
+
if (flags.json) {
|
|
605
|
+
const payload = {
|
|
606
|
+
ok: result.ok,
|
|
607
|
+
exitCode: result.exitCode,
|
|
608
|
+
agent,
|
|
609
|
+
board: { path: boardRelPath(agent), ...result.board },
|
|
610
|
+
spawnLog: { path: spawnLogRelPath(agent) },
|
|
611
|
+
open: result.open,
|
|
612
|
+
problems: result.problems,
|
|
613
|
+
unrecognisedStatuses: result.unrecognisedStatuses,
|
|
614
|
+
};
|
|
615
|
+
out.write(JSON.stringify(payload, null, 2) + '\n');
|
|
616
|
+
return result.exitCode;
|
|
617
|
+
}
|
|
618
|
+
out.write(renderHuman(agent, result));
|
|
619
|
+
return result.exitCode;
|
|
620
|
+
} catch (e) {
|
|
621
|
+
if (hookMode) {
|
|
622
|
+
const prefix = e instanceof UsageError ? 'usage error: ' : '';
|
|
623
|
+
out.write(buildFailureEnvelope(prefix + (e && e.message ? e.message : String(e))) + '\n');
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
err.write('[pa board] ' + (e && e.message ? e.message : String(e)) + '\n');
|
|
627
|
+
if (e instanceof UsageError) {
|
|
628
|
+
err.write('Run `pa board check --help` for usage.\n');
|
|
629
|
+
return 1;
|
|
630
|
+
}
|
|
631
|
+
return 3;
|
|
632
|
+
}
|
|
633
|
+
}
|