@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/draft-and-pick/REQUIREMENT.md +1 -1
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/{canon-screencast → draft-screencast}/REQUIREMENT.md +4 -4
- package/claude/skills/{canon-screencast → draft-screencast}/SKILL.md +4 -4
- package/claude/skills/{canon-slides-draft → draft-slides}/REQUIREMENT.md +3 -3
- package/claude/skills/{canon-slides-draft → draft-slides}/SKILL.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/REQUIREMENT.md +2 -2
- package/claude/skills/{canon-frames-read → read-frames}/SKILL.md +3 -3
- package/claude/skills/{canon-record → record-screencast}/REQUIREMENT.md +5 -5
- package/claude/skills/{canon-record → record-screencast}/SKILL.md +4 -4
- package/claude/skills/review-pr/SKILL.md +55 -7
- package/claude/skills/role-orchestrator/SKILL.md +2 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +7 -3
- package/claude/skills/role-orchestrator/scripts/poll.sh +79 -34
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/docs/agents/commands.md +3 -0
- package/docs/agents/demo.md +3 -3
- package/docs/agents/index.md +1 -1
- package/docs/agents/pr-reads.md +47 -12
- package/docs/agents/sandbox.md +13 -10
- package/docs/agents/tasks.md +46 -3
- package/docs/workflow/ai-workflow.md +19 -19
- package/package.json +3 -2
- package/scripts/core/regen-web-previews.ts +94 -0
- package/scripts/lib/sandbox-dispatch.sh +8 -0
- package/src/claude/cases/workflow.ts +4 -4
- package/src/claude/plugin-update.ts +48 -0
- package/src/commands/claude.ts +281 -1
- package/src/commands/demo.ts +1 -1
- package/src/commands/feedback.ts +15 -5
- package/src/commands/gate.ts +3 -1
- package/src/commands/pr.ts +130 -1
- package/src/commands/sandbox.ts +13 -4
- package/src/commands/tasks.ts +178 -1
- package/src/demo/beats.ts +1 -1
- package/src/design/components.ts +12 -0
- package/src/gate/measures.ts +47 -1
- package/src/migrate/skill-names.ts +15 -1
- package/src/pr/review-scope.ts +177 -0
- package/src/sandbox/expect.ts +26 -1
- package/src/tasks/archive.ts +206 -3
- package/src/tasks/label.ts +14 -6
- package/src/tasks/validate.ts +22 -0
- package/src/teach/nav.ts +97 -3
- package/standards/glossary.md +8 -0
- package/standards/plan.md +1 -1
- package/standards/tasks.md +15 -1
package/src/commands/tasks.ts
CHANGED
|
@@ -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 `
|
|
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
|
*/
|
package/src/design/components.ts
CHANGED
|
@@ -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
|
package/src/gate/measures.ts
CHANGED
|
@@ -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
|
-
`${
|
|
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
|
+
}
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -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
|
-
: {
|
|
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)
|