@jaybeeuu/agent-cortex 2.10.0 → 2.11.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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0a98d7b: Open a `prioritise-work` run with a summary of the committed strategy instead of a question. A new
8
+ `strategy.mjs summary` subcommand validates the file first, then prints a handful of lines — the
9
+ path it read, the version, the newest change-log entry, the themes, the two admissibility lists as
10
+ up to two bullets each, and the deadline factors when the file states them — so the digest cannot
11
+ describe a strategy that is not on disk. The skill shows it and asks "what should change?" before
12
+ the interview; with no strategy file it exits `3` and the skill goes straight to the first-run
13
+ interview with no digest; with an invalid or partly missing one it exits `1` and names what is wrong
14
+ rather than presenting it as current. The change requires no new section of the strategy file, so
15
+ every strategy already committed still validates: `## What counts as value` and `## Explicitly out
16
+ of scope` are summarised as the admissibility lists they are, never as a ranking order, and
17
+ `## Deadline tiers` is summarised narrowest first, the order the queue applies the factors, whatever
18
+ order the file holds the rows in.
19
+
20
+ The script contract moved to the skill's `REFERENCE.md` so `SKILL.md` stays within its length target.
21
+
22
+ ### Patch Changes
23
+
24
+ - 5a6a14a: Let the content proof decide a closed bead's worktree, whatever its upstream is.
25
+ `prune-worktrees.mjs` still refused every worktree ralph created, because ralph
26
+ cuts its branch from the remote base (`git worktree add -b feature/<id> …
27
+ origin/main`), `branch.autoSetupMerge` makes that base the branch's upstream, and
28
+ the squash merge that lands the work rewrites its commits — so the branch stays
29
+ ahead of its own base for ever and the unpushed-commit check, which ran first,
30
+ always fired. A closed bead is now decided by content alone: the base must hold
31
+ the branch's own version of every path the branch changed since their merge base,
32
+ and the refusal names that content when it does not. An open bead keeps the
33
+ upstream refusal, and with no upstream it is refused whatever the content says.
34
+ The base guard now tests the ref instead of the tip: a base that names the
35
+ worktree's own branch (local or remote-tracking) is a self-comparison and
36
+ refuses, while a branch cut from the base and never committed to holds the base's
37
+ own commit and is removed — the case every fresh ralph worktree used to reach.
38
+
3
39
  ## 2.10.0
4
40
 
5
41
  ### Minor Changes
@@ -92,7 +92,8 @@ A wish for a different order is a `prioritise-work` change, never a boundary one
92
92
  - **Never merges.** A pull request is a human gate. guppi may read one and hand it to a reviewer.
93
93
  - **Never pushes.** No `git push`, no release, no version bump of this package.
94
94
  - **Never overrides a priority silently.** "This should be higher" means the strategy is wrong,
95
- stale, or has shifted. Open the `prioritise-work` interview, record the change, then re-rank.
95
+ stale, or has shifted. Run `prioritise-work`, which opens with a digest of the current strategy,
96
+ record the change, then re-rank.
96
97
  - **Never invents a strategy.** If the strategy file is missing or invalid, say so plainly and offer
97
98
  the interview. Do not guess priorities.
98
99
  - **Propose-only and resident.** guppi is a standing collaborator in one session. It is not a task
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,34 @@
1
+ # The strategy scripts
2
+
3
+ The contract behind [SKILL.md](SKILL.md): what each subcommand prints, and the exit codes the
4
+ skill routes on. The workflow lives in `SKILL.md`; this file is the reference for one run.
5
+
6
+ Two scripts, five subcommands, zero dependencies, no tracker access. `strategy.mjs` reads and
7
+ writes the strategy file; `challenge.mjs` reads it and prints a trail entry. No run creates, tags
8
+ or closes a bead.
9
+
10
+ | Command | Behaviour | Exit |
11
+ |---|---|---|
12
+ | `init` | Writes the template, stamped with today's date; refuses an existing file | 0 written, 1 refused |
13
+ | `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 |
14
+ | `bump` | Refuses an invalid file or an empty `--reason`, raises the version by one, records today, appends the change-log line | 0 bumped, 1 invalid file, 2 no or empty `--reason` |
15
+ | `summary` | Validates first, then prints the digest a run opens with: the path it read, the version, the newest change-log entry, the themes and the two admissibility lists as up to two bullets each, and the deadline factors when the file states a table | 0 summarised, 1 invalid, 3 no file |
16
+ | `record` (`challenge.mjs`) | Refuses an order or a resolution the strategy does not support; prints one bead-trail entry | 0 recorded, 1 refused |
17
+
18
+ `summary` is the one command that separates "absent" from "wrong". It exits `3` only when the file
19
+ is not there, because the skill's next move is the first-run interview and no digest; a file that is
20
+ present but fails the shape check exits `1` with the validator's own lines, never a digest. Both
21
+ paths print nothing to stdout, so a caller can show whatever arrives there without deciding what to
22
+ hide.
23
+
24
+ `validate` splits its output: status and errors go to stderr, the rubric goes to stdout.
25
+ `validate > rubric.md` captures the rubric alone, which is how a scorer loads it. A failing run names
26
+ the file and lists one line per defect, so one fix round clears the whole file.
27
+
28
+ The date is checked as a **calendar** date, not as a shape. `2026-02-31` and `2026-04-31` fail in
29
+ both `validate` and `--today`, because V8's ISO parser rolls them into the next month and accepts
30
+ them; `2024-02-29` passes and `2026-02-29` does not. A rolled date records a change on a day it did
31
+ not happen.
32
+
33
+ Exit code 2 means a malformed call — an unknown subcommand, a bad `--today`, or a missing or empty
34
+ `--reason`. `--today YYYY-MM-DD` exists so a test pins the recorded date; a person accepts the default.
@@ -47,17 +47,29 @@ Use that rather than `git add -f`: an ignored file disappears from `git status`,
47
47
 
48
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
49
 
50
- ## Workflow
51
-
52
- ### First run — no strategy file
50
+ ## Step one — summarise what is already there
53
51
 
54
- 1. Confirm the file is absent:
52
+ A run never opens with a question. Run the summary first: it reads the committed file, so it cannot describe a strategy that is not on disk, and it shows the owner what they are about to edit.
55
53
 
56
54
  ```bash
