@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,87 @@
1
+ # Prioritisation strategy
2
+
3
+ <!--
4
+ This file is the rubric a scorer reads before it judges a bead's reach, impact,
5
+ confidence and effort. Keep it short enough that you actually maintain it.
6
+
7
+ It lives in the repository as a committed file, never in the tracker, so a reviewer can
8
+ diff how the judgement changed. Every `##` section below is required: leave one empty and
9
+ the validator names it. `## Deadline tiers` is the one exception — omit it and the queue
10
+ ranks on its documented default. Set the version and the date with
11
+ `node <skill-scripts>/strategy.mjs bump` after you edit the content.
12
+ -->
13
+
14
+ - **Version:** 1
15
+ - **Updated:** YYYY-MM-DD
16
+
17
+ ## Current themes
18
+
19
+ <!--
20
+ What you are optimising this month, as two to four themes. Name the outcome rather than
21
+ the feature: "shorter review turnaround", not "review tooling".
22
+ -->
23
+
24
+ ## What counts as value
25
+
26
+ <!--
27
+ What a bead must change to be worth doing now. One line per value, written so a scorer
28
+ can answer yes or no: "unblocks a human gate", "removes a manual step".
29
+ -->
30
+
31
+ ## How to judge confidence
32
+
33
+ <!--
34
+ When to score confidence low: no evidence in the repository, unclear scope, no known
35
+ user, an unproven dependency. Give the figure for each case, for example "0.3 with no
36
+ reproduction, 0.8 with a failing test".
37
+ -->
38
+
39
+ ## Explicitly out of scope
40
+
41
+ <!--
42
+ Work you are deliberately not doing this month, however good it looks. A scorer reads
43
+ this to stop a shiny bead from outranking the theme.
44
+ -->
45
+
46
+ ## How to calibrate effort
47
+
48
+ <!--
49
+ Anchor the effort scale to work you have recently completed here, so two scorers agree.
50
+ For example: 1 = half a day, one file; 3 = two to three days, one module with its tests;
51
+ 5 = a week or more, several modules or a migration.
52
+ -->
53
+
54
+ ## Deadline tiers
55
+
56
+ <!--
57
+ What a bd `--due` date does to a bead's rank in the HITL queue. The queue multiplies its
58
+ total by the factor of the narrowest row the remaining time fits inside, and a bead with
59
+ no deadline keeps the "more than" factor. Write each time as a number and one of `m`, `h`
60
+ or `d`, as in `within 1d`; read the rows in this order, narrowest first. A table needs
61
+ exactly one `overdue` row, exactly one `more than` row and at least one `within` row, and
62
+ `validate` names any row it cannot read.
63
+
64
+ A boundary belongs to the row that names it: exactly one day left earns 4, and a
65
+ millisecond more earns 2. A bead whose deadline is exactly now has not slipped yet, so it
66
+ is not overdue. A strategy with no table falls back to the queue's documented default, so
67
+ the rows below are the shipped values: edit a number, then `bump`, and the next queue run
68
+ uses it.
69
+ -->
70
+
71
+ | Remaining | Factor |
72
+ |---|---|
73
+ | within 1d | 4 |
74
+ | within 2d | 2 |
75
+ | within 7d | 1.5 |
76
+ | more than 7d | 1 |
77
+ | overdue | 8 |
78
+
79
+ ## Change log
80
+
81
+ <!--
82
+ One line per change to this rubric, oldest first, written by
83
+ `node <skill-scripts>/strategy.mjs bump --reason "<why>"`. The newest line must name the
84
+ version above: `validate` fails when the two disagree, because a version that moved with
85
+ no recorded reason is a rubric change nobody can review.
86
+ -->
87
+
@@ -231,11 +231,16 @@ A worktree outlives its bead: ralph leaves the directory behind when a feature c
231
231
  ```bash
232
232
  node skills/workflow/ralph/scripts/prune-worktrees.mjs # remove closed-bead worktrees, report the rest
233
233
  node skills/workflow/ralph/scripts/prune-worktrees.mjs --remove-orphans # also remove open-bead and missing-bead worktrees
234
+ node skills/workflow/ralph/scripts/prune-worktrees.mjs --base <ref> # prove content against <ref>, not origin/HEAD
234
235
  ```
235
236
 
