@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.
- package/CHANGELOG.md +304 -0
- package/README.md +30 -3
- package/agents/README.md +7 -2
- package/agents/guppi/agent.md +231 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/installers/claude.mjs +8 -23
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- package/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/package.json +1 -1
- package/scripts/lib/compose-agent.mjs +45 -7
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/review/review-defects/EXAMPLES.md +80 -4
- package/skills/review/review-defects/SKILL.md +47 -16
- package/skills/review/review-report/EXAMPLES.md +118 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
- package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
- package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
- package/skills/workflow/hitl-queue/SCOPES.md +118 -0
- package/skills/workflow/hitl-queue/SKILL.md +150 -0
- package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +1012 -0
- package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
- package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
- package/skills/workflow/plan/SKILL.md +13 -0
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +756 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +87 -0
- package/skills/workflow/ralph/REFERENCE.md +6 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
- package/skills/workflow/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +6 -1
- package/token-map.README.md +14 -2
- 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.
|