57
- node <skill-scripts>/strategy.mjs validate
55
+ node <skill-scripts>/strategy.mjs summary
58
56
  ```
59
57
 
60
- It exits 1 and names the path it looked for. Do not search the repository for an alternative: one strategy, one location, or two scorers rank against two rubrics.
58
+ The digest is all it prints: the path it read, the version, the newest change-log entry, the themes, the two admissibility lists as up to two bullets each, and — when the file states a table — the deadline factors in the order the queue applies them. It is not a re-print — open the file for what a cut line left out.
59
+
60
+ | Exit | The file | What to do |
61
+ |---|---|---|
62
+ | 0 | valid | Show the digest, then ask "what should change?" |
63
+ | 3 | absent | Say there is no strategy yet and take the first-run interview below. Print no digest. |
64
+ | 1 | invalid or partly missing | Never present it as current. Say what it named — the empty or missing section, the field it could not read — and fix the file or re-interview before anything is scored against it. |
65
+
66
+ **The file gains no new section.** An admissibility heading would fail every strategy already committed, because `validate` gates them all. The file says what is worth doing and what is not: `## Current themes` is what the month is for, `## What counts as value` is the test a bead must pass, and `## Explicitly out of scope` is the work the month rules out — the last two are the digest's **admissibility** lines. What orders the beads that pass them is the **scoring rubric**, RICE with its multipliers, never this file. The one ordered rule set the file states is `## Deadline tiers`, and the digest prints its rows narrowest first whatever order the file holds them in; with no table the documented default applies and the digest says nothing about deadlines.
67
+
68
+ ## Workflow
69
+
70
+ ### First run — no strategy file
71
+
72
+ 1. The summary exited `3`: the file is absent. Do not search the repository for an alternative — one strategy, one location, or two scorers rank against two rubrics.
61
73
 
62
74
  2. Write the shipped template to that path. This is the copy step — the script resolves its own template, so it works from either install layout:
63
75
 
@@ -79,7 +91,7 @@ node <skill-scripts>/strategy.mjs validate
79
91
 
80
92
  ### Later runs — the file exists
81
93
 
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.
94
+ 6. The summary is step one above, so the version and the last change are already in front of the owner. A last change from an earlier month is stale: re-run the interview before scoring anything against it.
83
95
 
84
96
  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.
85
97
 
@@ -109,20 +121,7 @@ The confidence question matters as much as the other four: effort and impact est
109
121
 
110
122
  ## The scripts
111
123
 
112
- Two scripts, four subcommands, zero dependencies, no tracker access. `strategy.mjs` reads and writes the strategy file; `challenge.mjs` reads it and prints a trail entry. No run creates, tags or closes a bead.
113
-
114
- | Command | Behaviour | Exit |
115
- |---|---|---|
116
- | `init` | Writes the template, stamped with today's date; refuses an existing file | 0 written, 1 refused |
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 |
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 |
119
- | `record` (`challenge.mjs`) | Refuses an order or a resolution the strategy does not support; prints one bead-trail entry | 0 recorded, 1 refused |
120
-
121
- `validate` splits its output: status and errors go to stderr, the rubric goes to stdout. `validate > rubric.md` captures the rubric alone, which is how a scorer loads it. A failing run names the file and lists one line per defect, so one fix round clears the whole file.
122
-
123
- The date is checked as a **calendar** date, not as a shape. `2026-02-31` and `2026-04-31` fail in both `validate` and `--today`, because V8's ISO parser rolls them into the next month and accepts them; `2024-02-29` passes and `2026-02-29` does not. A rolled date records a change on a day it did not happen.
124
-
125
- Exit code 2 means a malformed call — an unknown subcommand or a bad `--today`. `--today YYYY-MM-DD` exists so a test pins the recorded date; a person accepts the default.
124
+ Four subcommands of `strategy.mjs` and one of `challenge.mjs`, all zero dependencies with no tracker access: [REFERENCE.md](REFERENCE.md) lists what each one prints and the exit codes the steps above route on.
126
125
 
