@erclx/canon 4.68.0 → 4.70.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 (49) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
  4. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  5. package/claude/skills/{canon-screencast → draft-screencast}/REQUIREMENT.md +4 -4
  6. package/claude/skills/{canon-screencast → draft-screencast}/SKILL.md +4 -4
  7. package/claude/skills/{canon-slides-draft → draft-slides}/REQUIREMENT.md +3 -3
  8. package/claude/skills/{canon-slides-draft → draft-slides}/SKILL.md +2 -2
  9. package/claude/skills/{canon-frames-read → read-frames}/REQUIREMENT.md +2 -2
  10. package/claude/skills/{canon-frames-read → read-frames}/SKILL.md +3 -3
  11. package/claude/skills/{canon-record → record-screencast}/REQUIREMENT.md +5 -5
  12. package/claude/skills/{canon-record → record-screencast}/SKILL.md +4 -4
  13. package/claude/skills/review-pr/SKILL.md +55 -7
  14. package/claude/skills/role-orchestrator/SKILL.md +2 -1
  15. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +7 -3
  16. package/claude/skills/role-orchestrator/scripts/poll.sh +79 -34
  17. package/claude/skills/role-worker/SKILL.md +2 -1
  18. package/docs/agents/commands.md +3 -0
  19. package/docs/agents/demo.md +3 -3
  20. package/docs/agents/index.md +1 -1
  21. package/docs/agents/pr-reads.md +47 -12
  22. package/docs/agents/sandbox.md +13 -10
  23. package/docs/agents/tasks.md +46 -3
  24. package/docs/workflow/ai-workflow.md +19 -19
  25. package/package.json +3 -2
  26. package/scripts/core/regen-web-previews.ts +94 -0
  27. package/scripts/lib/sandbox-dispatch.sh +8 -0
  28. package/src/claude/cases/workflow.ts +4 -4
  29. package/src/claude/plugin-update.ts +48 -0
  30. package/src/commands/claude.ts +281 -1
  31. package/src/commands/demo.ts +1 -1
  32. package/src/commands/feedback.ts +15 -5
  33. package/src/commands/gate.ts +3 -1
  34. package/src/commands/pr.ts +130 -1
  35. package/src/commands/sandbox.ts +13 -4
  36. package/src/commands/tasks.ts +178 -1
  37. package/src/demo/beats.ts +1 -1
  38. package/src/design/components.ts +12 -0
  39. package/src/gate/measures.ts +47 -1
  40. package/src/migrate/skill-names.ts +15 -1
  41. package/src/pr/review-scope.ts +177 -0
  42. package/src/sandbox/expect.ts +26 -1
  43. package/src/tasks/archive.ts +206 -3
  44. package/src/tasks/label.ts +14 -6
  45. package/src/tasks/validate.ts +22 -0
  46. package/src/teach/nav.ts +97 -3
  47. package/standards/glossary.md +8 -0
  48. package/standards/plan.md +1 -1
  49. package/standards/tasks.md +15 -1
@@ -1,11 +1,15 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
+ import { execa } from 'execa'
4
+ import { gitEnv } from '@/git-env'
3
5
  import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
4
6
  import { type BranchOutcome, planBranch } from '@/tasks/branch'
5
7
  import {
6
8
  type ArchiveOutcome,
7
9
  archiveTask,
8
10
  type CitationOutcome,
11
+ type DeclineOutcome,
12
+ declineTask,
9
13
  type PlanCitations,
10
14
  planCitations,
11
15
  } from '@/tasks/archive'
@@ -44,12 +48,22 @@ import { mainWorktreeRoot } from '@/worktree'
44
48
  /** Returned when the board carries a finding, which is the gating result. */
45
49
  const EXIT_FINDINGS = 2
46
50
 
51
+ /** Matches `trunk.ts`'s bound on a git subprocess this verb also shells out to. */
52
+ const GIT_TIMEOUT_MS = 10_000
53
+
47
54
  interface ArchiveCommandOptions {
48
55
  readonly json?: boolean
49
56
  readonly pullRequest?: string
50
57
  readonly root?: string
51
58
  }
52
59
 
