@jaybeeuu/agent-cortex 2.9.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 +131 -0
- package/README.md +8 -4
- package/agents/README.md +5 -0
- package/agents/guppi/agent.md +70 -1
- package/bin/installers/claude.mjs +7 -3
- package/package.json +1 -1
- package/scripts/lib/compose-agent.mjs +45 -7
- package/skills/review/review-defects/SKILL.md +2 -1
- package/skills/review/review-report/EXAMPLES.md +7 -3
- 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 +48 -32
- package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +464 -78
- package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
- package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
- package/skills/workflow/prioritise-work/SKILL.md +9 -9
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +191 -1
- package/skills/workflow/prioritise-work/templates/strategy.md +27 -1
- package/skills/workflow/ralph/REFERENCE.md +6 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
- package/token-map.README.md +14 -2
- package/token-map.json +9 -1
|
@@ -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,8 +20,7 @@ The strategy is a **committed file**, never a bead and never a scratch note. A r
|
|
|
20
20
|
## When NOT to use
|
|
21
21
|
|
|
22
22
|
- Shaping one feature — use `plan`; that skill scopes a feature, this one orders the work.
|
|
23
|
-
- Recording an idea
|
|
24
|
-
- Tracker questions, or where a bead's state lives — use `bd-tool`.
|
|
23
|
+
- Recording an idea — use `record-idea`; tracker questions, or where a bead's state lives — use `bd-tool`.
|
|
25
24
|
- Writing a PRD or a design document — use `write-a-prd`.
|
|
26
25
|
|
|
27
26
|
## The strategy file
|
|
@@ -30,7 +29,8 @@ The strategy is a **committed file**, never a bead and never a scratch note. A r
|
|
|
30
29
|
|---|---|
|
|
31
30
|
| Default path | `.agent-cortex/working-docs/strategy.md`, relative to the repository root |
|
|
32
31
|
| Override | `--file <path>` on every subcommand |
|
|
33
|
-
| Contents | a version integer, a date,
|
|
32
|
+
| Contents | a version integer, a date, five required sections, and an optional `## Deadline tiers` table |
|
|
33
|
+
| Daily line | one `- daily: <scope>, <scope> — <why>` bullet under `## Current themes` |
|
|
34
34
|
| Lifetime | committed; edited by interview, then versioned with `bump` |
|
|
35
35
|
|
|
36
36
|
The working-docs root is the scratch directory the HITL handoff also uses: `.agent-cortex/working-docs/hitl/<bead-id>/handoff.md`. The strategy sits beside it and is the one file in that directory the repository commits.
|
|
@@ -45,6 +45,8 @@ That directory's contents are usually ignored (`working-docs/*`), so track the s
|
|
|
45
45
|
|
|
46
46
|
Use that rather than `git add -f`: an ignored file disappears from `git status`, so the next update is committed by nobody.
|
|
47
47
|
|
|
48
|
+
**One strategy, one workspace.** A rubric answers the question "what is worth doing here", so each bd workspace carries its own. The HITL queue reads several scopes at once; the **personal** scope's strategy is therefore also where the daily line lives, and the queue quotes it above the ranked list to say which scope the day belongs to. Write it under `## Current themes`, name the scopes first, then an em dash and the reason. `validate` does not require it: a strategy with no daily line is valid, and the queue says the note is missing rather than inventing one.
|
|
49
|
+
|
|
48
50
|
## Workflow
|
|
49
51
|
|
|
50
52
|
### First run — no strategy file
|
|
@@ -79,7 +81,7 @@ node <skill-scripts>/strategy.mjs validate
|
|
|
79
81
|
|
|
80
82
|
6. Read the current strategy and check its `**Updated:**` date. A date from an earlier month is stale: re-run the interview before scoring anything against it.
|
|
81
83
|
|
|
82
|
-
7. Ask the four questions again, plus the confidence question, and edit the sections that changed.
|
|
84
|
+
7. Ask the four questions again, plus the confidence question, and edit the sections that changed. Edit the deadline-tier table too when a due date should move a rank further or sooner.
|
|
83
85
|
|
|
84
86
|
8. Bump the version and record the date and the reason, then validate:
|
|
85
87
|
|
|
@@ -112,7 +114,7 @@ Two scripts, four subcommands, zero dependencies, no tracker access. `strategy.m
|
|
|
112
114
|
| Command | Behaviour | Exit |
|
|
113
115
|
|---|---|---|
|
|
114
116
|
| `init` | Writes the template, stamped with today's date; refuses an existing file | 0 written, 1 refused |
|
|
115
|
-
| `validate` | Checks the version integer, the date, the change log and every required section; prints the file to stdout when valid | 0 valid, 1 invalid |
|
|
117
|
+
| `validate` | Checks the version integer, the date, the change log, the deadline tiers and every required section; prints the file to stdout when valid | 0 valid, 1 invalid |
|
|
116
118
|
| `bump` | Refuses an invalid file or an empty `--reason`, raises the version by one, records today, appends the change-log line | 0 bumped, 1 refused |
|
|
117
119
|
| `record` (`challenge.mjs`) | Refuses an order or a resolution the strategy does not support; prints one bead-trail entry | 0 recorded, 1 refused |
|
|
118
120
|
|
|
@@ -138,13 +140,11 @@ A hand edit that changes a section but forgets `bump` still passes `validate`: t
|
|
|
138
140
|
- [ ] `node <skill-scripts>/strategy.mjs validate` exits 0 and printed the rubric, and every section carries the owner's words.
|
|
139
141
|
- [ ] The version is one higher than the previous file for an update, or `1` for a new one, and the change log names it.
|
|
140
142
|
- [ ] A challenged order moved only after a bump, and the record names what moved.
|
|
141
|
-
- [ ] `git ls-files --error-unmatch .agent-cortex/working-docs/strategy.md` succeeds.
|
|
142
|
-
- [ ] No bead was created, tagged or closed by this skill.
|
|
143
|
+
- [ ] No bead was created, tagged or closed by this skill; `git ls-files --error-unmatch .agent-cortex/working-docs/strategy.md` succeeds.
|
|
143
144
|
|
|
144
145
|
## See Also
|
|
145
146
|
|
|
146
147
|
- [CHALLENGE.md](CHALLENGE.md) — the sequence, the refusals and the no-change path when the order is challenged.
|
|
147
148
|
- [plan](../../workflow/plan/SKILL.md) — shapes one feature into beads; this skill orders the beads that exist.
|
|
148
149
|
- [bd-tool](../../productivity/bd-tool/SKILL.md) — where operational state lives, and why a durable document does not belong there.
|
|
149
|
-
- [score-bead](../../workflow/score-bead/SKILL.md) — turns this rubric into a RICE record on a bead.
|
|
150
|
-
- [hitl-collab](../../workflow/hitl-collab/SKILL.md) — the handoff document that shares the working-docs root with the strategy.
|
|
150
|
+
- [score-bead](../../workflow/score-bead/SKILL.md) — turns this rubric into a RICE record on a bead; the queue's daily line is read by [hitl-queue](../hitl-queue/SCOPES.md).
|