127
126
  ## Hand edits and forgotten bumps
128
127
 
@@ -137,7 +136,7 @@ A hand edit that changes a section but forgets `bump` still passes `validate`: t
137
136
 
138
137
  ## Verification checklist
139
138
 
140
- - [ ] `node <skill-scripts>/strategy.mjs validate` exits 0 and printed the rubric, and every section carries the owner's words.
139
+ - [ ] The run opened with the digest — file, version, last change, themes, admissibility, deadline factors — before it asked anything, and `node <skill-scripts>/strategy.mjs validate` exits 0 with the rubric printed.
141
140
  - [ ] The version is one higher than the previous file for an update, or `1` for a new one, and the change log names it.
142
141
  - [ ] A challenged order moved only after a bump, and the record names what moved.
143
142
  - [ ] No bead was created, tagged or closed by this skill; `git ls-files --error-unmatch .agent-cortex/working-docs/strategy.md` succeeds.
@@ -145,6 +144,7 @@ A hand edit that changes a section but forgets `bump` still passes `validate`: t
145
144
  ## See Also
146
145
 
147
146
  - [CHALLENGE.md](CHALLENGE.md) — the sequence, the refusals and the no-change path when the order is challenged.
147
+ - [REFERENCE.md](REFERENCE.md) — every subcommand, what it prints, and the exit codes that tell a first run from an invalid strategy.
148
148
  - [plan](../../workflow/plan/SKILL.md) — shapes one feature into beads; this skill orders the beads that exist.
149
149
  - [bd-tool](../../productivity/bd-tool/SKILL.md) — where operational state lives, and why a durable document does not belong there.
150
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).
@@ -6,11 +6,12 @@
6
6
  // and never a scratch file: operational state belongs in the bead trail, and durable
7
7
  // documents that are reviewed, diffed and versioned belong in git.
8
8
  //
9
- // Three subcommands, and nothing else:
9
+ // Four subcommands, and nothing else:
10
10
  //
11
11
  // node strategy.mjs init [--file <path>] write the template
12
12
  // node strategy.mjs validate [--file <path>] check the shape, then print it
13
13
  // node strategy.mjs bump [--file <path>] [--today d] raise the version, set the date
14
+ // node strategy.mjs summary [--file <path>] the digest a run opens with
14
15
  //
15
16
  // `validate` prints the rubric to stdout only after it passes, so a scorer can never
16
17
  // read a stale or malformed strategy: the file and the proof of its shape arrive
@@ -22,8 +23,14 @@
22
23
  // Zero runtime dependencies, async fs only. `skills/*/scripts` sits outside the
23
24
  // check-no-sync scan, and this script stays async anyway so the rule holds everywhere.
24
25
  //
25
- // Exit codes: 0 the file is valid, 1 the file is invalid or the operation refused,
26
- // 2 the caller asked for something malformed.
26
+ // `summary` is what the skill prints before it asks anything, and it exists so the digest
27
+ // is read off the file rather than re-derived by an agent: it validates first, then reports
28
+ // the version, the newest change-log entry, the themes, the two admissibility lists and the
29
+ // deadline factors — each one read from the file, none restated from elsewhere.
30
+ //
31
+ // Exit codes: 0 the file is valid or summarised, 1 the file is invalid or the operation
32
+ // refused, 2 the caller asked for something malformed, 3 `summary` found no strategy file —
33
+ // a first run, which the skill answers with the interview and no digest.
27
34
 
28
35
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
29
36
  import { dirname, join, resolve } from 'node:path';
@@ -143,6 +150,7 @@ const MS_PER_MINUTE = 60_000;
143
150
  * @property {boolean} ok
144
151
  * @property {DeadlineTier[]} tiers The parsed tiers, or the documented default when absent.
145
152
  * @property {string[]} errors One line per defect in the table.
153
+ * @property {'file' | 'default'} source Whether the tiers were read from the file.
146
154
  */
147
155
 
148
156
  /** A usage mistake: the caller asked for something malformed. Exit code 2. */