60
+ interface DeclineCommandOptions {
61
+ readonly by?: string
62
+ readonly json?: boolean
63
+ readonly reason?: string
64
+ readonly root?: string
65
+ }
66
+
53
67
  interface ValidateCommandOptions {
54
68
  readonly json?: boolean
55
69
  readonly root?: string
@@ -134,6 +148,39 @@ export function register(program: Command): void {
134
148
  process.exitCode = await runArchive(task, opts)
135
149
  })
136
150
 
151
+ tasks
152
+ .command('decline')
153
+ .description(
154
+ 'Move a task decided against into .canon/tasks/declined/, recording why',
155
+ )
156
+ .argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
157
+ .helpOption('-h, --help', 'Show this help message')
158
+ .option('--reason <text>', 'Why the task was decided against')
159
+ .option('--by <name>', 'Who decided, defaulting to git config user.name')
160
+ .option('--json', 'Emit a machine-readable record on stdout')
161
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
162
+ .addHelpText(
163
+ 'after',
164
+ [
165
+ '',
166
+ 'Unlike archive, decline carries no outcome-state gate: a task can be',
167
+ 'decided against at any outcome state, and the two never share a',
168
+ 'refusal set since they answer different questions.',
169
+ '',
170
+ 'Exit codes:',
171
+ ' 0 the task was declined',
172
+ ' 1 refused, with the reason on stderr or in the JSON record',
173
+ '',
174
+ 'Examples:',
175
+ ' canon tasks decline v28.1-trigger-escalation --reason "superseded by v30.2" # canon-allow-reference: illustrates the stem-selection form, not a citation of a real task',
176
+ ' canon tasks decline v28.1-trigger-escalation --reason "no longer needed" --by Alex --json',
177
+ '',
178
+ ].join('\n'),
179
+ )
180
+ .action(async (task: string, opts: DeclineCommandOptions) => {
181
+ process.exitCode = await runDecline(task, opts)
182
+ })
183
+
137
184
  tasks
138
185
  .command('validate')