236
237
  **Candidacy comes from bead state, never from git ancestry.** The repo squash-merges, so every landed branch still reports as unmerged. A directory named after a bead that `bd show` reports `closed` is the only proof that admits removal; an open bead, a missing bead, or a bead `bd` could not answer for is kept unless `--remove-orphans` asks otherwise. A bead `bd` could not read is never removed, whatever the flags say.
237
238
 
238
- **A candidate that fails a guard is refused, and the reason is printed.** The guards are: dirty working tree, `locked` worktree, detached HEAD, directory not registered with git, branch with no upstream, and branch with unpushed commits.
239
+ **A candidate that fails a guard is refused, and the reason is printed.** The guards are: dirty working tree, `locked` worktree, detached HEAD, directory not registered with git or unreadable, branch with unpushed commits, and branch whose content is not proven landed.
240
+
241
+ **No upstream is not a refusal by itself.** A merge that deletes the branch also deletes the local branch's upstream, so a closed bead's branch with no remote-tracking branch is the normal end of landed work. The guard is content: the base must hold the branch's own version of every path the branch changed since their merge base. That compares content, never history, so removal discards every commit the base does not contain, an empty commit or a change the branch added and reverted included, together with its message. The test is one-sided, so a base that moved on after the branch was cut still proves the branch landed. On a bead that is not closed, no upstream stays a refusal on its own.
242
+
243
+ **The base is the remote's default branch,** read from `origin/HEAD` so that no branch name is hardcoded. `--base <ref>` names another, such as a release branch. A base that cannot be resolved, or that resolves to the worktree's own commit, keeps the worktree and refuses.
239
244
 
240
245
  **Removal is three commands.** `git worktree remove <path>`, then `git branch -D <branch>`, then `git push origin --delete <branch>`. The remote branch name is the worktree's own checked-out branch, so ralph's `feature/<parent-id>` deletes `origin/feature/<parent-id>`. A remote ref that git already reports as gone is not a failure.
241
246
 
@@ -5,6 +5,20 @@
5
5
  // reads bead state, not git ancestry: this repo squash-merges, so every ancestry
6
6
  // test reports a landed branch as unmerged.
7
7
  //
8
+ // A merge that deletes its branch also deletes the local branch's upstream, so a
9
+ // closed bead with no remote-tracking branch is the normal end of a landed branch.
10
+ // Only the content can tell that apart from a branch that was never pushed: the
11
+ // branch goes when the base holds the branch's own version of every path the
12
+ // branch changed since their merge base, and stays otherwise. The test is
13
+ // one-sided on purpose: this repo squash-merges, so a base that moved on after the
14
+ // branch was cut holds paths the branch never had, and demanding tree equality
15
+ // would keep every branch but the last one landed.
16
+ //
17
+ // The test proves content, never history. A branch removed here is discarded
18
+ // together with every commit the base does not contain — a reverted change and a
19
+ // run of empty commits both go, and so do their messages. The base is the remote's
20
+ // default branch, never a hardcoded name.
21
+ //
8
22
  // Zero dependencies: run with `node prune-worktrees.mjs`.
9
23
 
10
24
  import { execFile } from 'node:child_process';
@@ -154,15 +168,134 @@ async function classifyBead(id, exec) {
154
168
  return { classification: status === 'closed' ? 'closed-bead' : 'open-bead', beadStatus: status };
155
169
  }
156
170
 