@@ -268,7 +276,7 @@ function parseTierRow(when, factorText) {
268
276
  */
269
277
  export function parseDeadlineTiers(text) {
270
278
  const body = sectionBody(text.split('\n'), DEADLINE_TIERS_SECTION);
271
- if (body === null) return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [] };
279
+ if (body === null) return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [], source: 'default' };
272
280
 
273
281
  /** @type {DeadlineTier[]} */
274
282
  const rows = [];
@@ -294,8 +302,10 @@ export function parseDeadlineTiers(text) {
294
302
  rows.push(parsed.tier);
295
303
  }
296
304
 
305
+ // A heading with no readable row states no tiers, so the default applies and the file is
306
+ // not credited with a table it does not carry.
297
307
  if (rows.length === 0 && errors.length === 0) {
298
- return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [] };
308
+ return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [], source: 'default' };
299
309
  }
300
310
 
301
311
  if (errors.length === 0) {
@@ -305,14 +315,14 @@ export function parseDeadlineTiers(text) {
305
315
  if (count('within') === 0) errors.push(`${DEADLINE_TIERS_SECTION}: needs at least one "within <time>" row`);
306
316
  }
307
317
 
308
- if (errors.length > 0) return { ok: false, tiers: [...DEFAULT_DEADLINE_TIERS], errors };
318
+ if (errors.length > 0) return { ok: false, tiers: [...DEFAULT_DEADLINE_TIERS], errors, source: 'file' };
309
319
 
310
320
  const within = rows
311
321
  .filter((tier) => tier.kind === 'within')
312
322
  .sort((a, b) => (a.kind === 'within' && b.kind === 'within' ? a.minutes - b.minutes : 0));
313
323
  const beyond = rows.filter((tier) => tier.kind === 'beyond');
314
324
  const overdue = rows.filter((tier) => tier.kind === 'overdue');
315
- return { ok: true, tiers: [...within, ...beyond, ...overdue], errors: [] };
325
+ return { ok: true, tiers: [...within, ...beyond, ...overdue], errors: [], source: 'file' };
316
326
  }
317
327
 
