@jaybeeuu/agent-cortex 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +30 -3
  3. package/agents/README.md +7 -2
  4. package/agents/guppi/agent.md +231 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph-plan/agent.md +6 -0
  9. package/bin/installers/claude.mjs +8 -23
  10. package/bin/installers/pi.mjs +8 -2
  11. package/extensions/interrupt-bookmark/README.md +129 -0
  12. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  13. package/extensions/interrupt-bookmark/index.ts +354 -0
  14. package/extensions/interrupt-bookmark/package.json +20 -0
  15. package/extensions/skill-stats/README.md +84 -3
  16. package/extensions/skill-stats/index.ts +62 -3
  17. package/extensions/skill-stats/package.json +1 -1
  18. package/extensions/skill-stats/rollup.test.ts +643 -0
  19. package/extensions/skill-stats/rollup.ts +415 -0
  20. package/extensions/skill-stats/timeline.ts +101 -0
  21. package/package.json +1 -1
  22. package/scripts/lib/compose-agent.mjs +45 -7
  23. package/scripts/lib/manifest.mjs +29 -0
  24. package/skills/review/review-defects/EXAMPLES.md +80 -4
  25. package/skills/review/review-defects/SKILL.md +47 -16
  26. package/skills/review/review-report/EXAMPLES.md +118 -0
  27. package/skills/review/review-report/SKILL.md +138 -0
  28. package/skills/review/review-report/scripts/report.mjs +1032 -0
  29. package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
  30. package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
  31. package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
  32. package/skills/workflow/hitl-queue/SCOPES.md +118 -0
  33. package/skills/workflow/hitl-queue/SKILL.md +150 -0
  34. package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
  35. package/skills/workflow/hitl-queue/scripts/queue.mjs +1012 -0
  36. package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
  37. package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
  38. package/skills/workflow/plan/SKILL.md +13 -0
  39. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  40. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  41. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  42. package/skills/workflow/prioritise-work/scripts/strategy.mjs +756 -0
  43. package/skills/workflow/prioritise-work/templates/strategy.md +87 -0
  44. package/skills/workflow/ralph/REFERENCE.md +6 -1
  45. package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
  46. package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
  47. package/skills/workflow/score-bead/FORMAT.md +73 -0
  48. package/skills/workflow/score-bead/SKILL.md +112 -0
  49. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  50. package/skills/workflow/using-agent-skills/SKILL.md +6 -1
  51. package/token-map.README.md +14 -2
  52. package/token-map.json +17 -3