139
186
  .description(
@@ -938,7 +985,7 @@ function reportValidation(
938
985
  intro('canon tasks validate')
939
986
  logStep('Board')
940
987
  logInfo(
941
- `${outcome.rows} row(s) across the readiness groups, ${outcome.backlog} backlog line(s), ${outcome.tasks} task file(s)`,
988
+ `${outcome.rows} row(s) across the readiness groups, ${outcome.backlog} backlog line(s), ${outcome.tasks} task file(s), ${outcome.declined} declined`,
942
989
  )
943
990
 
944
991
  logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
@@ -994,6 +1041,7 @@ function reportValidation(
994
1041
  rows: outcome.rows,
995
1042
  backlog: outcome.backlog,
996
1043
  tasks: outcome.tasks,
1044
+ declined: outcome.declined,
997
1045
  findings: outcome.findings,
998
1046
  untested: outcome.untested,
999
1047
  claims: outcome.claims,
@@ -1146,3 +1194,132 @@ function recordFor(
1146
1194
  cut: outcome.cut,
1147
1195
  }
1148
1196
  }
1197
+
1198
+ /**
1199
+ * Reads the local `git config user.name` when `--by` names nobody. `--root`
1200
+ * scopes the read so it answers for the board's own repository rather than
1201
+ * whatever the ambient environment points at, mirroring `trunk.ts`'s guard.
1202
+ */
1203
+ async function resolveBy(
1204
+ root: string,
1205
+ by: string | undefined,
1206
+ ): Promise<string | undefined> {
1207
+ if (by) return by
1208
+
1209
+ const result = await execa('git', ['-C', root, 'config', 'user.name'], {
1210
+ reject: false,
1211
+ timeout: GIT_TIMEOUT_MS,
1212
+ env: gitEnv(),
1213
+ extendEnv: false,
1214
+ })
1215
+
1216
+ const name = result.exitCode === 0 ? result.stdout.trim() : ''
1217
+ return name.length > 0 ? name : undefined
1218
+ }
1219
+
1220
+ async function runDecline(
1221
+ task: string,
1222
+ opts: DeclineCommandOptions,
1223
+ ): Promise<number> {
1224
+ const emitJson = opts.json ?? false
1225
+
1226
+ if (!opts.reason) {
1227
+ return reportDecline(
1228
+ {
1229
+ ok: false,
1230
+ reason: 'bad-input',
1231
+ message: 'No reason named. Pass --reason <text>.',
1232
+ detail: [],
1233
+ },
1234
+ emitJson,
1235
+ process.cwd(),
1236
+ )
1237
+ }
1238
+
1239
+ const root = opts.root ?? (await mainWorktreeRoot())
1240
+ const by = await resolveBy(root, opts.by)
1241
+
1242
+ if (!by) {
1243
+ return reportDecline(
1244
+ {
1245
+ ok: false,
1246
+ reason: 'bad-input',
1247
+ message:
1248
+ 'No decider named. Pass --by <name> or set git config user.name.',
1249
+ detail: [],
1250
+ },
1251
+ emitJson,
1252
+ root,
1253
+ )
1254
+ }
1255
+
1256
+ const outcome = await declineTask(root, task, opts.reason, by)
1257
+
1258
+ return reportDecline(outcome, emitJson, root)
1259
+ }
1260
+
1261
+ function reportDecline(
1262
+ outcome: DeclineOutcome,
1263
+ emitJson: boolean,
1264
+ root: string,
1265
+ ): number {
1266
+ if (emitJson) {
1267
+ process.stdout.write(`${JSON.stringify(declineRecordFor(outcome, root))}\n`)
1268
+ return outcome.ok ? 0 : 1
1269
+ }
1270
+
1271
+ intro('canon tasks decline')
1272
+
1273
+ if (!outcome.ok) {
1274
+ logStep('Refused')
1275
+ logError(outcome.message)
1276
+ if (outcome.detail.length > 0) pipeOutput(outcome.detail.join('\n'))
1277
+ outro()
1278
+ return 1
1279
+ }
1280
+
1281
+ logStep('Declined')
1282
+ logRemove(relative(root, outcome.from))
1283
+ logAdd(relative(root, outcome.to))
1284
+ if (outcome.plan) {
1285
+ logRemove(relative(root, outcome.plan.from))
1286
+ logAdd(relative(root, outcome.plan.to))
1287
+ logInfo('retargeted the Plan: line')
1288
+ }
1289
+ if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
1290
+ if (outcome.backlogRowRemoved) logInfo('cleared the backlog row')
1291
+ if (outcome.indexRegenerated) logInfo('regenerated index.md')
1292
+ outro()
1293
+
1294
+ return 0
1295
+ }
1296
+
1297
+ function declineRecordFor(
1298
+ outcome: DeclineOutcome,
1299
+ root: string,
1300
+ ): Record<string, unknown> {
1301
+ if (!outcome.ok) {
1302
+ return {
1303
+ ok: false,
1304
+ reason: outcome.reason,
1305
+ message: outcome.message,
1306
+ detail: outcome.detail,
1307
+ }
1308
+ }
1309
+
1310
+ return {
1311
+ ok: true,
1312
+ task: outcome.stem,
1313
+ from: relative(root, outcome.from),
1314
+ to: relative(root, outcome.to),
1315
+ priorityRowRemoved: outcome.priorityRowRemoved,
1316
+ backlogRowRemoved: outcome.backlogRowRemoved,
1317
+ indexRegenerated: outcome.indexRegenerated,
1318
+ plan: outcome.plan
1319
+ ? {
1320
+ from: relative(root, outcome.plan.from),
1321
+ to: relative(root, outcome.plan.to),
1322
+ }
1323
+ : null,
1324
+ }
1325
+ }
package/src/demo/beats.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Reads the human-facing draft `canon-screencast` writes. Nothing here knows
2
+ * Reads the human-facing draft `draft-screencast` writes. Nothing here knows
3
3
  * about a browser: the draft is prose aimed at a person, and turning it into
4
4
  * something executable is `@/demo/compile`'s job.
5
5
  */
@@ -998,6 +998,18 @@ h2 .count {
998
998
  line-height: 1.55;
999
999
  }
1000
1000
 
1001
+ .gloss-group {
1002
+ margin: 1.1rem 0 0.4rem;
1003
+ font-family: var(--teach-sans);
1004
+ font-size: 0.8125rem;
1005
+ font-weight: 600;
1006
+ color: var(--color-muted);
1007
+ text-transform: uppercase;
1008
+ letter-spacing: 0.02em;
1009
+ }
1010
+
1011
+ .gloss-group:first-of-type { margin-top: 0; }
1012
+
1001
1013
  .gterm b { font-weight: 700; }
1002
1014
  /* The markdown source separates a term from its definition with a colon,
1003
1015
  and the house standard bans an em dash outright, so the view mirrors
@@ -110,6 +110,17 @@ export const GOV_EXPECTED_UNREFERENCED = ['260-shadcn', '320-tanstack-query']
110
110
  */
111
111
  export const SANDBOX_UNDECLARED_CEILING = 47
112
112
 
113
+ /**
114
+ * Skills the sandbox skill census reports `asserted`, taken from `canon
115
+ * sandbox coverage --skills` against a clean tree. A dropped pairing lowers
116
+ * this directly, unlike the ceiling above, which a rename can starve without
117
+ * moving: fourteen arms drifted off their skill's name in one branch and the
118
+ * scenario-level ceiling stayed green throughout, since it counts scenarios
119
+ * declaring an expectation rather than skills a scenario reaches. Lowering
120
+ * this floor is a deliberate edit that says which skill lost its arm and why.
121
+ */
122
+ export const SANDBOX_ASSERTED_FLOOR = 26
123
+
113
124
  /**
114
125
  * The retained counts the audit stage compares each run against. Spelled here
115
126
  * rather than derived, because this stage only ever names the file in a remedy
@@ -740,10 +751,45 @@ export const sandboxCoverage: Measure = async (ctx) => {
740
751
  }
741
752
  }
742
753
 
754
+ const scenarioEmission = info(
755
+ `${armed} of ${total} scenarios declare expectations, ${undeclared} undeclared against a ceiling of ${SANDBOX_UNDECLARED_CEILING}`,
756
+ )
757
+
758
+ const skillsRun = await ctx.cli(['sandbox', 'coverage', '--skills', '--json'])
759
+
760
+ if (skillsRun.exitCode !== 0) {
761
+ return {
762
+ emissions: [scenarioEmission],
763
+ unmeasured: `The skill census did not report (exit ${skillsRun.exitCode}). It ships in the checkout beside the scenario report, so a run that does not report is a broken command rather than an absent census.`,
764
+ }
765
+ }
766
+
767
+ const skillsRecord = parseJson(skillsRun.stdout) as
768
+ | { asserted?: unknown; totalSkills?: unknown }
769
+ | undefined
770
+ const asserted = skillsRecord?.asserted
771
+ const totalSkills = skillsRecord?.totalSkills
772
+
773
+ if (typeof asserted !== 'number' || typeof totalSkills !== 'number') {
774
+ return {
775
+ emissions: [scenarioEmission],
776
+ failure:
777
+ 'The skill census carried no asserted total, so the stage measured nothing. Run bun src/cli.ts sandbox coverage --skills --json.',
778
+ }
779
+ }
780
+
781
+ if (asserted < SANDBOX_ASSERTED_FLOOR) {
782
+ return {
783
+ emissions: [scenarioEmission],
784
+ failure: `${asserted} of ${totalSkills} skills asserted, under the floor of ${SANDBOX_ASSERTED_FLOOR}. A rename or a moved arm likely dropped a skill's pairing; repair the arm's filename against its skill and say which skill lost its arm, or lower SANDBOX_ASSERTED_FLOOR in src/gate/measures.ts.`,
785
+ }
786
+ }
787
+
743
788
  return {
744
789
  emissions: [
790
+ scenarioEmission,
745
791
  info(
746
- `${armed} of ${total} scenarios declare expectations, ${undeclared} undeclared against a ceiling of ${SANDBOX_UNDECLARED_CEILING}`,
792
+ `${asserted} of ${totalSkills} skills asserted, against a floor of ${SANDBOX_ASSERTED_FLOOR}`,
747
793
  ),
748
794
  ],
749
795
  }
@@ -2,7 +2,8 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
2
2
 
3
3
  /**
4
4
  * The twenty-five shipped skills that carried a `claude-` prefix, and the
5
- * two-word name each takes instead.
5
+ * two-word name each takes instead, plus the four that carried a `canon-`
6
+ * prefix naming a subject other than the toolkit itself.
6
7
  *
7
8
  * The plugin namespace already resolves every one of them as `canon:<name>`,
8
9
  * so the prefix bought grouping rather than uniqueness, and the grouping it
@@ -11,6 +12,15 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
11
12
  * a listing groups the review triple, the three planning skills, and the three
12
13
  * session roles together.
13
14
  *
15
+ * The four `canon-` rows split the same way rather than sharing one
16
+ * replacement prefix. `canon-screencast` and `canon-slides-draft` draft a
17
+ * document, which is what the `draft-` family already means across its other
18
+ * members, so they join it as `draft-screencast` and `draft-slides`.
19
+ * `canon-record` and `canon-frames-read` are not drafting anything, so each
20
+ * takes a standalone verb-first name, `record-screencast` and `read-frames`,
21
+ * reading in sequence with `draft-screencast` as one three-step pipeline with
22
+ * no prefix forcing that reading.
23
+ *
14
24
  * Every name takes two words. Ten of these would have landed as a bare single
15
25
  * word under a plain strip, and a bare word such as `review` or `docs` is a
16
26
  * substring of ordinary prose with no token left for a later sweep to find.
@@ -41,6 +51,10 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
41
51
  'claude-ux-measure': 'ux-measure',
42
52
  'claude-worker': 'role-worker',
43
53
  'claude-worktree': 'session-worktree',
54
+ 'canon-screencast': 'draft-screencast',
55
+ 'canon-slides-draft': 'draft-slides',
56
+ 'canon-record': 'record-screencast',
57
+ 'canon-frames-read': 'read-frames',
44
58
  }
45
59
 
46
60
  /**
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The last state a review pass covered, read off the pass's own marker rather
3
+ * than off the fields GitHub stamps when a review is submitted.
4
+ *
5
+ * `commit.oid` names whatever the pull request head was at the instant the
6
+ * review was submitted, not the commit the session read. A push landing between
7
+ * the read and the post moves that stamp onto a commit nobody reviewed, and the
8
+ * next pass then scopes its delta past work no reader has seen. `submittedAt`
9
+ * carries the same defect on the time axis. `review-pr` therefore writes the
10
+ * commit it read and the instant it read it into the body itself, and this
11
+ * module is the one place that marker is parsed.
12
+ */
13
+
14
+ /** The two headings `review-pr` posts a pass under. */
15
+ export const REVIEW_HEADINGS = ['## Review', '## Review closed'] as const
16
+
17
+ export type ReviewHeading = (typeof REVIEW_HEADINGS)[number]
18
+
19
+ /** Whether the thread's newest pass still owes work on it. */
20
+ export type ReviewState = 'open' | 'closed' | 'none'
21
+
22
+ /** Where the covered state came from. */
23
+ export type ScopeSource =
24
+ /** The pass wrote its own read-time marker, which is the authority. */
25
+ | 'marker'
26
+ /** A pass posted before the marker shipped, read off GitHub's own stamps. */
27
+ | 'fallback'
28
+ /** The thread carries no pass at all, so this is a first pass. */
29
+ | 'none'
30
+
31
+ export interface ReviewScope {
32
+ /** The heading of the newest pass, absent when the thread carries none. */
33
+ readonly heading?: ReviewHeading
34
+ /** The same fact as `heading`, collapsed for a caller that reads only state. */
35
+ readonly state: ReviewState
36
+ /** The commit the newest pass covered. */
37
+ readonly commit?: string
38
+ /** When that pass read the commit, present only under a marker. */
39
+ readonly readAt?: string
40
+ /** When GitHub recorded the pass, which trails `readAt` by the compose window. */
41
+ readonly submittedAt?: string
42
+ readonly source: ScopeSource
43
+ }
44
+
45
+ /** One row of `gh pr view --json reviews`. */
46
+ export interface ReviewRow {
47
+ readonly body?: string
48
+ readonly commit?: { readonly oid?: string } | null
49
+ readonly submittedAt?: string | null
50
+ }
51
+
52
+ /**
53
+ * What this reads out of `gh pr view`.
54
+ *
55
+ * Only `reviews` is named, so a caller may hand over a wider payload it fetched
56
+ * for its own reasons without this module growing a field it never opens.
57
+ */
58
+ export interface ReviewListing {
59
+ readonly reviews?: readonly ReviewRow[]
60
+ }
61
+
62
+ /**
63
+ * The marker `review-pr` appends as the last line of every body it posts.
64
+ *
65
+ * The sha is bounded at git's own abbreviation range rather than pinned to 40,
66
+ * since an abbreviated sha still names a commit and refusing one drops the pass
67
+ * back to the stamp this exists to replace.
68
+ *
69
+ * Anchoring to a whole line is not on its own what keeps a quoted marker out.
70
+ * It stops the inline form, where the surrounding backticks leave the trimmed
71
+ * line unmatchable, and a marker shown alone inside a fenced block trims to
72
+ * exactly this pattern. Position is what separates the two, which is why
73
+ * `markerOf` reads one line rather than searching.
74
+ */
75
+ const MARKER =
76
+ /^<!--\s*review-pr:\s*commit=([0-9a-f]{7,40})\s+read-at=(\S+)\s*-->$/
77
+
78
+ /**
79
+ * Loose enough to accept every stamp `date -u +%Y-%m-%dT%H:%M:%SZ` and
80
+ * `toISOString` produce, strict enough that a body carrying prose in the field
81
+ * falls back rather than handing a caller a value no date parser reads.
82
+ */
83
+ const ISO_8601 =
84
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/
85
+
86
+ /**
87
+ * The heading a body opens with, or undefined when it opens with anything else.
88
+ *
89
+ * Matched for equality on the first line alone. A prefix test also reaches
90
+ * `## Review response`, which belongs to the reply family and would scope a
91
+ * pass to whatever commit a worker's answer carried. The `\r` trim covers a
92
+ * body composed in the GitHub web editor, which stores CRLF.
93
+ */
94
+ function headingOf(body: string): ReviewHeading | undefined {
95
+ const first = (body.split('\n')[0] ?? '').replace(/\r$/, '')
96
+ return REVIEW_HEADINGS.find((heading) => heading === first)
97
+ }
98
+
99
+ /**
100
+ * The marker a body carries, or undefined when it carries none this reader
101
+ * trusts.
102
+ *
103
+ * The last non-empty line and no other. Searching the body for the last match
104
+ * instead reads a marker the body was displaying rather than claiming: a pass
105
+ * that shows the format on its own line inside a fenced block, and carries no
106
+ * marker of its own because it predates this shipping, would hand the next
107
+ * reader a covered commit taken from an illustration. That is the defect this
108
+ * module exists to close, arriving by another route and just as silently.
109
+ *
110
+ * Position costs nothing, since Step 4 of `review-pr` puts the marker on the
111
+ * last line of every body it writes, the `PUT` rewrite included. Trailing blank
112
+ * lines are skipped rather than read as an absent marker, which is the one
113
+ * thing the search was buying.
114
+ */
115
+ function markerOf(
116
+ body: string,
117
+ ): { commit: string; readAt: string } | undefined {
118
+ const lines = body.split('\n')
119
+
120
+ let index = lines.length - 1
121
+ while (index >= 0 && (lines[index] ?? '').trim() === '') index -= 1
122
+ if (index < 0) return undefined
123
+
124
+ const match = MARKER.exec((lines[index] ?? '').trim())
125
+ if (match === null) return undefined
126
+
127
+ const [, commit, readAt] = match
128
+ if (commit === undefined || readAt === undefined) return undefined
129
+ if (!ISO_8601.test(readAt)) return undefined
130
+
131
+ return { commit, readAt }
132
+ }
133
+
134
+ /**
135
+ * Resolves what the newest review pass covered.
136
+ *
137
+ * The newest pass rather than the newest marker: a thread whose latest pass
138
+ * predates this mechanism reads through the fallback, and reaching back to an
139
+ * older marked pass would report a commit a later pass has already moved past.
140
+ */
141
+ export function resolveReviewScope(listing: ReviewListing): ReviewScope {
142
+ let newest: { heading: ReviewHeading; row: ReviewRow } | undefined
143
+
144
+ for (const row of listing.reviews ?? []) {
145
+ const heading = headingOf(row.body ?? '')
146
+ if (heading === undefined) continue
147
+ newest = { heading, row }
148
+ }
149
+
150
+ if (newest === undefined) return { state: 'none', source: 'none' }
151
+
152
+ const { heading, row } = newest
153
+ const state: ReviewState = heading === '## Review' ? 'open' : 'closed'
154
+ const submittedAt = row.submittedAt ?? undefined
155
+ const stamped = submittedAt === undefined ? {} : { submittedAt }
156
+ const marker = markerOf(row.body ?? '')
157
+
158
+ if (marker !== undefined) {
159
+ return {
160
+ heading,
161
+ state,
162
+ commit: marker.commit,
163
+ readAt: marker.readAt,
164
+ ...stamped,
165
+ source: 'marker',
166
+ }
167
+ }
168
+
169
+ const oid = row.commit?.oid
170
+ return {
171
+ heading,
172
+ state,
173
+ ...(oid === undefined || oid === '' ? {} : { commit: oid }),
174
+ ...stamped,
175
+ source: 'fallback',
176
+ }
177
+ }
@@ -78,6 +78,14 @@ export interface CheckInput {
78
78
  * nothing to watch, both of which produce the same empty `escapes` list.
79
79
  */
80
80
  readonly escapesWatched?: boolean
81
+ /**
82
+ * Names of sessions the client's own registry carried both before and after
83
+ * the run, so present rather than dispatched by it. Undefined when the
84
+ * caller supplied none, which is not the same as a run that had no witness:
85
+ * the registry carries no contract, so a client that stops writing a record
86
+ * per session makes this list empty regardless of who else was live.
87
+ */
88
+ readonly concurrentSessions?: readonly string[]
81
89
  readonly envelope?: RunEnvelope
82
90
  }
83
91
 
@@ -386,11 +394,19 @@ function checkWriteScope(
386
394
  * empty `escapes` list. `watched` is what tells them apart: a run that had
387
395
  * nothing to watch reports unmeasured rather than passing on a diff it never
388
396
  * had the target to take.
397
+ *
398
+ * A witnessed concurrent session never softens the verdict. `concurrentSessions`
399
+ * is evidence a reader can act on without a second lookup, appended to the
400
+ * message on an unbounded escape, never a reason to pass or skip one: a session
401
+ * merely alive throughout the run proves someone else was busy, not that a
402
+ * given file is theirs, and a real dispatch escape reads identically to an
403
+ * innocent sibling's write either way.
389
404
  */
390
405
  function checkEscapeScope(
391
406
  expectation: Expectation,
392
407
  escapes: readonly string[] | undefined,
393
408
  watched: boolean | undefined,
409
+ concurrentSessions: readonly string[] | undefined,
394
410
  ): KindOutcome {
395
411
  if (expectation.escapeScope === undefined) return { results: [], skipped: [] }
396
412
 
@@ -416,12 +432,20 @@ function checkEscapeScope(
416
432
  }
417
433
 
418
434
  const globs = expectation.escapeScope.map((glob) => new Bun.Glob(glob))
435
+ const witnessCount = concurrentSessions?.length ?? 0
436
+ const witnessSuffix =
437
+ witnessCount > 0
438
+ ? ` (${witnessCount} session${witnessCount === 1 ? '' : 's'} live throughout the run)`
439
+ : ''
419
440
 
420
441
  return {
421
442
  results: escapes.map((path) =>
422
443
  globs.some((glob) => glob.match(path))
423
444
  ? { ok: true, message: `declared escape: ${path}` }
424
- : { ok: false, message: `unbounded escape: ${path}` },
445
+ : {
446
+ ok: false,
447
+ message: `unbounded escape: ${path}${witnessSuffix}`,
448
+ },
425
449
  ),
426
450
  skipped: [],
427
451
  }
@@ -514,6 +538,7 @@ export function checkExpectation(
514
538
  expectation,
515
539
  input.escapes,
516
540
  input.escapesWatched,
541
+ input.concurrentSessions,
517
542
  )
518
543
  const reply = checkReply(expectation, input.envelope)
519
544
  const envelope = checkEnvelope(expectation, input.envelope)