318
328
  /**
@@ -524,16 +534,161 @@ export function bumpStrategy(text, { today, reason }) {
524
534
  return { from, to, updated: today, reason: cleanReason, line, text: appendChangeLog(bumped, line) };
525
535
  }
526
536
 
537
+ // ─── Summary ──────────────────────────────────────────────────────────────────
538
+
539
+ /**
540
+ * How many bullets the digest quotes from each admissibility list. Two keeps the digest a
541
+ * handful of lines; the count of what it left out points the reader back at the file.
542
+ */
543
+ const SUMMARY_ITEMS = 2;
544
+
545
+ /** The longest one bullet may run in the digest, so one answer cannot re-print a section. */
546
+ const SUMMARY_ITEM_LENGTH = 100;
547
+
548
+ /**
549
+ * The items under one heading: each bullet with the continuation lines that belong to it,
550
+ * and the template's HTML-comment guidance skipped. A bullet that wraps over several lines
551
+ * is one item, so a quoting rule is read whole rather than sliced at its first line.
552
+ * @param {string[]} lines @param {string} title @returns {string[]}
553
+ */
554
+ function sectionItems(lines, title) {
555
+ const body = sectionBody(lines, title);
556
+ if (body === null) return [];
557
+
558
+ /** @type {string[]} */
559
+ const items = [];
560
+ let inComment = false;
561
+ for (const line of body) {
562
+ const trimmed = line.trim();
563
+ if (inComment) {
564
+ if (trimmed.includes('-->')) inComment = false;
565
+ continue;
566
+ }
567
+ if (trimmed.startsWith('<!--')) {
568
+ if (!trimmed.includes('-->')) inComment = true;
569
+ continue;
570
+ }
571
+ const bullet = /^[-*][ \t]+(.+)$/.exec(trimmed);
572
+ if (bullet !== null) {
573
+ items.push(bullet[1].trim());
574
+ continue;
575
+ }
576
+ if (trimmed === '' || items.length === 0) continue;
577
+ items[items.length - 1] = `${items[items.length - 1]} ${trimmed}`;
578
+ }
579
+ return items;
580
+ }
581
+
582
+ /**
583
+ * One item as the digest shows it, cut when it is long. A cut item says so, because a
584
+ * silently shortened rule reads as the whole rule.
585
+ * @param {string} item @returns {string}
586
+ */
587
+ function shorten(item) {
588
+ return item.length <= SUMMARY_ITEM_LENGTH ? item : `${item.slice(0, SUMMARY_ITEM_LENGTH).trimEnd()}…`;
589
+ }
590
+
591
+ /**
592
+ * One section as a single digest line: at most `SUMMARY_ITEMS` bullets, then how many the
593
+ * digest left out, so the reader knows the file still says more.
594
+ * @param {string[]} items @returns {string}
595
+ */
596
+ function digestLine(items) {
597
+ const shown = items.slice(0, SUMMARY_ITEMS).map(shorten);
598
+ if (shown.length === 0) return '(none written)';
599
+ const hidden = items.length - shown.length;
600
+ return hidden === 0 ? shown.join(' · ') : `${shown.join(' · ')} · (+${hidden} more in the file)`;
601
+ }
602
+
603
+ /**
604
+ * A tier's distance in the largest unit that divides it, so the digest repeats the spelling
605
+ * the table uses: 1440 minutes reads `1d`, and 90 reads `90m`.
606
+ * @param {number} minutes @returns {string}
607
+ */
608
+ function spanText(minutes) {
609
+ if (minutes % TIER_UNIT_MINUTES.d === 0) return `${minutes / TIER_UNIT_MINUTES.d}d`;
610
+ if (minutes % TIER_UNIT_MINUTES.h === 0) return `${minutes / TIER_UNIT_MINUTES.h}h`;
611
+ return `${minutes}m`;
612
+ }
613
+
614
+ /**
615
+ * One tier as the digest shows it: the phrase the table's row uses, then the factor.
616
+ * @param {DeadlineTier} tier @returns {string}
617
+ */
618
+ function tierText(tier) {
619
+ if (tier.kind === 'overdue') return `overdue ×${tier.factor}`;
620
+ return `${tier.kind === 'within' ? 'within' : 'more than'} ${spanText(tier.minutes)} ×${tier.factor}`;
621
+ }
622
+
623
+ /**
624
+ * The deadline factors as one digest line, narrowest row first. The order is part of the
625
+ * rule — the queue multiplies by the narrowest row the remaining time fits inside — so the
626
+ * whole table prints: a truncated order would summarise a different rule.
627
+ * @param {DeadlineTier[]} tiers @returns {string}
628
+ */
629
+ function deadlineLine(tiers) {
630
+ return tiers.map(tierText).join(' · ');
631
+ }
632
+
633
+ /**
634
+ * When the rubric last changed and why: the newest change-log entry, because the log is
635
+ * written oldest first and `validate` already proves it names the version the file carries.
636
+ * A file with no entry says so rather than borrowing the header date as a reason.
637
+ * @param {string[]} lines @param {string} updated @returns {string}
638
+ */
639
+ function lastChange(lines, updated) {
640
+ const entries = (sectionBody(lines, CHANGE_LOG_SECTION) ?? [])
641
+ .map((line) => CHANGE_LOG_ENTRY.exec(line))
642
+ .filter((match) => match !== null);
643
+ const newest = entries[entries.length - 1];
644
+ if (newest === null || newest === undefined) return `${updated} — no change-log entry, so no reason is on file`;
645
+ return `${newest[1]} v${newest[2]}: ${newest[3]}`;
646
+ }
647
+
648
+ /**
649
+ * The digest the skill opens with: the file it read, the version, the newest change-log
650
+ * entry, the themes, the two admissibility lists, and the deadline factors when the file
651
+ * states them — a handful of lines, never a re-print.
652
+ *
653
+ * Nothing here is a ranking order, and the labels must not suggest one. `## What counts as
654
+ * value` and `## Explicitly out of scope` are admissibility: the test a bead passes to earn
655
+ * work, and the work the month rules out. What orders the beads that pass them is the
656
+ * scoring rubric — RICE with its multipliers — not this file. The one ordered rule set the
657
+ * file does state is `## Deadline tiers`, printed in its own order and omitted entirely when
658
+ * the section is absent, because the documented default then applies and the file is silent.
659
+ *
660
+ * None of these is a section of its own and none must become one. `validate` gates every
661
+ * strategy already committed, so a new required section would fail all of them.
662
+ * @param {string} text @param {string} file @param {StrategyReport} report A passing report.
663
+ * @returns {string}
664
+ */
665
+ export function summariseStrategy(text, file, report) {
666
+ const lines = text.split('\n');
667
+ const digest = [
668
+ `${file} — strategy v${report.version}`,
669
+ ` last change: ${lastChange(lines, report.updated ?? 'no date recorded')}`,
670
+ ` themes: ${digestLine(sectionItems(lines, 'Current themes'))}`,
671
+ ` admissibility — what counts as value: ${digestLine(sectionItems(lines, 'What counts as value'))}`,
672
+ ` admissibility — out of scope: ${digestLine(sectionItems(lines, 'Explicitly out of scope'))}`,
673
+ ];
674
+ const tiers = parseDeadlineTiers(text);
675
+ if (tiers.source === 'file') digest.push(` deadline factors: ${deadlineLine(tiers.tiers)}`);
676
+ return `${digest.join('\n')}\n`;
677
+ }
678
+
527
679
  // ─── CLI ──────────────────────────────────────────────────────────────────────
528
680
 
529
681
  /** @type {readonly string[]} */