171
+ /**
172
+ * Resolve the branch a worktree's content must be contained in before its local
173
+ * branch may be dropped. The remote's default branch is the generic answer:
174
+ * `origin/HEAD` is a symbolic ref to whatever the remote calls its default
175
+ * branch, so no branch name is baked in here.
176
+ * @param {string} repoRoot
177
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
178
+ * @returns {Promise<string | null>} the base ref, or null when git cannot name one.
179
+ */
180
+ async function resolveBaseRef(repoRoot, git) {
181
+ const result = await git(repoRoot, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
182
+ if (!result.ok) return null;
183
+ const ref = result.stdout.trim();
184
+ return ref === '' ? null : ref;
185
+ }
186
+
187
+ /**
188
+ * Split the output of `git diff --name-only -z` into paths. `-z` is what makes the
189
+ * round trip lossless: without it git C-quotes a path holding a non-ASCII byte, a
190
+ * double quote, a backslash or a newline, and a quoted name fed back as a
191
+ * pathspec names no path. With it, every path arrives raw and NUL-terminated, so
192
+ * a path holding a newline stays one path.
193
+ * @param {string} stdout
194
+ * @returns {string[]}
195
+ */
196
+ function splitNulPaths(stdout) {
197
+ // The output ends with a NUL, so the split leaves one empty entry to drop. A
198
+ // path is never empty, so no real path is lost with it.
199
+ return stdout.split('\0').filter((entry) => entry !== '');
200
+ }
201
+
202
+ /**
203
+ * Decide whether a branch with no upstream branch still holds work. Removing such
204
+ * a branch is only safe when the base holds the branch's own version of every path
205
+ * the branch changed — the squash-merge case. Anything unprovable keeps the
206
+ * worktree.
207
+ *
208
+ * One-sided containment, in three steps: take the merge base of the base and the
209
+ * branch, list the paths the branch changed since that merge base, then require the
210
+ * base to agree with the branch on those paths alone. Paths only the base added or
211
+ * changed lie outside that set and are ignored, so a base that advanced after the
212
+ * branch was cut still proves the branch landed.
213
+ *
214
+ * A status filter on the two-sided diff would be simpler and is wrong: `D` is the
215
+ * same status whether the branch deleted a path or the base never had it, so a
216
+ * filter cannot tell a deletion the base already carries from one it does not.
217
+ *
218
+ * Restricting to the branch's own paths is what makes an overlap detectable: when
219
+ * the base later changed a path the branch also changed, the two versions differ
220
+ * and the branch is not proven landed. A deletion on both sides agrees, so it is
221
+ * proven; a deletion on one side only is not.
222
+ *
223
+ * The branch's changed paths are read from git and then handed straight back to a
224
+ * second git call as pathspecs, so that round trip has to be exact. Two escapes
225
+ * make it inexact and both end in a false "proven": a path git would C-quote
226
+ * prints as a quoted string that names no path, and a path starting with `:` is
227
+ * read as pathspec magic instead of a name. Neither alone is sufficient to fix,
228
+ * so the first diff reads with `-z` and the second is run with
229
+ * `--literal-pathspecs`.
230
+ * @param {{ path: string, branch: string, beadClosed: boolean }} target
231
+ * @param {string | null} baseRef
232
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
233
+ * @returns {Promise<string | null>} a refusal reason, or null when nothing is lost.
234
+ */
235
+ async function findLostWork({ path, branch, beadClosed }, baseRef, git) {
236
+ if (!beadClosed) {
237
+ return `branch ${branch} has no remote-tracking branch and its bead is not closed`;
238
+ }
239
+ if (baseRef === null) {
240
+ return `branch ${branch} has no remote-tracking branch and the base branch cannot be resolved, so its content cannot be proven landed`;
241
+ }
242
+ const tip = await git(path, ['rev-parse', '--verify', 'HEAD']);
243
+ if (!tip.ok) {
244
+ const detail = tip.stderr.trim();
245
+ const suffix = detail === '' ? '' : `: ${detail}`;
246
+ return `branch ${branch} has no remote-tracking branch and its tip cannot be read${suffix}`;
247
+ }
248
+ const baseTip = await git(path, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
249
+ if (!baseTip.ok) {
250
+ const detail = baseTip.stderr.trim();
251
+ const suffix = detail === '' ? '' : `: ${detail}`;
252
+ return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
253
+ }
254
+ // A base that resolves to the branch being deleted compares the branch with
255
+ // itself: the diff is empty whatever the branch holds, so the guard cannot fail.
256
+ if (tip.stdout.trim() === baseTip.stdout.trim()) {
257
+ return `branch ${branch} has no remote-tracking branch and ${baseRef} resolves to this branch's own commit, so the comparison would prove nothing`;
258
+ }
259
+ const mergeBase = await git(path, ['merge-base', baseRef, 'HEAD']);
260
+ if (!mergeBase.ok) {
261
+ const detail = mergeBase.stderr.trim();
262
+ const suffix = detail === '' ? '' : `: ${detail}`;
263
+ return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
264
+ }
265
+ const touched = await git(path, ['diff', '--name-only', '-z', '--no-renames', mergeBase.stdout.trim(), 'HEAD', '--']);
266
+ if (!touched.ok) {
267
+ const detail = touched.stderr.trim();
268
+ const suffix = detail === '' ? '' : `: ${detail}`;
269
+ return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
270
+ }
271
+ const changedPaths = splitNulPaths(touched.stdout);
272
+ // Nothing but empty commits: no content to prove, and no content to lose.
273
+ if (changedPaths.length === 0) return null;
274
+ // `--literal-pathspecs` is a git global option, so it precedes the subcommand.
275
+ // It keeps a leading `:` in a real filename from being read as pathspec magic.
276
+ const unproven = await git(path, ['--literal-pathspecs', 'diff', '--name-only', '-z', '--no-renames', 'HEAD', baseRef, '--', ...changedPaths]);
277
+ if (!unproven.ok) {
278
+ const detail = unproven.stderr.trim();
279
+ const suffix = detail === '' ? '' : `: ${detail}`;
280
+ return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
281
+ }
282
+ const unprovenPaths = splitNulPaths(unproven.stdout).length;
283
+ if (unprovenPaths > 0) {
284
+ return `branch ${branch} has no remote-tracking branch and ${baseRef} does not hold this branch's version of ${unprovenPaths} path(s), so its commits may never have been pushed`;
285
+ }
286
+ return null;
287
+ }
288
+
157
289
  /**
158
290
  * Reasons git refuses to tear a worktree down. A worktree removed anyway would
159
291
  * throw away uncommitted work or an unpushed branch, so every reason keeps the
160
292
  * worktree in place.
161
- * @param {{ path: string, registered: { branch: string | null, locked: string | null } | undefined }} target
293
+ * @param {{ path: string, registered: { branch: string | null, locked: string | null } | undefined, beadClosed: boolean }} target
162
294
  * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
295
+ * @param {string | null} baseRef
163
296
  * @returns {Promise<{ branch: string | null, reasons: string[] }>}
164
297
  */
165
- async function findRefusals({ path, registered }, git) {
298
+ async function findRefusals({ path, registered, beadClosed }, git, baseRef) {
166
299
  /** @type {string[]} */
167
300
  const reasons = [];
168
301
 
@@ -183,7 +316,8 @@ async function findRefusals({ path, registered }, git) {
183
316
 
184
317
  const upstream = await git(path, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
185
318
  if (!upstream.ok) {
186
- reasons.push(`branch ${registered.branch} has no remote-tracking branch`);
319
+ const lost = await findLostWork({ path, branch: registered.branch, beadClosed }, baseRef, git);
320
+ if (lost !== null) reasons.push(lost);
187
321
  return { branch: registered.branch, reasons };
188
322
  }
189
323
  const trackingRef = upstream.stdout.trim();
@@ -231,12 +365,14 @@ async function removeWorktree(repoRoot, { path, branch }, git) {
231
365
  * @param {object} options
232
366
  * @param {string} options.repoRoot Repository to prune.
233
367
  * @param {boolean} [options.removeOrphans] Also remove worktrees whose bead is open or missing.
368
+ * @param {string} [options.base] Ref the content check compares against; defaults to the remote's default branch.
234
369
  * @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
235
370
  * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} [options.git] git runner; injectable for tests.
236
371
  * @returns {Promise<PruneResult>}
237
372
  */
238
- export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = false, git = runGit }) {
373
+ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = false, git = runGit, base }) {
239
374
  const registered = await readRegisteredWorktrees(repoRoot);
375
+ const baseRef = base ?? (await resolveBaseRef(repoRoot, git));
240
376
  /** @type {WorktreeEntry[]} */
241
377
  const entries = [];
242
378
 
@@ -252,7 +388,11 @@ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = f
252
388
  // an unreadable bead is never removed at all.
253
389
  const wanted = classification === 'closed-bead' || (classification !== 'unclassified' && removeOrphans);
254
390
  if (wanted) {
255
- const { branch, reasons } = await findRefusals({ path, registered: registered.get(path) }, git);
391
+ const { branch, reasons } = await findRefusals(
392
+ { path, registered: registered.get(path), beadClosed: classification === 'closed-bead' },
393
+ git,
394
+ baseRef,
395
+ );
256
396
  entry.branch = branch;
257
397
  if (reasons.length > 0) {
258
398
  entry.outcome = 'refused';
@@ -275,19 +415,56 @@ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = f
275
415
 
276
416
  // ─── CLI ──────────────────────────────────────────────────────────────────────
277
417
 
278
- const USAGE = `Usage: node prune-worktrees.mjs [--remove-orphans]
418
+ const USAGE = `Usage: node prune-worktrees.mjs [--remove-orphans] [--base <ref>]
279
419
 
280
420
  Removes worktrees under .agent-cortex/worktrees whose bead is closed, then
281
421
  reports the rest. A worktree whose bead is open or missing is an orphan: it is
282
422
  only removed when --remove-orphans is passed. Run from anywhere inside the
283
423
  repository.
424
+
425
+ A closed bead's branch may have no remote-tracking branch, because many merge
426
+ methods delete the remote branch when the work lands. Such a branch is removed
427
+ only when the base holds the branch's own version of every path the branch changed
428
+ since their merge base. That proves content and never history: removal discards
429
+ every commit the base does not contain, such as a reverted change or an empty
430
+ commit, together with its message. The base defaults to the remote's default
431
+ branch (origin/HEAD); --base names another ref, such as a release branch. A base
432
+ that cannot be resolved, or that resolves to the worktree's own branch, keeps the
433
+ worktree and refuses.
284
434
  `;
285
435
 
286
436
  /** Every flag the CLI accepts. */
287
437
  const REMOVE_ORPHANS_FLAG = '--remove-orphans';
438
+ const BASE_FLAG = '--base';
288
439
 
289
440
  class UsageError extends Error {}
290
441
 
442
+ /**
443
+ * @param {string[]} argv
444
+ * @returns {{ removeOrphans: boolean, base: string | undefined }}
445
+ */
446
+ function parseArgs(argv) {
447
+ let removeOrphans = false;
448
+ /** @type {string | undefined} */
449
+ let base;
450
+ for (let index = 0; index < argv.length; index += 1) {
451
+ const arg = argv[index];
452
+ if (arg === REMOVE_ORPHANS_FLAG) {
453
+ removeOrphans = true;
454
+ continue;
455
+ }
456
+ if (arg === BASE_FLAG) {
457
+ const value = argv[index + 1];
458
+ if (value === undefined || value.startsWith('-')) throw new UsageError(`${BASE_FLAG} needs a git ref`);
459
+ base = value;
460
+ index += 1;
461
+ continue;
462
+ }
463
+ throw new UsageError(`unexpected argument: ${arg}`);
464
+ }
465
+ return { removeOrphans, base };
466
+ }
467
+
291
468
  /**
292
469
  * Resolve the repository root from any directory inside it — the main checkout
293
470
  * or a linked worktree. The shared `.git` directory is one level below the root
@@ -313,14 +490,12 @@ export async function runCli({ argv, cwd = process.cwd(), exec = runBd, git = ru
313
490
  write(USAGE);
314
491
  return 0;
315
492
  }
316
- const unknown = argv.filter((arg) => arg !== REMOVE_ORPHANS_FLAG);
317
- if (unknown.length > 0) throw new UsageError(`unexpected argument: ${unknown[0]}`);
318
- const removeOrphans = argv.includes(REMOVE_ORPHANS_FLAG);
493
+ const { removeOrphans, base } = parseArgs(argv);
319
494
 
320
495
  const repoRoot = await resolveRepoRoot(cwd);
321
496
  if (repoRoot === null) throw new Error('not inside a git repository');
322
497
 
323
- const { entries, exitCode } = await pruneWorktrees({ repoRoot, exec, removeOrphans, git });
498
+ const { entries, exitCode } = await pruneWorktrees({ repoRoot, exec, removeOrphans, git, base });
324
499
 
325
500
  write(`Scanning ${join(repoRoot, '.agent-cortex', 'worktrees')}\n`);
326
501
  for (const entry of entries) {
@@ -40,8 +40,11 @@ bd update <id> --add-label defect:test-unproven
40
40
  `review-defects` pass `--all` and a query without the flag finds nothing. Two findings of
41
41
  different kinds are two `BLOCKING_ISSUES` entries and one label: the class of the finding that
42
42
  must be fixed first.
43
- - A class that is not on the closed list is a review defect in itself. Say so under
44
- `BLOCKING_ISSUES` and use the nearest real class — never invent a `defect:` label.
43
+ - When no class fits, apply `defect:unclassified`, say so under `BLOCKING_ISSUES`, and file a bead
44
+ proposing the class with this finding as its case. The escape hatch is not a pass: without the
45
+ gap bead it silently degrades the aggregate the feedback loop reads.
46
+ - Never invent a `defect:` label, and never apply a class that does not fit — that is a review
47
+ defect in itself. `defect:unclassified` is the only honest way to say no class fits.
45
48
  - The review that confirms the fix removes the label
46
49
  (`bd update <id> --remove-label defect:test-unproven`) and records the removal in its trail
47
50
  entry. A closed defect is a trail fact, never a second class.
@@ -0,0 +1,73 @@
1
+ # Score Bead — Record, Bands and Trail
2
+
3
+ The exact shapes `rice.mjs` reads and writes. Read this when a record looks wrong, when you need the band thresholds, or when you are auditing a re-score.
4
+
5
+ ## The metadata record
6
+
7
+ One key, `rice`, written with `bd update <id> --metadata '{"rice":{…}}'`. `bd show` renders it under `METADATA`:
8
+
9
+ ```
10
+ METADATA
11
+ rice: {"assessedAt":"2026-11-14T09:00:00.000Z","confidence":0.7,"effort":2,
12
+ "impact":3,"rationale":"unblocks the messaging epic — Current themes",
13
+ "reach":4,"score":4.2,"strategyVersion":2}
14
+ ```
15
+
16
+ | Field | Type | Meaning |
17
+ |---|---|---|
18
+ | `reach` | number ≥ 0 | How much work the bead changes |
19
+ | `impact` | number ≥ 0 | How much each unit of reach gains, on the rubric's scale |
20
+ | `confidence` | number `0..1` | Probability that the reach, impact and effort estimates hold |
21
+ | `effort` | number > 0 | Cost, on the rubric's effort calibration |
22
+ | `score` | number, one decimal | `reach × impact × confidence ÷ effort` |
23
+ | `strategyVersion` | integer | The strategy file's own version, read after it validated |
24
+ | `assessedAt` | string | When the judgement was made — now in UTC, or `--at` copied verbatim |
25
+ | `rationale` | string | Why these numbers, then the `[prior]` trail |
26
+
27
+ The total is rounded once, at the end: `Math.round(value * 10) / 10`. Rounding a component instead would make the stored components disagree with the stored total.
28
+
29
+ `--at` is a convention, not a guarantee. The flag documents an RFC 3339 UTC timestamp, but `score` writes the value exactly as given and never validates it, so a mistyped timestamp reaches the record unchanged. Omit the flag and the record carries the real time.
30
+
31
+ `score` refuses, writing nothing, when:
32
+
33
+ - the strategy file is missing, or `validate` rejects it — exit 1, with the reason;
34
+ - a component is not a number, `confidence` falls outside `0..1`, or `effort` is zero or less — exit 2;
35
+ - the rationale is empty — exit 2.
36
+
37
+ Exit 1 means the run reached a judgement and refused it; exit 2 means the call was malformed and never got that far. Neither reaches the tracker: the components, the rationale and the strategy are all checked before the first `bd` call, so a refused score leaves no metadata, no note and no tag behind.
38
+
39
+ ## The band table
40
+
41
+ | Score at least | Proposed priority |
42
+ |---|---|
43
+ | 8 | P1 |
44
+ | 4 | P2 |
45
+ | 1 | P3 |
46
+ | 0 | P4 |
47
+
48
+ Bands are highest-first and evaluated in order. P0 is absent and `bandFor` throws if a table ever yields it, so a computed score can never file a bead as an incident. The table lives in `rice.mjs` as `DEFAULT_BANDS`; changing it is a code change, reviewable like any other.
49
+
50
+ `band <id>` prints the proposal and the command that would apply it:
51
+
52
+ ```
53
+ ✓ tst-6: score 4.2 → P2 (currently P3)
54
+ proposal only — review the components and the rationale, then apply: bd update tst-6 -p 2
55
+ ```
56
+
57
+ It reads the bead with `bd show` and calls nothing else. A bead already at P0 is refused before a proposal exists, because an incident is not re-prioritised by a rubric. A bead with no numeric `score` is refused too — score it first, then band it.
58
+
59
+ ## The rationale trail
60
+
61
+ The rationale holds the current argument, then one `[prior]` line per score it replaced, oldest first and ending with the score just replaced:
62
+
63
+ ```
64
+ re-scored against the December rubric
65
+ [prior] score 1 @ strategy v1, assessed 2026-10-01T00:00:00.000Z (reach 1, impact 1, confidence 0.5, effort 2)
66
+ [prior] score 4.2 @ strategy v2, assessed 2026-11-14T09:00:00.000Z (reach 4, impact 3, confidence 0.7, effort 2)
67
+ ```
68
+
69
+ Each line carries the superseded total, its components, the strategy version it was judged against and its date, so the old arithmetic stays checkable after the components are overwritten.
70
+
71
+ Why the rationale and not a separate `history` array: the bead carries one record, `bd show` renders it in one place, and a re-score cannot drop the history by forgetting a second store. A reader greps `[prior]` to separate the trail from the prose. A record with no numeric `score` contributes no new line, but its existing trail is carried forward unchanged.
72
+
73
+ Nothing caps the trail. Every re-score appends one line and drops none, so the field grows by a line per re-score for the whole life of the bead. In practice a bead is re-scored a handful of times, once per strategy version; treat a long trail as history, not as a defect.
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: score-bead
3
+ description: Scores one bead RICE-style against the committed strategy and stores the components, total, confidence, rationale and strategy version in its metadata, then proposes a priority band for review. Use when a bead is unscored, when asked to "score this bead", "rank this work", or "what priority should this be".
4
+ ---
5
+
6
+ # Score Bead
7
+
8
+ Owns two things about one bead: the **RICE record** in its metadata, and a **band proposal** for its priority. The rubric the numbers are judged against is not this skill's — `prioritise-work` owns the strategy file, validates it and versions it.
9
+
10
+ Score and band are two steps on purpose. `score` writes an auditable record; `band` only reads it and prints a proposal. A score is a judgement, so the priority it implies is reviewed before it lands. A script that re-prioritised the backlog silently would remove the review, not save it.
11
+
12
+ ## When to use
13
+
14
+ - A bead appears in a queue with no `rice` metadata, or with a `strategyVersion` older than the strategy file.
15
+ - "Score this bead", "rank this work", "is this a P1?", "what priority should this be".
16
+ - Before dispatching work: an unscored bead cannot be compared with a scored one.
17
+
18
+ ## When NOT to use
19
+
20
+ - Setting or updating the strategy — use `prioritise-work`. This skill reads the rubric and never writes it.
21
+ - Recording an idea for later — use `record-idea`.
22
+ - Changing where a bead's state lives — use `bd-tool`.
23
+ - Ordering the whole backlog into one list — that is the [hitl-queue](../hitl-queue/SKILL.md) skill's job; this skill scores one bead.
24
+
25
+ ## The record
26
+
27
+ `score` writes one metadata key, so a reader greps one key instead of parsing the bead:
28
+
29
+ ```json
30
+ {"rice":{"reach":4,"impact":3,"confidence":0.7,"effort":2,"score":4.2,
31
+ "strategyVersion":2,"assessedAt":"2026-11-14T09:00:00.000Z",
32
+ "rationale":"unblocks the messaging epic — Current themes"}}
33
+ ```
34
+
35
+ Components and total are both stored, so a reader can redo the arithmetic. The full shape, the band table and the rationale trail are in [FORMAT.md](FORMAT.md).
36
+
37
+ ## Workflow
38
+
39
+ 1. **Check the strategy is valid.** Load `prioritise-work` and run its validator:
40
+
41
+ ```bash
42
+ node <skill-scripts>/../../prioritise-work/scripts/strategy.mjs validate
43
+ ```
44
+
45
+ A missing or malformed strategy stops here. `score` refuses in that state and writes nothing, so fixing the rubric is the only useful next action. The path above is the default (`.agent-cortex/working-docs/strategy.md` relative to the repository root); pass `--file <path>` to `score` when the repository keeps it elsewhere, and the same `--file` to the validator.
46
+
47
+ 2. **Judge the four components against the strategy.** Each number must trace to a section of the rubric, and the rationale names that section.
48
+
49
+ | Component | What it measures | Watch for |
50
+ |---|---|---|
51
+ | `reach` | How much work this changes — how many workflows, beads or users | Counting the author once, not the repeated cost |
52
+ | `impact` | How much each unit of reach gains, on the rubric's own scale | A number the rubric does not define |
53
+ | `confidence` | How sure the estimate is, as a probability `0..1` | A guess presented as a figure |
54
+ | `effort` | Cost, on the effort calibration the rubric anchors | An effort that is a wish |
55
+
56
+ 3. **Record a confidence figure you can defend.** Use `0..1`, and let the rationale say what the evidence is and what is missing. `0.5` with "no comparable work yet" is honest; `0.9` with no evidence is a guess wearing a number. When you cannot estimate at all, lower the figure and name the gap rather than inflating it.
57
+
58
+ 4. **Score the bead.** One command, one record:
59
+
60
+ ```bash
61
+ node <skill-scripts>/rice.mjs score <bead-id> --reach 4 --impact 3 --confidence 0.7 \
62
+ --effort 2 --rationale "unblocks the messaging epic — Current themes"
63
+ ```
64
+
65
+ It computes `reach × impact × confidence ÷ effort` to one decimal, records the strategy version it validated, and writes the record with `bd update <id> --metadata`. It writes nothing when the strategy is missing or invalid, and nothing when a component falls outside its range.
66
+
67
+ 5. **Read back what landed.** `bd show <bead-id>` renders the `METADATA` block: the components, the total, the version and the rationale. Confirm the arithmetic before you trust the score.
68
+
69
+ 6. **Propose a band.** Separate command, read-only:
70
+
71
+ ```bash
72
+ node <skill-scripts>/rice.mjs band <bead-id>
73
+ ```
74
+
75
+ It prints `score 4.2 → P2 (currently P3)` and the exact command that would apply it.
76
+
77
+ 7. **Apply the band only after review.** Show the proposal — score, current priority, proposed priority, and the rationale — and run the printed `bd update <bead-id> -p <n>` only when the owner accepts it. Skip the step when the proposal equals the current priority.
78
+
79
+ ## Re-scoring keeps the history
80
+
81
+ When the strategy version moves, re-score the bead. The new record carries the new `strategyVersion`, and the rationale keeps a `[prior]` line for every score it replaced — old score, components, version and date. The mechanism is the rationale text itself, not a second field: one record then holds both the current argument and its trail, and `bd show` renders all of it.
82
+
83
+ So a re-score never needs a note to explain the change. Write the new rationale, score, and read the trail back to confirm the old total survived (see [FORMAT.md](FORMAT.md) for the rendered shape).
84
+
85
+ ## Priority is an output of review, not of arithmetic
86
+
87
+ - **A band is a proposal.** The script never runs the update it prints; step 7 does, and only on acceptance.
88
+ - **P0 is an incident.** A score never produces P0, and `band` refuses a bead that is already at P0 without proposing anything. An incident is declared by a person and cleared by a person; a rubric that could compute one would let a high-impact feature hide a production failure.
89
+ - **A previous priority stays readable.** The rationale carries the scores a band decision was made from, so a reader sees why the priority moved.
90
+
91
+ ## Red Flags
92
+
93
+ - Red flag: writing a confidence number with no evidence behind it, because the field needs a value.
94
+ - Red flag: applying a band yourself, in the same breath as proposing it, so no review happens.
95
+ - Red flag: scoring against a strategy whose `validate` was never run — the record then cites a version nobody checked.
96
+ - Red flag: re-scoring and dropping the `[prior]` trail, so the priority change has no recorded reason.
97
+ - Red flag: raising a P0 bead to make a queue look tidy.
98
+
99
+ ## Verification checklist
100
+
101
+ - [ ] `strategy.mjs validate` exited 0 before the score ran.
102
+ - [ ] `bd show <id>` shows a `rice` key whose components reproduce the total: `reach × impact × confidence ÷ effort`, one decimal.
103
+ - [ ] The recorded `strategyVersion` equals the file's version after validation.
104
+ - [ ] The rationale names the strategy section each component rests on, and the confidence is a figure the evidence supports.
105
+ - [ ] A re-score carries a `[prior]` line for the score it replaced.
106
+ - [ ] The band proposal was accepted by a reviewer before the priority changed, and no P0 bead was touched.
107
+
108
+ ## See Also
109
+
110
+ - [prioritise-work](../../workflow/prioritise-work/SKILL.md) — owns the strategy file; run its `validate` before any score.
111
+ - [hitl-queue](../hitl-queue/SKILL.md) — ranks the beads these scores belong to, and reads them back.
112
+ - [bd-tool](../../productivity/bd-tool/SKILL.md) — where the record lives, and why a bead holds operational state rather than the rubric.