@erclx/canon 5.23.0 → 5.25.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 (98) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  3. package/claude/skills/backlog-triage/SKILL.md +19 -6
  4. package/claude/skills/canon-operator/REQUIREMENT.md +1 -0
  5. package/claude/skills/canon-operator/SKILL.md +5 -1
  6. package/claude/skills/canvas/REQUIREMENT.md +49 -0
  7. package/claude/skills/canvas/SKILL.md +106 -0
  8. package/claude/skills/design-extract/REQUIREMENT.md +1 -1
  9. package/claude/skills/draft-doc/REQUIREMENT.md +2 -1
  10. package/claude/skills/draft-doc/references/readme/agent-facing.md +59 -0
  11. package/claude/skills/draft-doc/references/readme/application.md +66 -0
  12. package/claude/skills/draft-doc/references/readme/cli.md +64 -0
  13. package/claude/skills/draft-doc/references/readme/library.md +57 -0
  14. package/claude/skills/draft-doc/references/readme/plugin.md +66 -0
  15. package/claude/skills/draft-doc/references/readme.md +33 -4
  16. package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
  17. package/claude/skills/draft-figure/SKILL.md +24 -51
  18. package/claude/skills/draft-figure/references/architecture-set.md +35 -0
  19. package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
  20. package/claude/skills/draft-figure/references/mermaid.md +55 -0
  21. package/claude/skills/git-followup/SKILL.md +17 -6
  22. package/claude/skills/git-pr/references/evidence.md +4 -2
  23. package/claude/skills/git-split/SKILL.md +1 -1
  24. package/claude/skills/index-lookup/SKILL.md +2 -2
  25. package/claude/skills/review-address/SKILL.md +6 -13
  26. package/claude/skills/review-address/references/rest-reads.md +43 -0
  27. package/claude/skills/review-craft/references/ui.md +7 -1
  28. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  29. package/claude/skills/review-pr/SKILL.md +9 -3
  30. package/claude/skills/review-pr/references/close-out.md +2 -2
  31. package/claude/skills/review-pr/references/post.md +17 -5
  32. package/claude/skills/review-pr/references/scope.md +10 -8
  33. package/claude/skills/review-ui/SKILL.md +12 -4
  34. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +30 -1
  35. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
  36. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
  37. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
  38. package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
  39. package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
  40. package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
  41. package/docs/agents/canvas.md +6 -2
  42. package/docs/agents/commands.md +45 -45
  43. package/docs/agents/context-audit-checks.md +2 -2
  44. package/docs/agents/index.md +0 -1
  45. package/docs/agents/key-changes.md +3 -3
  46. package/docs/agents/pr-evidence.md +11 -2
  47. package/docs/agents/pr-reads.md +5 -4
  48. package/docs/target/projects.md +4 -4
  49. package/docs/workflow/ai-workflow.md +0 -1
  50. package/docs/workflow/skill-map.md +3 -3
  51. package/docs/workflow/visual-design-workflow.md +2 -1
  52. package/governance/renames.toml +0 -4
  53. package/governance/rules/standards/markdown.md +1 -0
  54. package/package.json +1 -2
  55. package/scripts/core/install-check.sh +1 -1
  56. package/src/audits/catalog.ts +2 -2
  57. package/src/canvas/client/inspector/field.tsx +200 -0
  58. package/src/canvas/client/inspector/fields.css +151 -0
  59. package/src/canvas/client/inspector/section.tsx +27 -0
  60. package/src/canvas/client/inspector/values.ts +60 -0
  61. package/src/canvas/client/inspector.tsx +192 -111
  62. package/src/canvas/client/shell.css +0 -94
  63. package/src/canvas/tokens.ts +9 -1
  64. package/src/claude/cases/misc.ts +5 -0
  65. package/src/claude/cases/workflow.ts +1 -1
  66. package/src/claude/seeds.ts +0 -1
  67. package/src/commands/canvas.ts +29 -0
  68. package/src/commands/design.ts +2 -64
  69. package/src/commands/pr.ts +219 -202
  70. package/src/context/audit.ts +7 -6
  71. package/src/design/budget.ts +149 -0
  72. package/src/design/document.ts +22 -10
  73. package/src/design/render.ts +11 -1
  74. package/src/design/tokens.ts +40 -61
  75. package/src/gate/measures.ts +49 -0
  76. package/src/gate/stages.ts +5 -1
  77. package/src/help.ts +2 -2
  78. package/src/migrate/skill-names.ts +7 -1
  79. package/src/pr/evidence.ts +25 -5
  80. package/src/pr/rest.ts +137 -0
  81. package/standards/canvas.md +112 -0
  82. package/standards/context.md +2 -1
  83. package/standards/design.md +13 -3
  84. package/standards/figures.md +0 -1
  85. package/standards/index.md +1 -1
  86. package/standards/markdown.md +1 -0
  87. package/standards/mermaid.md +1 -7
  88. package/tooling/claude/reference.md +0 -5
  89. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
  90. package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
  91. package/claude/skills/draft-diagram/SKILL.md +0 -177
  92. package/docs/agents/design-board.md +0 -33
  93. package/governance/rules/standards/diagrams.md +0 -14
  94. package/governance/rules/standards/mermaid.md +0 -15
  95. package/scripts/core/check-gallery-exclusion.sh +0 -50
  96. package/src/design/board.ts +0 -576
  97. package/standards/diagrams.md +0 -132
  98. package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