530
- export const COMMANDS = /** @type {const} */ (['init', 'validate', 'bump']);
682
+ export const COMMANDS = /** @type {const} */ (['init', 'validate', 'bump', 'summary']);
531
683
 
532
684
  const USAGE = `Usage: node strategy.mjs <${COMMANDS.join('|')}> [options]
533
685
 
534
686
  init Write the shipped template to the strategy path. Refuses to overwrite.
535
687
  validate Check the shape and print the file to stdout only when it passes.
536
688
  bump Raise the version by one, record today's date and append the reason.
689
+ summary Print the digest to open a run with: path, version, last change, admissibility,
690
+ deadline factors. Exits 3 when no strategy file exists, so a first run is told
691
+ apart from a strategy that is present but invalid.
537
692
 
538
693
  Options:
539
694
  --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
@@ -637,6 +792,15 @@ export async function resolveStrategyPath({ file = DEFAULT_FILE, cwd = process.c
637
792
  return resolve(base, file);
638
793
  }
639
794
 
795
+ /**
796
+ * The one line every subcommand prints when the strategy file is not there yet. Reused so
797
+ * `summary` and `validate` cannot drift into two descriptions of the same absence.
798
+ * @param {string} file @returns {string}
799
+ */
800
+ function missingMessage(file) {
801
+ return `${file} does not exist — run "init" and interview the owner, or pass --file <path>`;
802
+ }
803
+
640
804
  /**
641
805
  * Build the report text for one failed run.
642
806
  * @param {string} file @param {string[]} errors @returns {string}
@@ -706,14 +870,33 @@ export async function runCli({
706
870
  return 0;
707
871
  }
708
872
 
873
+ // `summary` separates the two states the skill answers differently: no file is a first
874
+ // run, which takes the interview and no digest; a file that fails the shape check is
875
+ // reported as what it is, never as the current strategy.
876
+ if (options.command === 'summary') {
877
+ /** @type {string} */
878
+ let current;
879
+ try {
880
+ current = await readFile(file, 'utf8');
881
+ } catch {
882
+ writeError(`${missingMessage(file)}\n`);
883
+ return 3;
884
+ }
885
+ const currentReport = validateStrategy(current);
886
+ if (!currentReport.ok) {
887
+ writeError(formatErrors(file, currentReport.errors));
888
+ return 1;
889
+ }
890
+ write(summariseStrategy(current, file, currentReport));
891
+ return 0;
892
+ }
893
+
709
894
  /** @type {string} */
710
895
  let text;
711
896
  try {
712
897
  text = await readFile(file, 'utf8');
713
898
  } catch {
714
- throw new StrategyError(
715
- `${file} does not exist — run "init" and interview the owner, or pass --file <path>`,
716
- );
899
+ throw new StrategyError(missingMessage(file));
717
900
  }
718
901
 
719
902
  if (options.command === 'bump') {
@@ -236,11 +236,13 @@ node skills/workflow/ralph/scripts/prune-worktrees.mjs --base <ref> # prove
236
236
 
237
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.
238
238
 
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.
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, and one proof guard that follows the bead's state — unpushed commits on an open bead, unproven content on a closed one.
240
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.
241
+ **A closed bead is decided by content alone, whatever its upstream is.** Ralph cuts its branch from the remote base (`git worktree add -b feature/<id> … origin/main`), so `branch.autoSetupMerge` makes that base the branch's upstream, and a squash merge rewrites the commits: the branch stays ahead of its own upstream for ever. Counting commits against that upstream refuses every landed ralph worktree. So the guard is content, upstream or not: the base must hold the branch's own version of every path the branch changed since their merge base. No upstream is not a refusal by itself — a merge that deletes the branch also deletes its upstream, so no remote-tracking branch is the normal end of landed work. 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.
242
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.
243
+ **An open bead is decided by its upstream.** With no upstream it is refused on that alone, whatever its content says; with one, every commit must be pushed to it.
244
+
245
+ **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 names the worktree's own branch, keeps the worktree and refuses.
244
246
 
245
247
  **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.
246
248
 
@@ -14,6 +14,15 @@
14
14
  // branch was cut holds paths the branch never had, and demanding tree equality
15
15
  // would keep every branch but the last one landed.
16
16
  //
17
+ // A closed bead's upstream is never the arbiter. Ralph cuts its branch from the
18
+ // remote base (`git worktree add -b feature/<id> … origin/<base>`), and
19
+ // `branch.autoSetupMerge` then makes that base the branch's upstream. A squash
20
+ // merge rewrites the commits, so the branch stays ahead of its own upstream for
21
+ // ever: counting commits against it refuses every landed ralph worktree, which is
22
+ // the one case the content test exists to decide. A closed bead is therefore
23
+ // decided by content alone, whatever its upstream is. An open bead keeps the
24
+ // upstream refusal, because nothing about an open bead is proven.
25
+ //
17
26
  // The test proves content, never history. A branch removed here is discarded
18
27
  // together with every commit the base does not contain — a reverted change and a
19
28
  // run of empty commits both go, and so do their messages. The base is the remote's
@@ -200,10 +209,45 @@ function splitNulPaths(stdout) {
200
209
  }
201
210
 
202
211
  /**
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.
212
+ * The opening clause of a content-proof refusal. Two situations reach the content
213
+ * check — a closed bead whose branch has lost its upstream, and a closed bead whose
214
+ * branch still tracks the base it was cut from — and both are refused for the same
215
+ * reason: the base does not hold the branch's content. The clause states which
216
+ * situation applies, so a reader can tell an unpublished branch from one whose
217
+ * upstream cannot prove it.
218
+ * @param {string} branch
219
+ * @param {string | null} upstream the branch's remote-tracking ref, or null when it has none.
220
+ * @returns {string}
221
+ */
222
+ function contentLead(branch, upstream) {
223
+ return upstream === null
224
+ ? `branch ${branch} has no remote-tracking branch`
225
+ : `branch ${branch} is not proven by ${upstream}`;
226
+ }
227
+
228
+ /**
229
+ * True when `ref` names the worktree's own branch, locally or as its
230
+ * remote-tracking ref. Comparing such a ref with the branch is a self-comparison:
231
+ * the diff is empty whatever the branch holds, so it proves nothing.
232
+ * @param {string} path
233
+ * @param {string} ref
234
+ * @param {string} branch
235
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
236
+ * @returns {Promise<boolean>}
237
+ */
238
+ async function namesOwnBranch(path, ref, branch, git) {
239
+ const resolved = await git(path, ['rev-parse', '--symbolic-full-name', ref]);
240
+ if (!resolved.ok) return false;
241
+ const name = resolved.stdout.trim();
242
+ if (name === `refs/heads/${branch}`) return true;
243
+ // `refs/remotes/<remote>/<branch>`, whatever the remote is called.
244
+ return name.startsWith('refs/remotes/') && name.endsWith(`/${branch}`);
245
+ }
246
+
247
+ /**
248
+ * Decide whether a closed bead's branch still holds work. Removing such a branch is
249
+ * only safe when the base holds the branch's own version of every path the branch
250
+ * changed — the squash-merge case. Anything unprovable keeps the worktree.
207
251
  *
208
252
  * One-sided containment, in three steps: take the merge base of the base and the
209
253
  * branch, list the paths the branch changed since that merge base, then require the
@@ -227,46 +271,51 @@ function splitNulPaths(stdout) {
227
271
  * read as pathspec magic instead of a name. Neither alone is sufficient to fix,
228
272
  * so the first diff reads with `-z` and the second is run with
229
273
  * `--literal-pathspecs`.
230
- * @param {{ path: string, branch: string, beadClosed: boolean }} target
274
+ * @param {{ path: string, branch: string, beadClosed: boolean, upstream: string | null }} target
275
+ * `upstream` is the branch's remote-tracking ref, or null when it has none.
231
276
  * @param {string | null} baseRef
232
277
  * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
233
278
  * @returns {Promise<string | null>} a refusal reason, or null when nothing is lost.
234
279
  */
235
- async function findLostWork({ path, branch, beadClosed }, baseRef, git) {
280
+ async function findLostWork({ path, branch, beadClosed, upstream }, baseRef, git) {
236
281
  if (!beadClosed) {
237
282
  return `branch ${branch} has no remote-tracking branch and its bead is not closed`;
238
283
  }
284
+ /** @param {string} clause @returns {string} */
285
+ const refuse = (clause) => `${contentLead(branch, upstream)}: ${clause}`;
239
286
  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`;