@@ -0,0 +1,479 @@
1
+ // @ts-check
2
+ // The scope list: which bd workspaces the HITL queue reads, and which ones it may write.
3
+ //
4
+ // A scope is one bd workspace. The queue reads every configured scope, tags every row with
5
+ // the scope it came from, and reports a scope it cannot read as unavailable rather than
6
+ // failing the run — a personal workspace will not exist on every machine.
7
+ //
8
+ // Three rules keep the list safe to ship and safe to copy between machines:
9
+ //
10
+ // • **A scope you did not mark writable is read-only.** `readOnly` defaults to `true` in
11
+ // the file, and a scope is writable only when it is not read-only *and* its resolved
12
+ // path is the current workspace. Every other scope is read-only whatever the file says,
13
+ // so a copied config cannot hand a personal workspace to a code session.
14
+ // • **A scope is available only when it is its own tracker.** bd resolves a workspace by
15
+ // walking up from the current directory, so a configured directory with no `.beads` of
16
+ // its own would silently read the enclosing workspace's beads and tag them with the
17
+ // wrong scope. The marker check refuses that and reports the scope unavailable.
18
+ // • **A read-only scope only ever receives a read call.** `guardRunner` refuses any bd
19
+ // call that is not one of the two the queue issues, so the boundary is enforced at the
20
+ // one place every tracker call passes through, not by remembering not to write.
21
+ //
22
+ // Zero runtime dependencies, async fs only. The configuration is a file with a documented
23
+ // default: `<repository root>/.agent-cortex/scopes.json`, then
24
+ // `<AGENT_CORTEX_HOME|~/.agent-cortex>/scopes.json`, then one implicit writable scope for
25
+ // the current workspace. No path and no hostname of one machine ships in this module.
26
+
27
+ import { access, readFile, realpath, stat } from 'node:fs/promises';
28
+ import { homedir } from 'node:os';
29
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
30
+
31
+ import { findRepositoryRoot, resolveStrategyPath, validateStrategy } from '../../prioritise-work/scripts/strategy.mjs';
32
+
33
+ /** @typedef {(args: string[]) => Promise<string>} Runner */
34
+ /** @typedef {(args: string[], scope: Scope) => Promise<string>} ScopedRunner */
35
+
36
+ // ─── Contract ─────────────────────────────────────────────────────────────────
37
+
38
+ /** The project-level scope list, relative to the repository root. */
39
+ export const DEFAULT_SCOPES_FILE = '.agent-cortex/scopes.json';
40
+
41
+ /** Overrides the install root the user-level scope list is read from. */
42
+ export const HOME_ENV = 'AGENT_CORTEX_HOME';
43
+
44
+ /** Marks an invocation whose output will leave this machine. */
45
+ export const SHARED_ENV = 'AGENT_CORTEX_QUEUE_SHARED';
46
+
47
+ /** The scope kinds. A kind says what the work is, so a reader knows what a row is about. */
48
+ export const SCOPE_KINDS = /** @type {const} */ (['code', 'personal', 'ops']);
49
+
50
+ /**
51
+ * The kind whose rows and strategy text a shared invocation withholds. Personal work is
52
+ * the one class that must never travel to another workspace.
53
+ */
54
+ export const PERSONAL_KIND = 'personal';
55
+
56
+ /** The name of the implicit scope: the current workspace, when no list is configured. */
57
+ export const DEFAULT_SCOPE_NAME = 'code';
58
+
59
+ /** The directory bd keeps in a workspace. Its absence means the workspace is uninitialised. */
60
+ export const WORKSPACE_MARKER = '.beads';
61
+
62
+ /** A scope the file does not mark writable is read-only: the safe reading of an omission. */
63
+ export const DEFAULT_READ_ONLY = true;
64
+
65
+ /**
66
+ * The bd calls this skill issues, as argv prefixes. `dep list` is a read; `dep add` is not,
67
+ * which is why the guard matches the second word rather than the first.
68
+ * @type {readonly (readonly string[])[]}
69
+ */
70
+ export const READ_CALLS = /** @type {const} */ ([['list'], ['dep', 'list']]);
71
+
72
+ /** The word that starts a daily scope line in a strategy file. */
73
+ export const DAILY_MARKER = 'daily';
74
+
75
+ /** A usage mistake: the scope list the caller named cannot be read. Exit code 2. */
76
+ export class ScopeError extends Error {}
77
+
78
+ /** A write attempted against a read-only scope. This is a defect in the caller, not the machine. */
79
+ export class ScopeWriteError extends Error {}
80
+
81
+ // ─── Types ────────────────────────────────────────────────────────────────────
82
+
83
+ /** @typedef {'code' | 'personal' | 'ops'} ScopeKind */
84
+
85
+ /**
86
+ * One configured scope, resolved to an absolute path.
87
+ * @typedef {object} Scope
88
+ * @property {string} name
89
+ * @property {string} path
90
+ * @property {ScopeKind} kind
91
+ * @property {boolean} readOnly As the file declares it.
92
+ * @property {boolean} current The resolved path is the current workspace.
93
+ * @property {boolean} writable `!readOnly && current` — the only writable scope.
94
+ */
95
+
96
+ /**
97
+ * The daily scope note: which scopes the day belongs to, read from the personal strategy.
98
+ * @typedef {object} DailyNote
99
+ * @property {string} scopeName The personal scope it was read from.
100
+ * @property {string[]} scopes The scope names the line leads with.
101
+ * @property {string} line The strategy line, verbatim.
102
+ * @property {string | undefined} section The `##` heading it sat under.
103
+ * @property {number | undefined} strategyVersion
104
+ * @property {string | undefined} reason Why there is no note instead of one.
105
+ * @property {boolean} [withheld] True when a shared invocation withholds it.
106
+ */
107
+
108
+ // ─── Paths ────────────────────────────────────────────────────────────────────
109
+
110
+ /** @param {string} path @returns {Promise<boolean>} */
111
+ async function exists(path) {
112
+ return await access(path).then(
113
+ () => true,
114
+ () => false,
115
+ );
116
+ }
117
+
118
+ /** The install root, or where the user-level scope list lives when it is not overridden.
119
+ * @returns {string}
120
+ */
121
+ export function cortexHome() {
122
+ const override = process.env[HOME_ENV];
123
+ return override !== undefined && override !== '' ? resolve(override) : join(homedir(), '.agent-cortex');
124
+ }
125
+
126
+ /**
127
+ * A workspace path as written in the file, resolved: `~` is the home directory, a relative
128
+ * path resolves against `base` (default: the directory the run was started from), and an
129
+ * existing path is resolved through symlinks so two spellings of one workspace compare
130
+ * equal.
131
+ * @param {string} path @param {string} [base] @returns {Promise<string>}
132
+ */
133
+ export async function resolveWorkspacePath(path, base = process.cwd()) {
134
+ const expanded = path === '~'
135
+ ? homedir()
136
+ : path.startsWith('~/')
137
+ ? join(homedir(), path.slice(2))
138
+ : path;
139
+ const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(base, expanded);
140
+ return await realpath(absolute).catch(() => absolute);
141
+ }
142
+
143
+ /**
144
+ * The workspace a run belongs to: the nearest ancestor of `cwd`, itself included, that
145
+ * carries `.beads`. bd resolves a workspace by walking up in exactly this way, so a run from
146
+ * a subdirectory belongs to the workspace above it — "current" means that workspace, never
147
+ * the subdirectory. With no ancestor marked, the directory the run started in is the answer.
148
+ * @param {string} cwd @returns {Promise<string>}
149
+ */
150
+ async function currentWorkspace(cwd) {
151
+ const from = await resolveWorkspacePath('.', cwd);
152
+ let directory = from;
153
+ for (;;) {
154
+ if (await exists(join(directory, WORKSPACE_MARKER))) return directory;
155
+ const parent = dirname(directory);
156
+ if (parent === directory) return from;
157
+ directory = parent;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The one scope a run reads when no list is configured: the current workspace, writable.
163
+ * @param {string} [cwd] @returns {Promise<Scope>}
164
+ */
165
+ export async function currentScope(cwd = process.cwd()) {
166
+ return {
167
+ name: DEFAULT_SCOPE_NAME,
168
+ path: await currentWorkspace(cwd),
169
+ kind: /** @type {ScopeKind} */ ('code'),
170
+ readOnly: false,
171
+ current: true,
172
+ writable: true,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * The scope list this repository or this user has configured, if any.
178
+ * @param {string} cwd @returns {Promise<string | undefined>}
179
+ */
180
+ async function defaultScopesFile(cwd) {
181
+ const root = await findRepositoryRoot(cwd);
182
+ const inRepository = join(root, DEFAULT_SCOPES_FILE);
183
+ if (await exists(inRepository)) return inRepository;
184
+ const inHome = join(cortexHome(), 'scopes.json');
185
+ if (await exists(inHome)) return inHome;
186
+ return undefined;
187
+ }
188
+
189
+ // ─── Configuration ────────────────────────────────────────────────────────────
190
+
191
+ /** @param {unknown} value @returns {Record<string, unknown> | undefined} */
192
+ function asRecord(value) {
193
+ return typeof value === 'object' && value !== null ? /** @type {Record<string, unknown>} */ (value) : undefined;
194
+ }
195
+
196
+ /** @param {unknown} value @returns {string | undefined} */
197
+ function asString(value) {
198
+ return typeof value === 'string' ? value : undefined;
199
+ }
200
+
201
+ /**
202
+ * Read one entry's name.
203
+ * @param {unknown} value @param {number} index @param {string} file @returns {string}
204
+ */
205
+ function readName(value, index, file) {
206
+ const name = asString(value)?.trim();
207
+ if (name === undefined || name === '') {
208
+ throw new ScopeError(`scope ${index + 1} in ${file} has no "name"`);
209
+ }
210
+ return name;
211
+ }
212
+
213
+ /**
214
+ * Read one entry's workspace path, resolved against the run's directory.
215
+ * @param {unknown} value @param {number} index @param {string} file
216
+ */
217
+ function readPath(value, index, file) {
218
+ const path = asString(value)?.trim();
219
+ if (path === undefined || path === '') {
220
+ throw new ScopeError(`scope ${index + 1} in ${file} has no "path"`);
221
+ }
222
+ return path;
223
+ }
224
+
225
+ /**
226
+ * Parse a scope list. Every defect names the scope, so one fix round clears the file.
227
+ *
228
+ * `readOnly` defaults to `true`, and `kind` to `code`. Writability is never taken from the
229
+ * file alone: a scope is writable only when it is not read-only and it is the current
230
+ * workspace, so a list copied onto another machine cannot widen the writable set.
231
+ *
232
+ * A relative `path` resolves against `base` — the repository root of the list itself — and
233
+ * never against the directory the run was started from, so the one committed list means the
234
+ * same thing run from a subdirectory, from a worktree, or from a hook.
235
+ *
236
+ * @param {string} text @param {object} options
237
+ * @param {string} options.file
238
+ * @param {string} options.base Directory a relative scope path resolves against.
239
+ * @param {string} [options.cwd] The current workspace, which decides writability.
240
+ * @returns {Promise<Scope[]>}
241
+ */
242
+ export async function parseScopeList(text, { file, base, cwd = process.cwd() }) {
243
+ /** @type {unknown} */
244
+ let parsed;
245
+ try {
246
+ parsed = JSON.parse(text);
247
+ } catch {
248
+ throw new ScopeError(`${file} is not JSON`);
249
+ }
250
+
251
+ const record = asRecord(parsed);
252
+ if (record === undefined) throw new ScopeError(`${file} must be a JSON object`);
253
+
254
+ const entries = record.scopes;
255
+ if (!Array.isArray(entries)) throw new ScopeError(`${file} must carry a "scopes" array`);
256
+ if (entries.length === 0) throw new ScopeError(`${file} lists no scopes`);
257
+
258
+ const current = await currentWorkspace(cwd);
259
+ /** @type {Scope[]} */
260
+ const scopes = [];
261
+ const seen = new Set();
262
+
263
+ for (const [index, entry] of entries.entries()) {
264
+ const item = asRecord(entry);
265
+ if (item === undefined) throw new ScopeError(`scope ${index + 1} in ${file} is not an object`);
266
+
267
+ const name = readName(item.name, index, file);
268
+ if (seen.has(name)) throw new ScopeError(`${file} names the scope "${name}" twice`);
269
+ seen.add(name);
270
+
271
+ const rawKind = item.kind;
272
+ /** @type {ScopeKind} */
273
+ let kind = /** @type {ScopeKind} */ ('code');
274
+ if (rawKind !== undefined) {
275
+ const matched = SCOPE_KINDS.find((candidate) => candidate === rawKind);
276
+ if (matched === undefined) {
277
+ throw new ScopeError(
278
+ `scope "${name}" in ${file} has kind ${JSON.stringify(rawKind)}; ` +
279
+ `use one of ${SCOPE_KINDS.join(', ')}`,
280
+ );
281
+ }
282
+ kind = matched;
283
+ }
284
+
285
+ const rawReadOnly = item.readOnly;
286
+ if (rawReadOnly !== undefined && typeof rawReadOnly !== 'boolean') {
287
+ throw new ScopeError(`scope "${name}" in ${file} has a non-boolean "readOnly"`);
288
+ }
289
+
290
+ const path = await resolveWorkspacePath(readPath(item.path, index, file), base);
291
+ const isCurrent = path === current;
292
+ const readOnly = rawReadOnly ?? DEFAULT_READ_ONLY;
293
+ scopes.push({ name, path, kind, readOnly, current: isCurrent, writable: !readOnly && isCurrent });
294
+ }
295
+
296
+ return scopes;
297
+ }
298
+
299
+ /**
300
+ * The scope list for one run. A named file that cannot be read is a usage error; no file
301
+ * at all is not, because the one-workspace queue is the documented default.
302
+ * @param {object} [options]
303
+ * @param {string} [options.file] The scope list to read, when the caller named one.
304
+ * @param {string} [options.cwd]
305
+ * @returns {Promise<{ file: string | undefined, configured: boolean, scopes: Scope[] }>}
306
+ */
307
+ export async function readScopeConfig({ file, cwd = process.cwd() } = {}) {
308
+ const path = file === undefined ? await defaultScopesFile(cwd) : await resolveWorkspacePath(file, cwd);
309
+ if (path === undefined) {
310
+ return { file: undefined, configured: false, scopes: [await currentScope(cwd)] };
311
+ }
312
+
313
+ const text = await readFile(path, 'utf8').catch(() => undefined);
314
+ if (text === undefined) throw new ScopeError(`${path} cannot be read`);
315
+ const base = await findRepositoryRoot(dirname(path));
316
+ return { file: path, configured: true, scopes: await parseScopeList(text, { file: path, base, cwd }) };
317
+ }
318
+
319
+ // ─── Availability ─────────────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * Why a scope cannot be read, or `undefined` when it can. A missing or uninitialised
323
+ * workspace is the expected case on a machine that does not carry the personal repo, so it
324
+ * is reported and skipped, never thrown.
325
+ * @param {Scope} scope @returns {Promise<string | undefined>}
326
+ */
327
+ export async function unavailableReason(scope) {
328
+ const info = await stat(scope.path).catch(() => undefined);
329
+ if (info === undefined) return 'workspace does not exist';
330
+ if (!info.isDirectory()) return 'workspace path is not a directory';
331
+ const marker = await stat(join(scope.path, WORKSPACE_MARKER)).catch(() => undefined);
332
+ if (marker === undefined || !marker.isDirectory()) {
333
+ return `no ${WORKSPACE_MARKER} workspace here, so it is not initialised`;
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ // ─── The read-only guard ──────────────────────────────────────────────────────
339
+
340
+ /**
341
+ * Whether an argv is one of the calls this skill may issue against any scope.
342
+ * @param {string[]} args @returns {boolean}
343
+ */
344
+ export function isReadCall(args) {
345
+ return READ_CALLS.some((prefix) => prefix.every((word, index) => args[index] === word));
346
+ }
347
+
348
+ /**
349
+ * Wrap a scope's runner so a read-only scope can only ever receive a read call.
350
+ *
351
+ * The queue issues two reads and no writes, so this guard is what keeps that true after a
352
+ * later change: the boundary sits on the one function every tracker call for a scope passes
353
+ * through, not in the memory of whoever edits the queue next.
354
+ * @param {Scope} scope @param {ScopedRunner} bd @returns {Runner}
355
+ */
356
+ export function guardRunner(scope, bd) {
357
+ return async (args) => {
358
+ if (!scope.writable && !isReadCall(args)) {
359
+ throw new ScopeWriteError(
360
+ `refusing "bd ${args.join(' ')}" on scope "${scope.name}": ` +
361
+ (scope.readOnly ? 'the scope is read-only' : 'only the current workspace is writable'),
362
+ );
363
+ }
364
+ return await bd(args, scope);
365
+ };
366
+ }
367
+
368
+ // ─── The daily note ───────────────────────────────────────────────────────────
369
+
370
+ /** A daily line: an optional bullet, the marker, then the scope names. */
371
+ const DAILY_LINE = /^[ \t]*(?:[-*][ \t]+)?daily:[ \t]*(\S.*?)[ \t]*$/i;
372
+
373
+ /** The bullet in front of a list item, so the quoted line carries no markup. */
374
+ const BULLET = /^[ \t]*[-*][ \t]+/;
375
+
376
+ /** A markdown heading, with its title captured. */
377
+ const HEADING = /^[ \t]*##[ \t]+(.*?)[ \t]*$/;
378
+
379
+ /** The separator between the scope names and the reason in a daily line. */
380
+ const REASON_SEPARATOR = /\s+(?:—|--)\s+/;
381
+
382
+ /**
383
+ * The scope names a daily line leads with: everything before the em dash, comma separated.
384
+ * @param {string} rest @returns {string[]}
385
+ */
386
+ export function dailyScopes(rest) {
387
+ const [names = ''] = rest.split(REASON_SEPARATOR);
388
+ return names
389
+ .split(',')
390
+ .map((name) => name.trim().replace(/^[`"']|[`"']$/g, ''))
391
+ .filter((name) => name !== '');
392
+ }
393
+
394
+ /**
395
+ * The nearest `##` heading above a line, so the citation names where it was read.
396
+ * @param {string[]} lines @param {number} index @returns {string | undefined}
397
+ */
398
+ function sectionAt(lines, index) {
399
+ for (let i = index; i >= 0; i--) {
400
+ const match = HEADING.exec(lines[i]);
401
+ if (match !== null) return match[1];
402
+ }
403
+ return undefined;
404
+ }
405
+
406
+ /**
407
+ * Read one scope's strategy for a daily line.
408
+ * @param {Scope} scope @returns {Promise<DailyNote | undefined>}
409
+ */
410
+ async function dailyNoteFrom(scope) {
411
+ const path = await resolveStrategyPath({ cwd: scope.path }).catch(() => undefined);
412
+ if (path === undefined) return undefined;
413
+ const text = await readFile(path, 'utf8').catch(() => undefined);
414
+ if (text === undefined) return undefined;
415
+
416
+ const lines = text.split('\n');
417
+ const index = lines.findIndex((line) => DAILY_LINE.test(line));
418
+ if (index === -1) return undefined;
419
+
420
+ const match = /** @type {RegExpExecArray} */ (DAILY_LINE.exec(lines[index]));
421
+ const strategy = validateStrategy(text);
422
+ return {
423
+ scopeName: scope.name,
424
+ scopes: dailyScopes(match[1]),
425
+ line: lines[index].replace(BULLET, '').replace(/\s+$/, ''),
426
+ section: sectionAt(lines, index),
427
+ strategyVersion: strategy.ok ? strategy.version : undefined,
428
+ reason: undefined,
429
+ };
430
+ }
431
+
432
+ /**
433
+ * The daily scope note: which scopes the day belongs to, taken from the personal strategy
434
+ * and quoting the line it came from.
435
+ *
436
+ * The personal scope is the one whose strategy is personal, so its strategy is where a
437
+ * daily line belongs. When several personal scopes are configured, the first one that
438
+ * carries a daily line answers; when none does, the reason is reported instead of a note.
439
+ * @param {Scope[]} scopes @returns {Promise<DailyNote>}
440
+ */
441
+ export async function readDailyNote(scopes) {
442
+ const personal = scopes.filter((scope) => scope.kind === PERSONAL_KIND);
443
+ if (personal.length === 0) {
444
+ return emptyNote(
445
+ `no ${PERSONAL_KIND} scope is configured, so there is no personal strategy to read a ` +
446
+ `"${DAILY_MARKER}:" line from`,
447
+ );
448
+ }
449
+
450
+ /** @type {string[]} */
451
+ const reasons = [];
452
+ let hint = false;
453
+ for (const scope of personal) {
454
+ const reason = await unavailableReason(scope);
455
+ if (reason !== undefined) {
456
+ reasons.push(`scope "${scope.name}" is unavailable (${reason})`);
457
+ continue;
458
+ }
459
+ const note = await dailyNoteFrom(scope);
460
+ if (note !== undefined) return note;
461
+ reasons.push(`no "${DAILY_MARKER}:" line in the strategy of "${scope.name}"`);
462
+ hint = true;
463
+ }
464
+
465
+ const advice = hint ? ' (add one: "- ' + DAILY_MARKER + ': <scope>, <scope> — <why>" under "## Current themes")' : '';
466
+ return emptyNote(`${reasons.join('; ')}${advice}`);
467
+ }
468
+
469
+ /** @param {string} reason @returns {DailyNote} */
470
+ export function emptyNote(reason) {
471
+ return {
472
+ scopeName: '',
473
+ scopes: [],
474
+ line: '',
475
+ section: undefined,
476
+ strategyVersion: undefined,
477
+ reason,
478
+ };
479
+ }
@@ -20,6 +20,8 @@ Covers everything needed to prepare work for autonomous execution: understand th
20
20
 
21
21
  ## When NOT to use
22
22
 
23
+ - Choosing which feature to plan next, or re-ranking the backlog — that is the **guppi** agent, which
24
+ decides what gets planned and hands one feature to this skill.
23
25
  - User already has a PRD and just needs it broken into tasks — use `prd-to-tasks` directly.
24
26
  - Single well-understood task that just needs a bead — use `create-task`.
25
27
  - Raw idea not ready for implementation planning — use `record-idea`.
@@ -28,6 +30,16 @@ Covers everything needed to prepare work for autonomous execution: understand th
28
30
 
29
31
  ## Workflow
30
32
 
33
+ ### Phase 0 — Report the HITL queue
34
+
35
+ Before asking anything, presenting a plan, or dispatching a task, load the `hitl-queue` skill and
36
+ report its queue exactly as the script prints it. Then name the top item in one sentence: its id,
37
+ its title, and what answering it unblocks.
38
+
39
+ The queue is the first thing the session says, because the answer decides what gets planned at all.
40
+ A queue with no scores says so: report the unscored list and ask which bead to score, rather than
41
+ choosing one by feel. Never summarise the queue away or reorder it.
42
+
31
43
  ### Phase 1 — Understand and specify
32
44
 
33
45
  Establish what needs to be built through investigation and collaborative design.
@@ -262,6 +274,7 @@ Deliver a handoff bead, message the running ralph session, and give the instruct
262
274
  - [spec-to-tickets](../../planning/spec-to-tickets/SKILL.md) — load instead when the PRD must become external tickets rather than beads.
263
275
  - [create-task](../../planning/create-task/SKILL.md) — Phase 3's per-task bead creation and pipeline expansion; it invokes `classify-bead` internally, so this skill never classifies directly.
264
276
  - [ralph](../ralph/SKILL.md) — the downstream consumer of the handoff bead — hand off by sending ralph a `handoff` message naming the bead, with "Use the ralph skill. Start from bead #<handoff-id>" as the manual fallback when no ralph session is online. Its [REFERENCE.md](../ralph/REFERENCE.md) owns the planner ↔ ralph message convention.
277
+ - **guppi** (the `agent-cortex:guppi` agent) — the upstream entry point: it decides what gets planned and hands this skill one feature. Load this skill for that feature. The strategy guppi recommends from is the committed file that `prioritise-work` owns; `score-bead` records the score and `hitl-queue` prints the order.
265
278
 
266
279
  ## Examples
267
280
 
@@ -0,0 +1,139 @@
1
+ # A challenged priority
2
+
3
+ Detail behind `SKILL.md`. When the owner says "this should be higher", "that is not the priority",
4
+ or "why is this above that?", the queue is being challenged. Read this before you touch a score.
5
+
6
+ ## A challenge is a strategy signal
7
+
8
+ A challenge does not mean one score is wrong. It means the rubric that produced the score is
9
+ wrong, stale, or has shifted: the owner is optimising something else this month, or the value
10
+ rule reads differently against this bead than the scorer read it.
11
+
12
+ So the reply is a **strategy review**, never an edit. The rule is one sentence, and every refusal
13
+ below enforces it:
14
+
15
+ > No score and no position moves without the strategy version moving first.
16
+
17
+ An ad-hoc override does not exist. The only way an item legitimately ranks higher is for a
18
+ sentence in the rubric to say why it should, so the next scorer reaches the same answer.
19
+
20
+ ## The sequence
21
+
22
+ 1. **Note the version you are challenging.** `node <skill-scripts>/strategy.mjs validate` prints
23
+ `version N`. Keep `N`: it becomes `--from-version`, and it is what proves the rubric moved.
24
+ 2. **Write down the order before.** The candidate bead ids, highest first, comma-separated. This
25
+ is the baseline the diff is taken against.
26
+ 3. **Read the challenge back as a claim about one section.** Which section disagrees — the theme,
27
+ the value rule, the confidence rule, the effort anchor? Ask; do not guess.
28
+ 4. **Offer the interview.** Say plainly that the order is produced by the rubric, so a reorder
29
+ means a rubric change, and ask the questions in `SKILL.md` § Workflow, "Later runs".
30
+ 5. **Decide.** The rubric changes, or it stands. Both are legitimate outcomes; see below.
31
+ 6. **Re-rank** against the rubric as it now reads.
32
+ 7. **Record** the challenge on the bead, and show the owner what moved.
33
+
34
+ ## Outcome: the rubric changed
35
+
36
+ Edit the sections the interview changed, then bump with the reason. A bump without a reason is
37
+ refused, because a version that moved with no recorded reason is a change nobody can review.
38
+
39
+ ```bash
40
+ node <skill-scripts>/strategy.mjs bump --reason "review turnaround is the theme now"
41
+ ```
42
+
43
+ That writes the version, the date, and one change-log line. Then re-rank and record:
44
+
45
+ ```bash
46
+ node <skill-scripts>/challenge.mjs record --bead agnt-ctx-abc.1 \
47
+ --challenge "the review queue should be above the installer fix" \
48
+ --resolution changed \
49
+ --reason "review turnaround is the theme now" \
50
+ --from-version 3 \
51
+ --before agnt-ctx-abc.1,agnt-ctx-abc.2,agnt-ctx-def.1 \
52
+ --after agnt-ctx-def.1,agnt-ctx-abc.1,agnt-ctx-abc.2 \
53
+ --out /tmp/challenge-entry.txt
54
+ bd note agnt-ctx-abc.1 --file /tmp/challenge-entry.txt
55
+ ```
56
+
57
+ `--out` writes the entry to a file, so the tracker appends it as data. Never paste the entry's own
58
+ words into a shell string: the entry repeats what the human said, so a `"` in those words would end
59
+ the quoting early and a `$(…)` would run a command. Pasting a **path** is safe instead —
60
+ `bd note <bead> "$(cat <path>)"` reads the file and never parses the entry — but every example
61
+ here uses `--file`, which needs no shell at all, so that is the one form to copy. `record` also
62
+ prints the movement, which is what you show the owner:
63
+
64
+ ```
65
+ [2026-12-01T09:30:00.000Z] [challenge] trail:2f9c4b1e7a3d5c60
66
+ Outcome: done
67
+ Evidence: challenged "the review queue should be above the installer fix"; strategy v3 → v4
68
+ (updated 2026-12-01); rubric reason: review turnaround is the theme now; order 3 items, 2 moved:
69
+ agnt-ctx-def.1 #3→#1, agnt-ctx-abc.1 #1→#2
70
+ What is left: nothing — scores are re-ranked against strategy v4
71
+ ```
72
+
73
+ ## Outcome: no change to the rubric
74
+
75
+ Valid, and common: the rubric already answers the challenge, or the owner decides the challenge
76
+ does not outweigh the theme. The order does not move either — nothing changed that could move it.
77
+
78
+ Record it anyway. An outcome that leaves no trace is a defect: the next session would rediscover
79
+ the same challenge, and the owner would see the same question asked again.
80
+
81
+ ```bash
82
+ node <skill-scripts>/challenge.mjs record --bead agnt-ctx-abc.1 \
83
+ --challenge "the review queue should be above the installer fix" \
84
+ --resolution no-change \
85
+ --reason "the value rule already requires a human gate, and the installer fix unblocks one" \
86
+ --from-version 3 \
87
+ --out /tmp/challenge-entry.txt
88
+ bd note agnt-ctx-abc.1 --file /tmp/challenge-entry.txt
89
+ ```
90
+
91
+ The entry carries the words that raised the challenge and the reasoning that answered it, under
92
+ `Evidence`, so `bd show <bead>` recovers both. The strategy file is not touched.
93
+
94
+ ## The refusals
95
+
96
+ `record` exits 1, writes nothing, and names the problem in four cases. Each is a shortcut worth
97
+ refusing:
98
+
99
+ | You tried | What it says |
100
+ |---|---|
101
+ | Reordered, rubric untouched | the order moved, but the strategy stayed at vN — a reorder without a rubric change |
102
+ | "changed" with no bump | resolution "changed", but the version did not rise (v3 → v3) |
103
+ | "no-change" after a bump | the version moved (v3 → v4), so the resolution is "changed" |
104
+ | Challenged against a broken rubric | the strategy is not valid, so no score can be judged against it |
105
+
106
+ `validate` adds a fifth, after the fact, and it needs one surviving entry: the newest change-log
107
+ line must name the version the file carries, so a hand-raised version, or a bump whose log line was
108
+ deleted, fails the next validation. The section is optional, so a log emptied of every entry passes
109
+ — and `record` is what catches a silent reorder then, because it refuses an order that moved while
110
+ the version stayed put.
111
+
112
+ ## How the order is computed
113
+
114
+ The order is a plain list of candidate bead ids, highest first — from a scorer when one produced
115
+ the queue, otherwise from re-reading the rubric yourself. `record` does not rank; it compares
116
+ positions, which is what makes the movement deterministic and repeatable:
117
+
118
+ - An id in both lists at a different position **moved**. Positions are 1-based, and the delta is
119
+ old minus new, so a positive delta is upward movement.
120
+ - An id only in the new list is **new at** its position.
121
+ - An id only in the old list **dropped from** its position.
122
+
123
+ Moved items are reported in new-position order, then the arrivals, then the departures in
124
+ old-position order. The same two lists therefore print the same clause every time.
125
+
126
+ `--before` and `--after` are required for `changed`, because a recording that does not name what
127
+ moved does not show the owner the consequence. They are optional for `no-change`, where they can
128
+ only agree with each other — and are refused when they do not.
129
+
130
+ ## Where each record goes
131
+
132
+ | Record | Lives in | Why |
133
+ |---|---|---|
134
+ | Why the rubric changed | the strategy file's change log, written by `bump` | a reviewer diffs it, and it is versioned next to the rubric |
135
+ | The challenge, its reasoning and the movement | the bead trail, written by `bd note` from `record` | it is the progress record for that one bead, and a cold session reads it from `bd show` |
136
+
137
+ Neither script writes to the other's store: `strategy.mjs` never calls the tracker, and
138
+ `challenge.mjs` never edits the strategy file. A challenge recorded on a bead is not a rubric
139
+ change, and a rubric change with no bead is not a challenge.