@@ -70,7 +70,18 @@ import {
70
70
  type RunRow,
71
71
  type WorkflowFile,
72
72
  } from '@/pr/preview'
73
- import { type ReviewListing, resolveReviewScope } from '@/pr/review-scope'
73
+ import {
74
+ commentRowOf,
75
+ identityOf,
76
+ type PullIdentity,
77
+ parseJsonLines,
78
+ type RestComment,
79
+ type RestPull,
80
+ type RestReview,
81
+ reviewRowOf,
82
+ selectBranchPull,
83
+ } from '@/pr/rest'
84
+ import { resolveReviewScope } from '@/pr/review-scope'
74
85
  import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
75
86
 
76
87
  const GH_TIMEOUT_MS = 30_000
@@ -78,17 +89,6 @@ const GH_TIMEOUT_MS = 30_000
78
89
  /** How many unnamed files the frame prints before it names a count instead. */
79
90
  const UNNAMED_PRINT_LIMIT = 10
80
91
 
81
- /**
82
- * Where `gh pr view --json files` stops.
83
- *
84
- * It pages the underlying query once and returns at most this many rows with
85
- * nothing on the record saying so, which was measured against `#1250`: the
86
- * pull request carries 101 files and the view reports 100. A set silently one
87
- * short is the worst input this comparison can take, since the missing file is
88
- * exactly what a claim would then be accused of inventing.
89
- */
90
- const GH_VIEW_FILE_CAP = 100
91
-
92
92
  interface KeyChangesOptions {
93
93
  readonly body?: string
94
94
  readonly base?: string
@@ -113,6 +113,10 @@ interface LocalOptions extends ReadOptions {
113
113
  readonly note?: string
114
114
  }
115
115
 
116
+ /** Shared by every verb that resolves a pull request from the checkout's branch. */
117
+ const AMBIGUOUS_PULL =
118
+ 'More than one pull request is open on this branch, each against another base. Name the pull request number.'
119
+
116
120
  /** What replaces the local address line once the pull request closes. */
117
121
  const LOCAL_REMOVED_NOTE =
118
122
  '_Local preview removed when the pull request closed._'
@@ -226,6 +230,7 @@ const PREVIEW_HEAD_NOTES: Record<PreviewHeadReason, string> = {
226
230
  type PullRefusal =
227
231
  | 'gh-missing'
228
232
  | 'gh-failed'
233
+ | 'ambiguous-pull'
229
234
  | 'no-branch'
230
235
  | 'runs-unreadable'
231
236
  | 'reviews-unreadable'
@@ -236,6 +241,7 @@ const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
236
241
  'gh is not on the path, so no pull request could be resolved. Name the branch through a checkout that carries one.',
237
242
  'gh-failed':
238
243
  'gh could not answer for this branch. Name the pull request number instead.',
244
+ 'ambiguous-pull': AMBIGUOUS_PULL,
239
245
  'no-branch':
240
246
  'The pull request carries no head branch name, so no ref could be read for it.',
241
247
  'unresolvable-ref':
@@ -282,7 +288,7 @@ const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
282
288
  type SourceRefusal =
283
289
  | 'gh-missing'
284
290
  | 'gh-failed'
285
- | 'gh-truncated'
291
+ | 'ambiguous-pull'
286
292
  | 'unreadable-body'
287
293
  | 'unreadable-tree'
288
294
  | 'no-base'
@@ -297,7 +303,7 @@ const REFUSALS: Record<Refusal, string> = {
297
303
  'gh is not on the path, so no pull request body could be read. Pass --body <path> to read one off disk instead.',
298
304
  'gh-failed':
299
305
  'gh could not answer for this branch. Name the pull request number, or pass --body <path>.',
300
- 'gh-truncated': `gh returned the first ${GH_VIEW_FILE_CAP} changed files and the paginated read that would complete the set failed, so a claim could be accused of naming a file this read never saw.`,
306
+ 'ambiguous-pull': AMBIGUOUS_PULL,
301
307
  'unreadable-body': 'The file named by --body could not be read.',
302
308
  'unreadable-tree':
303
309
  'git could not list this repository, so no path could be judged whole rather than partial.',
@@ -817,11 +823,11 @@ type SourceRead =
817
823
  * Reads the body and the changed set from the pull request the caller named,
818
824
  * or from the one open on this branch.
819
825
  *
820
- * One call wherever the file list fits inside it. The body and the file list
821
- * have to describe the same head, and reading them separately leaves a window
822
- * where a push between them compares a body against another commit's files. A
823
- * pull request at the view's cap takes the second read anyway, because a set
824
- * short by an unknown number is worse than a set read a moment later.
826
+ * The body comes off the pull read and the files off the paginated files
827
+ * endpoint, since GraphQL is the only route that returns both in one call and
828
+ * a cloud session's proxy refuses it. A push between the two reads can compare
829
+ * a body against the next commit's files, which the paginated read already
830
+ * risked for any pull request past the old view's cap of 100 files.
825
831
  */
826
832
  async function readFromApi(
827
833
  cwd: string,
@@ -831,63 +837,31 @@ async function readFromApi(
831
837
  return { kind: 'refused', reason: 'gh-missing' }
832
838
  }
833
839
 
834
- const args = ['pr', 'view']
835
- if (number !== undefined) args.push(number)
836
- args.push('--json', 'body,files,headRefOid,number')
837
-
838
- try {
839
- // See src/worktrees/reclaim.ts for why gh needs the stripped environment:
840
- // it resolves its repository through the same variables git does and they
841
- // beat `cwd`, so a run from inside a hook would answer for another
842
- // repository and compare this branch's claims against its files.
843
- const result = await execa('gh', args, {
844
- cwd,
845
- timeout: GH_TIMEOUT_MS,
846
- env: gitEnv(),
847
- extendEnv: false,
848
- })
840
+ const pull = await readPull(cwd, number)
841
+ if (pull.kind === 'refused') return pull
849
842
 
850
- const row = JSON.parse(result.stdout) as {
851
- body?: string
852
- files?: readonly { path: string }[]
853
- headRefOid?: string
854
- number?: number
855
- }
843
+ const { row } = pull
844
+ if (row.number === undefined) return { kind: 'refused', reason: 'gh-failed' }
856
845
 
857
- const viewed = (row.files ?? []).map((file) => file.path)
858
- const changed =
859
- viewed.length < GH_VIEW_FILE_CAP || row.number === undefined
860
- ? viewed
861
- : await listFilesByPage(cwd, row.number)
846
+ const changed = await listFilesByPage(cwd, row.number)
847
+ if (changed === undefined) return { kind: 'refused', reason: 'gh-failed' }
862
848
 
863
- if (changed === undefined) {
864
- return { kind: 'refused', reason: 'gh-truncated' }
865
- }
849
+ const body = row.body ?? ''
850
+ const head = row.head?.sha
851
+ const sorted = [...changed].sort()
852
+ const evidence = await resolveApiEvidence(cwd, body, sorted, head, row.number)
866
853
 
867
- const body = row.body ?? ''
868
- const sorted = [...changed].sort()
869
- const evidence = await resolveApiEvidence(
870
- cwd,
854
+ return {
855
+ kind: 'read',
856
+ source: {
871
857
  body,
872
- sorted,
873
- row.headRefOid,
874
- row.number,
875
- )
876
-
877
- return {
878
- kind: 'read',
879
- source: {
880
- body,
881
- changed: sorted,
882
- head: row.headRefOid,
883
- number: row.number,
884
- renames: evidence.renames,
885
- ignoreAdditions: evidence.ignoreAdditions,
886
- evidenceUnread: evidence.unread,
887
- },
888
- }
889
- } catch {
890
- return { kind: 'refused', reason: 'gh-failed' }
858
+ changed: sorted,
859
+ head,
860
+ number: row.number,
861
+ renames: evidence.renames,
862
+ ignoreAdditions: evidence.ignoreAdditions,
863
+ evidenceUnread: evidence.unread,
864
+ },
891
865
  }
892
866
  }
893
867
 
@@ -896,9 +870,8 @@ async function readFromApi(
896
870
  * first pass with neither already reports an unmet claim.
897
871
  *
898
872
  * The probe pays for a repository listing this read takes again a moment
899
- * later in `runKeyChanges`, and the `gh api …/files` call besides it only on
900
- * the pass that already has something to double-check, the same trade the
901
- * `gh-truncated` pagination fallback makes.
873
+ * later in `runKeyChanges`, and the full-row `gh api …/files` call besides it
874
+ * only on the pass that already has something to double-check.
902
875
  *
903
876
  * `unread` separates a read that failed from one that succeeded and found
904
877
  * neither, which an empty `renames`/`ignoreAdditions` cannot do on its own.
@@ -983,11 +956,9 @@ async function resolveApiEvidence(
983
956
  /**
984
957
  * Every file a pull request changed, read through the paginated endpoint.
985
958
  *
986
- * Only reached when the view came back at the cap, since it costs a request per
987
- * page and nearly every pull request here fits in one view. Returns undefined
988
- * when the follow-up fails, which refuses rather than falling back to the
989
- * capped set: a comparison run against a set known to be short would accuse a
990
- * correct bullet of naming a file nobody changed.
959
+ * Returns undefined when any page fails, which refuses rather than comparing
960
+ * the pages that arrived: a comparison run against a set known to be short
961
+ * would accuse a correct bullet of naming a file nobody changed.
991
962
  */
992
963
  async function listFilesByPage(
993
964
  cwd: string,
@@ -1182,14 +1153,6 @@ async function runKeyChanges(
1182
1153
  return report.unmet.length === 0 ? 0 : 2
1183
1154
  }
1184
1155
 
1185
- /** The head branch and reported head of the pull request a caller named. */
1186
- interface PullIdentity {
1187
- readonly number: number | undefined
1188
- readonly branch: string
1189
- readonly head?: string
1190
- readonly mergeState?: string
1191
- }
1192
-
1193
1156
  type IdentityRead =
1194
1157
  | { readonly kind: 'read'; readonly identity: PullIdentity }
1195
1158
  | { readonly kind: 'refused'; readonly reason: PullRefusal }
@@ -1218,54 +1181,152 @@ async function gh(
1218
1181
  }
1219
1182
  }
1220
1183
 
1184
+ type PullNumber =
1185
+ | { readonly kind: 'found'; readonly number: string }
1186
+ | {
1187
+ readonly kind: 'refused'
1188
+ readonly reason: 'gh-failed' | 'ambiguous-pull'
1189
+ }
1190
+
1221
1191
  /**
1222
- * Reads the head branch and the head the pull request object reports.
1192
+ * The number the caller named, or the one open pull request on the branch the
1193
+ * checkout at `cwd` holds.
1223
1194
  *
1224
- * Both come from one call, so the branch a ref is read for and the head that
1225
- * ref is compared against describe the same object.
1195
+ * `{owner}` in the head filter is the base repository's owner, which is the
1196
+ * head owner for every pull request opened from a branch of this repository.
1226
1197
  */
1227
- async function readIdentity(
1198
+ async function resolvePullNumber(
1228
1199
  cwd: string,
1229
1200
  number: string | undefined,
1230
- ): Promise<IdentityRead> {
1231
- if (Bun.which('gh') === null) {
1232
- return { kind: 'refused', reason: 'gh-missing' }
1233
- }
1201
+ ): Promise<PullNumber> {
1202
+ if (number !== undefined) return { kind: 'found', number }
1234
1203
 
1235
- const args = ['pr', 'view']
1236
- if (number !== undefined) args.push(number)
1237
- args.push('--json', 'number,headRefName,headRefOid,mergeStateStatus')
1204
+ const branch = await $`git -C ${cwd} symbolic-ref --short -q HEAD`
1205
+ .env(gitEnv())
1206
+ .quiet()
1207
+ .nothrow()
1208
+ const name = branch.exitCode === 0 ? branch.text().trim() : ''
1209
+ if (name === '') return { kind: 'refused', reason: 'gh-failed' }
1238
1210
 
1239
- const stdout = await gh(cwd, args)
1211
+ const stdout = await gh(cwd, [
1212
+ 'api',
1213
+ `repos/{owner}/{repo}/pulls?head={owner}:${encodeURIComponent(name)}&state=open&per_page=100`,
1214
+ ])
1240
1215
  if (stdout === null) return { kind: 'refused', reason: 'gh-failed' }
1241
1216
 
1242
- let row: {
1243
- number?: number
1244
- headRefName?: string
1245
- headRefOid?: string
1246
- mergeStateStatus?: string
1247
- }
1217
+ let rows: readonly RestPull[]
1248
1218
  try {
1249
- row = JSON.parse(stdout)
1219
+ rows = JSON.parse(stdout)
1250
1220
  } catch {
1251
1221
  return { kind: 'refused', reason: 'gh-failed' }
1252
1222
  }
1223
+ const selected = selectBranchPull(rows)
1224
+ return selected.kind === 'found'
1225
+ ? { kind: 'found', number: String(selected.number) }
1226
+ : selected
1227
+ }
1228
+
1229
+ type PullRead =
1230
+ | { readonly kind: 'read'; readonly row: RestPull }
1231
+ | {
1232
+ readonly kind: 'refused'
1233
+ readonly reason: 'gh-failed' | 'ambiguous-pull'
1234
+ }
1253
1235
 
1254
- if (row.headRefName === undefined || row.headRefName === '') {
1255
- return { kind: 'refused', reason: 'no-branch' }
1236
+ /**
1237
+ * The pull request object off the REST pull endpoint, which a cloud session's
1238
+ * proxy serves where it refuses the GraphQL every `gh pr view` runs on.
1239
+ */
1240
+ async function readPull(
1241
+ cwd: string,
1242
+ number: string | undefined,
1243
+ ): Promise<PullRead> {
1244
+ const resolved = await resolvePullNumber(cwd, number)
1245
+ if (resolved.kind === 'refused') return resolved
1246
+
1247
+ const stdout = await gh(cwd, [
1248
+ 'api',
1249
+ `repos/{owner}/{repo}/pulls/${resolved.number}`,
1250
+ ])
1251
+ if (stdout === null) return { kind: 'refused', reason: 'gh-failed' }
1252
+ try {
1253
+ return { kind: 'read', row: JSON.parse(stdout) as RestPull }
1254
+ } catch {
1255
+ return { kind: 'refused', reason: 'gh-failed' }
1256
1256
  }
1257
+ }
1257
1258
 
1258
- return {
1259
- kind: 'read',
1260
- identity: {
1261
- number: row.number,
1262
- branch: row.headRefName,
1263
- ...(row.headRefOid !== undefined && { head: row.headRefOid }),
1264
- ...(row.mergeStateStatus !== undefined && {
1265
- mergeState: row.mergeStateStatus,
1266
- }),
1267
- },
1259
+ type PagesRead<T> =
1260
+ | { readonly kind: 'read'; readonly rows: readonly T[] }
1261
+ | { readonly kind: 'refused'; readonly reason: 'gh-failed' | 'unparsed' }
1262
+
1263
+ /**
1264
+ * Every row of a paginated listing, or a refusal when any page failed.
1265
+ *
1266
+ * A refusal rather than an empty list, since a refused read that reads as no
1267
+ * reviews routes the next pass to the whole change.
1268
+ */
1269
+ async function listPages<T>(
1270
+ cwd: string,
1271
+ path: string,
1272
+ fields: string,
1273
+ ): Promise<PagesRead<T>> {
1274
+ const stdout = await gh(cwd, [
1275
+ 'api',
1276
+ '--paginate',
1277
+ `${path}?per_page=100`,
1278
+ '--jq',
1279
+ `.[] | {${fields}} | @json`,
1280
+ ])
1281
+ if (stdout === null) return { kind: 'refused', reason: 'gh-failed' }
1282
+ const rows = parseJsonLines<T>(stdout)
1283
+ return rows === undefined
1284
+ ? { kind: 'refused', reason: 'unparsed' }
1285
+ : { kind: 'read', rows }
1286
+ }
1287
+
1288
+ async function listReviews(cwd: string, number: string) {
1289
+ return listPages<RestReview>(
1290
+ cwd,
1291
+ `repos/{owner}/{repo}/pulls/${number}/reviews`,
1292
+ 'body, commit_id, submitted_at',
1293
+ )
1294
+ }
1295
+
1296
+ /** The issue comments on a pull request, or undefined when the thread is unread. */
1297
+ async function listComments(
1298
+ cwd: string,
1299
+ number: string,
1300
+ ): Promise<readonly { url?: string; body: string }[] | undefined> {
1301
+ const read = await listPages<RestComment>(
1302
+ cwd,
1303
+ `repos/{owner}/{repo}/issues/${number}/comments`,
1304
+ 'html_url, body',
1305
+ )
1306
+ return read.kind === 'read' ? read.rows.map(commentRowOf) : undefined
1307
+ }
1308
+
1309
+ /**
1310
+ * Reads the head branch and the head the pull request object reports.
1311
+ *
1312
+ * Both come from one call, so the branch a ref is read for and the head that
1313
+ * ref is compared against describe the same object.
1314
+ */
1315
+ async function readIdentity(
1316
+ cwd: string,
1317
+ number: string | undefined,
1318
+ ): Promise<IdentityRead> {
1319
+ if (Bun.which('gh') === null) {
1320
+ return { kind: 'refused', reason: 'gh-missing' }
1268
1321
  }
1322
+
1323
+ const pull = await readPull(cwd, number)
1324
+ if (pull.kind === 'refused') return pull
1325
+
1326
+ const identity: PullIdentity = identityOf(pull.row)
1327
+ if (identity.branch === '') return { kind: 'refused', reason: 'no-branch' }
1328
+
1329
+ return { kind: 'read', identity }
1269
1330
  }
1270
1331
 
1271
1332
  /**
@@ -1476,39 +1537,32 @@ async function runReviewState(
1476
1537
  return refuseWith('gh-missing', PULL_REFUSALS['gh-missing'], emitJson, root)
1477
1538
  }
1478
1539
 
1479
- const args = ['pr', 'view']
1480
- if (number !== undefined) args.push(number)
1481
- // `number` rides along so the record names the pull request a caller that
1482
- // passed no argument was answered about. The comment families the poll reads
1483
- // stay out of the query, since nothing here parses one and a listing the
1484
- // caller discards is a payload paid for twice.
1485
- args.push('--json', 'number,reviews')
1486
-
1487
- const stdout = await gh(root, args)
1488
- if (stdout === null) {
1489
- return refuseWith('gh-failed', PULL_REFUSALS['gh-failed'], emitJson, root)
1490
- }
1491
-
1492
- let listing: ReviewListing & { number?: number }
1493
- try {
1494
- listing = JSON.parse(stdout)
1495
- } catch {
1540
+ // The number is resolved up front so the record names the pull request a
1541
+ // caller that passed no argument was answered about.
1542
+ const resolved = await resolvePullNumber(root, number)
1543
+ if (resolved.kind === 'refused') {
1496
1544
  return refuseWith(
1497
- 'reviews-unreadable',
1498
- PULL_REFUSALS['reviews-unreadable'],
1545
+ resolved.reason,
1546
+ PULL_REFUSALS[resolved.reason],
1499
1547
  emitJson,
1500
1548
  root,
1501
1549
  )
1502
1550
  }
1503
1551
 
1504
- const scope = resolveReviewScope(listing)
1552
+ const reviews = await listReviews(root, resolved.number)
1553
+ if (reviews.kind === 'refused') {
1554
+ const reason =
1555
+ reviews.reason === 'gh-failed' ? 'gh-failed' : 'reviews-unreadable'
1556
+ return refuseWith(reason, PULL_REFUSALS[reason], emitJson, root)
1557
+ }
1558
+
1559
+ const listing = { number: Number(resolved.number) }
1560
+ const scope = resolveReviewScope({
1561
+ reviews: reviews.rows.map(reviewRowOf),
1562
+ })
1505
1563
 
1506
1564
  logStep('Scope')
1507
- logInfo(
1508
- listing.number === undefined
1509
- ? 'the pull request on this branch'
1510
- : `#${listing.number}`,
1511
- )
1565
+ logInfo(`#${listing.number}`)
1512
1566
 
1513
1567
  if (scope.source === 'none') {
1514
1568
  logStep('First pass')
@@ -1534,7 +1588,7 @@ async function runReviewState(
1534
1588
  process.stdout.write(
1535
1589
  `${JSON.stringify({
1536
1590
  root,
1537
- ...(listing.number !== undefined && { number: listing.number }),
1591
+ number: listing.number,
1538
1592
  ...scope,
1539
1593
  })}\n`,
1540
1594
  )
@@ -1581,22 +1635,13 @@ async function listPullFiles(
1581
1635
  */
1582
1636
  async function readPullMergeBase(
1583
1637
  cwd: string,
1584
- number: number,
1638
+ baseRef: string | undefined,
1585
1639
  head: string,
1586
1640
  ): Promise<string | undefined> {
1587
- const baseRef = await gh(cwd, [
1588
- 'pr',
1589
- 'view',
1590
- String(number),
1591
- '--json',
1592
- 'baseRefName',
1593
- '--jq',
1594
- '.baseRefName',
1595
- ])
1596
- if (baseRef === null || baseRef.trim() === '') return undefined
1641
+ if (baseRef === undefined || baseRef === '') return undefined
1597
1642
  const sha = await gh(cwd, [
1598
1643
  'api',
1599
- `repos/{owner}/{repo}/compare/${baseRef.trim()}...${head}`,
1644
+ `repos/{owner}/{repo}/compare/${baseRef}...${head}`,
1600
1645
  '--jq',
1601
1646
  '.merge_base_commit.sha',
1602
1647
  ])
@@ -1672,9 +1717,7 @@ async function runEvidence(
1672
1717
  // a failed one cannot hold a draft lift for a reason the check never had.
1673
1718
  const [pullFiles, base] = await Promise.all([
1674
1719
  listPullFiles(root, identity.number),
1675
- isCheck
1676
- ? undefined
1677
- : readPullMergeBase(root, identity.number, identity.head),
1720
+ isCheck ? undefined : readPullMergeBase(root, identity.base, identity.head),
1678
1721
  ])
1679
1722
  if (pullFiles === undefined) {
1680
1723
  return refuseWith(
@@ -1695,30 +1738,12 @@ async function runEvidence(
1695
1738
  let carriedPreview: string | undefined
1696
1739
  let carriedChecklist: string | undefined
1697
1740
  let carriedLocal: string | undefined
1698
- const commentsRow = await gh(root, [
1699
- 'pr',
1700
- 'view',
1701
- String(identity.number),
1702
- '--json',
1703
- 'comments',
1704
- ])
1705
1741
  // An unread thread refuses rather than rendering, since a body built
1706
1742
  // without it knows neither the comment to edit nor the preview address
1707
1743
  // to carry, and posting it would duplicate the comment and drop the link.
1708
1744
  // It refuses ahead of no-evidence too, so that record never reports an
1709
1745
  // absent field it did not read.
1710
- let comments: readonly { url?: string; body: string }[] | undefined
1711
- if (commentsRow !== null) {
1712
- try {
1713
- comments = (
1714
- JSON.parse(commentsRow) as {
1715
- comments?: readonly { url?: string; body: string }[]
1716
- }
1717
- ).comments
1718
- } catch {
1719
- comments = undefined
1720
- }
1721
- }
1746
+ const comments = await listComments(root, String(identity.number))
1722
1747
  if (comments === undefined) {
1723
1748
  return refuseWith(
1724
1749
  'gh-failed',
@@ -1803,12 +1828,7 @@ async function runEvidence(
1803
1828
  return 0
1804
1829
  }
1805
1830
 
1806
- const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1807
- const repo =
1808
- repoRow === null
1809
- ? undefined
1810
- : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1811
-
1831
+ const repo = await readRepoName(root)
1812
1832
  if (repo === undefined) {
1813
1833
  return refuseWith(
1814
1834
  'gh-failed',
@@ -2278,23 +2298,19 @@ async function runLocalRemove(
2278
2298
  return refuseWith('gh-missing', PULL_REFUSALS['gh-missing'], emitJson, root)
2279
2299
  }
2280
2300
 
2281
- const args = ['pr', 'view']
2282
- if (number !== undefined) args.push(number)
2283
- args.push('--json', 'comments')
2284
- const row = await gh(root, args)
2285
- let comments: readonly { url?: string; body: string }[] | undefined
2286
- try {
2287
- comments =
2288
- row === null
2289
- ? undefined
2290
- : (
2291
- JSON.parse(row) as {
2292
- comments?: readonly { url?: string; body: string }[]
2293
- }
2294
- ).comments
2295
- } catch {
2296
- comments = undefined
2301
+ const resolved = await resolvePullNumber(root, number)
2302
+ if (resolved.kind === 'refused') {
2303
+ const reason = resolved.reason
2304
+ return refuseWith(
2305
+ reason,
2306
+ reason === 'ambiguous-pull'
2307
+ ? PULL_REFUSALS[reason]
2308
+ : LOCAL_REFUSALS[reason],
2309
+ emitJson,
2310
+ root,
2311
+ )
2297
2312
  }
2313
+ const comments = await listComments(root, resolved.number)
2298
2314
  if (comments === undefined) {
2299
2315
  return refuseWith('gh-failed', LOCAL_REFUSALS['gh-failed'], emitJson, root)
2300
2316
  }
@@ -2531,11 +2547,12 @@ function framesArgumentRefusal(
2531
2547
  return undefined
2532
2548
  }
2533
2549
 
2550
+ /** `owner/repo` off the REST repository read, since `gh repo view` runs on GraphQL. */
2534
2551
  async function readRepoName(root: string): Promise<string | undefined> {
2535
- const row = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
2552
+ const row = await gh(root, ['api', 'repos/{owner}/{repo}'])
2536
2553
  if (row === null) return undefined
2537
2554
  try {
2538
- const name = (JSON.parse(row) as { nameWithOwner?: unknown }).nameWithOwner
2555
+ const name = (JSON.parse(row) as { full_name?: unknown }).full_name
2539
2556
  return typeof name === 'string' ? name : undefined
2540
2557
  } catch {
2541
2558
  return undefined
@@ -18,9 +18,9 @@ import { isStubSeed } from '@/seed-marker'
18
18
  * Every reason `canon context audit` refuses for.
19
19
  *
20
20
  * `no-folders` is the one ordinary absence: a project that never adopted
21
- * `canon/context/`, `.canon/diagrams/`, or `canon/wireframes/` names no
22
- * corpus this audit can measure, the same state `no-skills` reads for the
23
- * skill corpora. The other four are a malformed invocation or a checkout git
21
+ * `canon/context/` or `canon/wireframes/`, and holds no `.canon/diagrams/`
22
+ * folder an older install left, names no corpus this audit can measure, the
23
+ * same state `no-skills` reads for the skill corpora. The other four are a malformed invocation or a checkout git
24
24
  * cannot read, which stay a break rather than an absence.
25
25
  */
26
26
  export type ContextAuditRefusal =
@@ -157,9 +157,10 @@ const SENTENCE_END = /[.!?:;]\s(?=[^.!?:;]*$)/
157
157
  * JSON record publishes as `checkpoints.provenanceFolder` breaks every consumer
158
158
  * reading that field for a gain of one word.
159
159
  *
160
- * `standards/context.md` opens its scope by handing diagrams and wireframes to
161
- * `diagrams.md` and `wireframes.md`, so a marker reported in either would cite
162
- * a rule that entry's own standard routes elsewhere. The length and table
160
+ * `standards/context.md` opens its scope by handing figures to the
161
+ * `draft-figure` skill and wireframes to `wireframes.md`, so a marker reported
162
+ * in a diagrams or wireframes entry would cite a rule that entry's own
163
+ * standard routes elsewhere. The length and table
163
164
  * checkpoints are quoted from the same standard and keep reaching every audited
164
165
  * folder, because a threshold on how far a reader travels generalizes across
165
166
  * entry types while a rule about what an entry may say does not.