287
+ return refuse('the base branch cannot be resolved, so its content cannot be proven landed');
241
288
  }
242
289
  const tip = await git(path, ['rev-parse', '--verify', 'HEAD']);
243
290
  if (!tip.ok) {
244
291
  const detail = tip.stderr.trim();
245
292
  const suffix = detail === '' ? '' : `: ${detail}`;
246
- return `branch ${branch} has no remote-tracking branch and its tip cannot be read${suffix}`;
293
+ return refuse(`its tip cannot be read${suffix}`);
247
294
  }
248
295
  const baseTip = await git(path, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
249
296
  if (!baseTip.ok) {
250
297
  const detail = baseTip.stderr.trim();
251
298
  const suffix = detail === '' ? '' : `: ${detail}`;
252
- return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
299
+ return refuse(`${baseRef} cannot be compared with it${suffix}`);
253
300
  }
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`;
301
+ // A base that names the branch being deleted compares the branch with itself,
302
+ // whatever the branch holds. The test is on the ref, not on the tip: a branch cut
303
+ // from the base and never committed to holds the base's own commit, and that is a
304
+ // real proof — it has no content of its own to lose.
305
+ if (await namesOwnBranch(path, baseRef, branch, git)) {
306
+ return refuse(`${baseRef} names this branch, so the comparison would prove nothing`);
258
307
  }
259
308
  const mergeBase = await git(path, ['merge-base', baseRef, 'HEAD']);
260
309
  if (!mergeBase.ok) {
261
310
  const detail = mergeBase.stderr.trim();
262
311
  const suffix = detail === '' ? '' : `: ${detail}`;
263
- return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
312
+ return refuse(`${baseRef} cannot be compared with it${suffix}`);
264
313
  }
265
314
  const touched = await git(path, ['diff', '--name-only', '-z', '--no-renames', mergeBase.stdout.trim(), 'HEAD', '--']);
266
315
  if (!touched.ok) {
267
316
  const detail = touched.stderr.trim();
268
317
  const suffix = detail === '' ? '' : `: ${detail}`;
269
- return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
318
+ return refuse(`${baseRef} cannot be compared with it${suffix}`);
270
319
  }
271
320
  const changedPaths = splitNulPaths(touched.stdout);
272
321
  // Nothing but empty commits: no content to prove, and no content to lose.
@@ -277,11 +326,11 @@ async function findLostWork({ path, branch, beadClosed }, baseRef, git) {
277
326
  if (!unproven.ok) {
278
327
  const detail = unproven.stderr.trim();
279
328
  const suffix = detail === '' ? '' : `: ${detail}`;
280
- return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
329
+ return refuse(`${baseRef} cannot be compared with it${suffix}`);
281
330
  }
282
331
  const unprovenPaths = splitNulPaths(unproven.stdout).length;
283
332
  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`;
333
+ return refuse(`${baseRef} does not hold this branch's version of ${unprovenPaths} path(s), so its content is not landed`);
285
334
  }
286
335
  return null;
287
336
  }
@@ -315,12 +364,18 @@ async function findRefusals({ path, registered, beadClosed }, git, baseRef) {
315
364
  if (dirtyPaths > 0) reasons.push(`worktree is dirty: ${dirtyPaths} uncommitted path(s)`);
316
365
 
317
366
  const upstream = await git(path, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
318
- if (!upstream.ok) {
319
- const lost = await findLostWork({ path, branch: registered.branch, beadClosed }, baseRef, git);
367
+ const trackingRef = upstream.ok ? upstream.stdout.trim() : null;
368
+
369
+ // A closed bead is decided by content alone, never by its upstream: a ralph
370
+ // branch is cut from the remote base, so it tracks that base and stays ahead of
371
+ // it after a squash merge rewrites its commits. Counting commits against such an
372
+ // upstream refuses every landed ralph worktree. An open bead with no upstream has
373
+ // nothing to prove, so it lands here too and refuses below.
374
+ if (beadClosed || trackingRef === null) {
375
+ const lost = await findLostWork({ path, branch: registered.branch, beadClosed, upstream: trackingRef }, baseRef, git);
320
376
  if (lost !== null) reasons.push(lost);
321
377
  return { branch: registered.branch, reasons };
322
378
  }
323
- const trackingRef = upstream.stdout.trim();
324
379
  const unpushed = await git(path, ['rev-list', '--count', `${trackingRef}..HEAD`]);
325
380
  if (!unpushed.ok) return { branch: registered.branch, reasons: [...reasons, `branch ${registered.branch} cannot be compared with ${trackingRef}: ${unpushed.stderr.trim()}`] };
326
381
  const unpushedCommits = Number.parseInt(unpushed.stdout.trim(), 10);
@@ -422,15 +477,20 @@ reports the rest. A worktree whose bead is open or missing is an orphan: it is
422
477
  only removed when --remove-orphans is passed. Run from anywhere inside the
423
478
  repository.
424
479
 
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.
480
+ A closed bead's branch is proven by content alone, whatever its upstream is: many
481
+ merge methods delete the remote branch when the work lands, and a branch cut from
482
+ the remote base tracks that base, which a squash merge leaves behind. Such a branch
483
+ is removed only when the base holds the branch's own version of every path the
484
+ branch changed since their merge base. That proves content and never history:
485
+ removal discards every commit the base does not contain, such as a reverted change
486
+ or an empty commit, together with its message. The base defaults to the remote's
487
+ default branch (origin/HEAD); --base names another ref, such as a release branch. A
488
+ base that names the worktree's own branch compares the branch with itself, so it
489
+ proves nothing: the worktree is kept and refused, as it is when no base resolves.
490
+
491
+ An open bead is proven by its upstream instead, and only with --remove-orphans: its
492
+ branch must carry an upstream and no commit unpushed to it. With no upstream it is
493
+ refused whatever its content says.
434
494
  `;
435
495
 
436
496
  /** Every flag the CLI accepts. */