@erclx/canon 5.75.0 → 5.77.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 (41) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
  4. package/claude/skills/ci-workflow/SKILL.md +3 -0
  5. package/claude/skills/design-identity/REQUIREMENT.md +1 -1
  6. package/claude/skills/design-identity/SKILL.md +13 -4
  7. package/claude/skills/design-identity/references/card-route.md +11 -4
  8. package/claude/skills/git-issue/REQUIREMENT.md +4 -2
  9. package/claude/skills/git-issue/SKILL.md +32 -3
  10. package/claude/skills/git-issue/references/issue.md +7 -0
  11. package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
  12. package/claude/skills/issue-triage/SKILL.md +130 -0
  13. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  14. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  15. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
  16. package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
  17. package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
  18. package/docs/agents/migrate.md +10 -10
  19. package/docs/agents/targets.md +3 -1
  20. package/docs/migrations/by-verb.md +8 -4
  21. package/docs/workflow/ai-workflow.md +1 -1
  22. package/docs/workflow/skill-map.md +1 -0
  23. package/package.json +2 -2
  24. package/src/capture/render.ts +30 -5
  25. package/src/claude/cases/git.ts +5 -0
  26. package/src/claude/cases/workflow.ts +4 -0
  27. package/src/commands/capture.ts +38 -1
  28. package/src/commands/claude.ts +4 -0
  29. package/src/commands/targets.ts +11 -1
  30. package/src/markdown/scope.ts +2 -0
  31. package/src/migrate/session-defaults.ts +36 -8
  32. package/src/targets/registry.ts +99 -14
  33. package/src/targets/resolve.ts +3 -1
  34. package/tooling/astro/configs/card.config.mjs +4 -0
  35. package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
  36. package/tooling/astro/reference.md +2 -1
  37. package/tooling/claude/seeds/.claude/settings.json +4 -0
  38. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
  39. package/tooling/web/configs/.github/workflows/verify.yml +5 -2
  40. package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
  41. package/tooling/web/reference.md +5 -0
@@ -2,10 +2,10 @@
2
2
  //
3
3
  // Run as `bun poll.ts`. The pure pieces are exported so a test drives the code
4
4
  // a loop runs, and `main` runs only when this file is the entry point.
5
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
6
  import { join } from 'node:path'
7
7
 
8
- import { baseBranch, baseRef, mainRoot, run } from './repo'
8
+ import { baseBranch, baseRef, mainRoot, mapPool, run, runAsync } from './repo'
9
9
 
10
10
  interface Comment {
11
11
  body?: null | string
@@ -499,11 +499,11 @@ const parseJson = (text: string): unknown => {
499
499
  * line that does not parse both return null, so neither reads as a thread with
500
500
  * nothing on it.
501
501
  */
502
- const readRows = (
502
+ const readRows = async (
503
503
  path: string,
504
504
  fields: string,
505
- ): null | Record<string, unknown>[] => {
506
- const read = run('gh', [
505
+ ): Promise<null | Record<string, unknown>[]> => {
506
+ const read = await runAsync('gh', [
507
507
  'api',
508
508
  '--paginate',
509
509
  `repos/{owner}/{repo}/${path}`,
@@ -546,15 +546,14 @@ export const toComment = (row: Record<string, unknown>): Comment => ({
546
546
  * Two reads feed one payload, so either failing makes the pull request
547
547
  * unreadable rather than a thread missing half its rows.
548
548
  */
549
- const readPayload = (number: string): null | Payload => {
550
- const reviews = readRows(
551
- `pulls/${number}/reviews?per_page=100`,
552
- 'body, commit_id, submitted_at',
553
- )
554
- const comments = readRows(
555
- `issues/${number}/comments?per_page=100`,
556
- 'body, created_at',
557
- )
549
+ const readPayload = async (number: string): Promise<null | Payload> => {
550
+ const [reviews, comments] = await Promise.all([
551
+ readRows(
552
+ `pulls/${number}/reviews?per_page=100`,
553
+ 'body, commit_id, submitted_at',
554
+ ),
555
+ readRows(`issues/${number}/comments?per_page=100`, 'body, created_at'),
556
+ ])
558
557
  if (reviews === null || comments === null) return null
559
558
 
560
559
  return { comments: comments.map(toComment), reviews: reviews.map(toReview) }
@@ -562,14 +561,17 @@ const readPayload = (number: string): null | Payload => {
562
561
 
563
562
  type Snapshot = { baseline: Baseline } | { row: Row }
564
563
 
564
+ /** What one pull request's reads produced, before the baseline is consulted. */
565
+ type Collected = { number: string; reason: string } | { row: Row }
566
+
565
567
  const nowSeconds = (): number => Math.floor(Date.now() / 1000)
566
568
 
567
- const readHead = (number: string): string => {
569
+ const readHead = async (number: string): Promise<string> => {
568
570
  // The tip is the authority for the head, not the pull request object, which
569
571
  // lags the ref by up to a minute after a push. The object's head is the
570
572
  // fallback for a target on an older binary with no `pr head` verb.
571
573
  const record = parseJson(
572
- run('canon', ['pr', 'head', number, '--json']).stdout,
574
+ (await runAsync('canon', ['pr', 'head', number, '--json'])).stdout,
573
575
  )
574
576
  const tip =
575
577
  typeof record === 'object' && record !== null
@@ -578,7 +580,7 @@ const readHead = (number: string): string => {
578
580
 
579
581
  if (typeof tip === 'string' && tip !== '') return tip
580
582
 
581
- const pull = run('gh', [
583
+ const pull = await runAsync('gh', [
582
584
  'api',
583
585
  `repos/{owner}/{repo}/pulls/${number}`,
584
586
  '--jq',
@@ -594,16 +596,26 @@ const readHead = (number: string): string => {
594
596
  * against the base this machine has. It exits non-zero on a ref it cannot
595
597
  * resolve as well as on a real conflict, so both sides are checked first.
596
598
  */
597
- const readMerges = (number: string, head: string, ref: string): string => {
598
- run('git', ['fetch', '-q', 'origin', `pull/${number}/head`])
599
- if (
600
- !run('git', ['cat-file', '-e', `${head}^{commit}`]).isOk ||
601
- !run('git', ['rev-parse', '--verify', '-q', ref]).isOk
602
- ) {
603
- return 'unknown'
604
- }
599
+ const readMerges = async (
600
+ number: string,
601
+ head: string,
602
+ ref: string,
603
+ ): Promise<string> => {
604
+ // Concurrent fetches would each rewrite FETCH_HEAD, which nothing here reads.
605
+ await runAsync('git', [
606
+ 'fetch',
607
+ '-q',
608
+ '--no-write-fetch-head',
609
+ 'origin',
610
+ `pull/${number}/head`,
611
+ ])
612
+ const [commit, base] = await Promise.all([
613
+ runAsync('git', ['cat-file', '-e', `${head}^{commit}`]),
614
+ runAsync('git', ['rev-parse', '--verify', '-q', ref]),
615
+ ])
616
+ if (!commit.isOk || !base.isOk) return 'unknown'
605
617
 
606
- return run('git', ['merge-tree', '--write-tree', ref, head]).isOk
618
+ return (await runAsync('git', ['merge-tree', '--write-tree', ref, head])).isOk
607
619
  ? 'clean'
608
620
  : 'conflict'
609
621
  }
@@ -622,11 +634,20 @@ const nonEmptyLines = (output: string): string[] =>
622
634
  * the base to either one is a change under the branch. A head or base that does
623
635
  * not resolve, which a fork or a deleted head leaves, reads as null.
624
636
  */
625
- const readWritten = (head: string, ref: string): null | Written => {
626
- const forkPoint = run('git', ['merge-base', ref, head])
637
+ const readWritten = async (
638
+ head: string,
639
+ ref: string,
640
+ ): Promise<null | Written> => {
641
+ const forkPoint = await runAsync('git', ['merge-base', ref, head])
627
642
  if (!forkPoint.isOk) return null
628
643
  const point = forkPoint.stdout.trim()
629
- const diff = run('git', ['diff', '--name-only', '--no-renames', point, head])
644
+ const diff = await runAsync('git', [
645
+ 'diff',
646
+ '--name-only',
647
+ '--no-renames',
648
+ point,
649
+ head,
650
+ ])
630
651
  if (!diff.isOk) return null
631
652
 
632
653
  return { forkPoint: point, paths: nonEmptyLines(diff.stdout) }
@@ -638,14 +659,14 @@ const readWritten = (head: string, ref: string): null | Written => {
638
659
  * net diff rather than the log is read, so a change the base made and then
639
660
  * reverted moves nothing.
640
661
  */
641
- const readStale = (
662
+ const readStale = async (
642
663
  written: null | Written,
643
664
  ref: string,
644
- ): { files: string[]; stale: Staleness } => {
665
+ ): Promise<{ files: string[]; stale: Staleness }> => {
645
666
  if (written === null) return { files: [], stale: 'unknown' }
646
667
  if (written.paths.length === 0) return { files: [], stale: 'fresh' }
647
668
 
648
- const moved = run('git', [
669
+ const moved = await runAsync('git', [
649
670
  '--literal-pathspecs',
650
671
  'diff',
651
672
  '--name-only',
@@ -754,22 +775,23 @@ const readOverlap = (first: string, second: string): null | string[] => {
754
775
  return [...new Set(lines.slice(1))]
755
776
  }
756
777
 
757
- const buildRow = (
778
+ const buildRow = async (
758
779
  number: string,
759
780
  payload: Payload,
760
781
  head: string,
761
782
  ref: string,
762
- ): Row => {
783
+ ): Promise<Row> => {
763
784
  const now = nowSeconds()
764
785
  const replies = replyState(payload.comments ?? [])
765
786
  const unmatched = unmatchedState(payload.comments ?? [])
766
- const record = parseJson(
767
- run('canon', ['pr', 'review-state', number, '--json']).stdout,
768
- )
787
+ const [state, merges] = await Promise.all([
788
+ runAsync('canon', ['pr', 'review-state', number, '--json']),
789
+ readMerges(number, head, ref),
790
+ ])
791
+ const record = parseJson(state.stdout)
769
792
  const scope = scopeFromVerb(record, now) ?? scopeFallback(payload, now)
770
- const merges = readMerges(number, head, ref)
771
- const written = merges === 'unknown' ? null : readWritten(head, ref)
772
- const stale = readStale(written, ref)
793
+ const written = merges === 'unknown' ? null : await readWritten(head, ref)
794
+ const stale = await readStale(written, ref)
773
795
 
774
796
  return {
775
797
  age: scope.age,
@@ -845,7 +867,7 @@ export const standingFromLift = (
845
867
  * after it would leave a stored reading claiming clear. The lift verb's reading
846
868
  * comes first, and the read below stays as the fallback for an older binary.
847
869
  */
848
- const check = (number: string): number => {
870
+ const check = async (number: string): Promise<number> => {
849
871
  // The verb exits 1 on any refused gate and still prints its record, so the
850
872
  // exit says nothing about whether the standing was read.
851
873
  const lifted = standingFromLift(
@@ -858,7 +880,7 @@ const check = (number: string): number => {
858
880
  }
859
881
  const ref = baseRef()
860
882
  run('git', ['fetch', '-q', 'origin', baseBranch(ref)])
861
- const head = readHead(number)
883
+ const head = await readHead(number)
862
884
  if (head === '') {
863
885
  console.error(
864
886
  `poll: #${number} returned no head, so nothing is known about it`,
@@ -866,43 +888,57 @@ const check = (number: string): number => {
866
888
 
867
889
  return 1
868
890
  }
869
- const merges = readMerges(number, head, ref)
870
- const written = merges === 'unknown' ? null : readWritten(head, ref)
891
+ const merges = await readMerges(number, head, ref)
892
+ const written = merges === 'unknown' ? null : await readWritten(head, ref)
871
893
  console.log(
872
894
  standingLine({
873
895
  head,
874
896
  merges,
875
897
  number,
876
- stale: readStale(written, ref).stale,
898
+ stale: (await readStale(written, ref)).stale,
877
899
  }),
878
900
  )
879
901
 
880
902
  return 0
881
903
  }
882
904
 
883
- const main = (args: string[]): number => {
884
- const root = mainRoot()
885
- if (root === null) {
886
- console.error('poll: not a git repository, so nothing is classified')
905
+ /** Pull requests read at once, which keeps a wave under GitHub's burst limit. */
906
+ const READ_POOL = 4
887
907
 
888
- return 1
889
- }
908
+ interface PollRun {
909
+ /** Lines for stdout, the state lines and the closing `No movement.` */
910
+ readonly out: string[]
911
+ /** `poll:` lines for stderr, each a read that failed. */
912
+ readonly err: string[]
913
+ readonly isChanged: boolean
914
+ readonly status: number
915
+ }
916
+
917
+ /**
918
+ * One classification pass. It writes the baseline only when `record` holds,
919
+ * so a run made to time or inspect the board leaves the next run's view intact.
920
+ */
921
+ const pollOnce = async (
922
+ root: string,
923
+ { record }: { record: boolean },
924
+ ): Promise<PollRun> => {
925
+ const out: string[] = []
926
+ const err: string[] = []
890
927
 
891
928
  // The baseline is per-machine mutable state, so it stays in gitignored
892
929
  // scratch even though the script is tracked.
893
- if (args[0] === '--check') return check(args[1] ?? '')
894
930
  const stateDir = join(root, '.canon', 'tmp', 'pr', 'poll')
895
- mkdirSync(stateDir, { recursive: true })
896
931
  const statePath = join(stateDir, 'baseline.txt')
897
- appendFileSync(statePath, '')
932
+ const previous = (
933
+ existsSync(statePath) ? readFileSync(statePath, 'utf8') : ''
934
+ )
935
+ .split('\n')
936
+ .filter((line) => line !== '')
937
+ .map(parseBaseline)
898
938
 
899
939
  const ref = baseRef()
900
940
  const base = baseBranch(ref)
901
941
  const known = new Map<string, Baseline>()
902
- const previous = readFileSync(statePath, 'utf8')
903
- .split('\n')
904
- .filter((line) => line !== '')
905
- .map(parseBaseline)
906
942
  for (const entry of previous) {
907
943
  if (!known.has(entry.number)) known.set(entry.number, entry)
908
944
  }
@@ -920,47 +956,50 @@ const main = (args: string[]): number => {
920
956
  '.[].number',
921
957
  ])
922
958
  if (!listed.isOk) {
923
- console.error('poll: the open pull request list could not be read')
924
- console.error(
959
+ err.push(
960
+ 'poll: the open pull request list could not be read',
925
961
  'poll: nothing is classified this run and the baseline is unchanged',
926
962
  )
927
963
 
928
- return 1
964
+ return { err, isChanged: false, out, status: 1 }
929
965
  }
930
966
 
931
- const snapshots: Snapshot[] = []
932
- for (const number of listed.stdout.split(/\s+/).filter((n) => n !== '')) {
933
- // A read that failed and a pull request with no reviews both arrive as an
934
- // empty result, so each read returns null on failure and the payload gives
935
- // the failure a single place to surface.
936
- const payload = readPayload(number)
937
- const old = known.get(number)
938
- const unreadable = (reason: string): void => {
939
- if (old === undefined) {
940
- console.error(
941
- `poll: #${number} ${reason}, and it has no last known state, so it goes unclassified`,
942
- )
943
-
944
- return
945
- }
946
- console.error(
947
- `poll: #${number} ${reason}, so it keeps its last known state and goes unclassified`,
948
- )
949
- snapshots.push({ baseline: carryForward(old) })
950
- }
967
+ const numbers = listed.stdout.split(/\s+/).filter((n) => n !== '')
968
+ const collected = await mapPool(
969
+ numbers,
970
+ READ_POOL,
971
+ async (number): Promise<Collected> => {
972
+ // A read that failed and a pull request with no reviews both arrive as an
973
+ // empty result, so each read returns null on failure and the payload gives
974
+ // the failure a single place to surface.
975
+ const [payload, head] = await Promise.all([
976
+ readPayload(number),
977
+ readHead(number),
978
+ ])
979
+ if (payload === null) return { number, reason: 'could not be read' }
980
+ if (head === '') return { number, reason: 'returned no head' }
981
+
982
+ return { row: await buildRow(number, payload, head, ref) }
983
+ },
984
+ )
951
985
 
952
- if (payload === null) {
953
- unreadable('could not be read')
986
+ const snapshots: Snapshot[] = []
987
+ for (const item of collected) {
988
+ if ('row' in item) {
989
+ snapshots.push({ row: item.row })
954
990
  continue
955
991
  }
956
-
957
- const head = readHead(number)
958
- if (head === '') {
959
- unreadable('returned no head')
992
+ const old = known.get(item.number)
993
+ if (old === undefined) {
994
+ err.push(
995
+ `poll: #${item.number} ${item.reason}, and it has no last known state, so it goes unclassified`,
996
+ )
960
997
  continue
961
998
  }
962
-
963
- snapshots.push({ row: buildRow(number, payload, head, ref) })
999
+ err.push(
1000
+ `poll: #${item.number} ${item.reason}, so it keeps its last known state and goes unclassified`,
1001
+ )
1002
+ snapshots.push({ baseline: carryForward(old) })
964
1003
  }
965
1004
 
966
1005
  const rows = snapshots.flatMap((snapshot) =>
@@ -996,7 +1035,7 @@ const main = (args: string[]): number => {
996
1035
  base,
997
1036
  countSince: countSince(row.number),
998
1037
  })
999
- for (const line of result.lines) console.log(line)
1038
+ out.push(...result.lines)
1000
1039
  if (result.isChanged) isChanged = true
1001
1040
  final.push(
1002
1041
  formatBaseline({
@@ -1007,20 +1046,96 @@ const main = (args: string[]): number => {
1007
1046
  }
1008
1047
 
1009
1048
  for (const line of overlapLines(overlaps, known)) {
1010
- console.log(line)
1049
+ out.push(line)
1011
1050
  isChanged = true
1012
1051
  }
1013
1052
 
1014
1053
  for (const entry of previous) {
1015
1054
  if (seen.has(entry.number)) continue
1016
- console.log(`GONE #${entry.number} merged or closed`)
1055
+ out.push(`GONE #${entry.number} merged or closed`)
1017
1056
  isChanged = true
1018
1057
  }
1019
1058
 
1020
- if (!isChanged) console.log('No movement.')
1021
- writeFileSync(statePath, final.map((line) => `${line}\n`).join(''))
1059
+ if (record) {
1060
+ mkdirSync(stateDir, { recursive: true })
1061
+ writeFileSync(statePath, final.map((line) => `${line}\n`).join(''))
1062
+ }
1022
1063
 
1023
- return 0
1064
+ return { err, isChanged, out, status: 0 }
1065
+ }
1066
+
1067
+ /** The gap between reads in a wait, the interval the scheduled prompt used. */
1068
+ const DEFAULT_EVERY_S = 180
1069
+
1070
+ /** A flag's value in seconds, null when absent and NaN when not positive. */
1071
+ const seconds = (args: string[], flag: string): null | number => {
1072
+ const at = args.indexOf(flag)
1073
+ if (at === -1) return null
1074
+ const value = Number(args[at + 1])
1075
+
1076
+ return Number.isFinite(value) && value > 0 ? value : Number.NaN
1077
+ }
1078
+
1079
+ const print = (result: PollRun): void => {
1080
+ for (const line of result.err) console.error(line)
1081
+ for (const line of result.out) console.log(line)
1082
+ }
1083
+
1084
+ const main = async (args: string[]): Promise<number> => {
1085
+ const root = mainRoot()
1086
+ if (root === null) {
1087
+ console.error('poll: not a git repository, so nothing is classified')
1088
+
1089
+ return 1
1090
+ }
1091
+
1092
+ if (args[0] === '--check') return check(args[1] ?? '')
1093
+
1094
+ const record = !args.includes('--no-record')
1095
+ const wait = seconds(args, '--wait')
1096
+ const every = seconds(args, '--every') ?? DEFAULT_EVERY_S
1097
+ if (Number.isNaN(wait) || Number.isNaN(every)) {
1098
+ console.error('poll: --wait and --every take a positive number of seconds')
1099
+
1100
+ return 1
1101
+ }
1102
+
1103
+ if (wait === null) {
1104
+ const result = await pollOnce(root, { record })
1105
+ print(result)
1106
+ if (result.status === 0 && !result.isChanged) console.log('No movement.')
1107
+
1108
+ return result.status
1109
+ }
1110
+
1111
+ // Only movement ends the wait early. A failed read, of one pull request or of
1112
+ // the whole open list, holds to the cap, since one failing every run would end
1113
+ // each wait at once and the re-arm would spin. Its lines ride with the exit,
1114
+ // and a list still unreadable at the cap exits 1.
1115
+ const deadline = Date.now() + wait * 1000
1116
+ const failed = new Set<string>()
1117
+ for (;;) {
1118
+ const result = await pollOnce(root, { record })
1119
+ for (const line of result.err) failed.add(line)
1120
+ if (result.status === 0 && result.isChanged) {
1121
+ print({ ...result, err: [...failed] })
1122
+
1123
+ return 0
1124
+ }
1125
+ // A quiet wait never ends before its cap, whatever `--every` says, since an
1126
+ // early exit is a re-arm the orchestrator spends a turn on.
1127
+ const left = deadline - Date.now()
1128
+ if (left <= 0) {
1129
+ for (const line of failed) console.error(line)
1130
+ if (result.status !== 0) return result.status
1131
+ console.log('No movement.')
1132
+
1133
+ return 0
1134
+ }
1135
+ await new Promise((resolve) =>
1136
+ setTimeout(resolve, Math.min(every * 1000, left)),
1137
+ )
1138
+ }
1024
1139
  }
1025
1140
 
1026
- if (import.meta.main) process.exitCode = main(process.argv.slice(2))
1141
+ if (import.meta.main) process.exitCode = await main(process.argv.slice(2))
@@ -1,23 +1,75 @@
1
1
  // Shared by the poll and the watch. Imports are `node:` builtins only, since
2
2
  // the plugin ships `claude/` alone and a cached plugin root carries no
3
3
  // `node_modules`.
4
- import { spawnSync } from 'node:child_process'
4
+ import { spawn, spawnSync } from 'node:child_process'
5
5
 
6
6
  export interface RunResult {
7
7
  isOk: boolean
8
8
  stdout: string
9
9
  }
10
10
 
11
+ /**
12
+ * How long one command may run before it is killed and read as failed. A
13
+ * background wait has no caller to cut a hung `git fetch` or `gh api` off, so
14
+ * without this bound one stalled read would hold the wait past its cap.
15
+ */
16
+ export const RUN_TIMEOUT_MS = 60_000
17
+
11
18
  /** Runs a command to completion. A command that cannot start reads as failed. */
12
19
  export const run = (command: string, args: string[]): RunResult => {
13
20
  const result = spawnSync(command, args, {
14
21
  encoding: 'utf8',
15
22
  stdio: ['ignore', 'pipe', 'ignore'],
23
+ timeout: RUN_TIMEOUT_MS,
16
24
  })
17
25
 
18
26
  return { isOk: result.status === 0, stdout: result.stdout ?? '' }
19
27
  }
20
28
 
29
+ /** `run` without blocking, so reads for several pull requests overlap. */
30
+ export const runAsync = (
31
+ command: string,
32
+ args: string[],
33
+ timeoutMs = RUN_TIMEOUT_MS,
34
+ ): Promise<RunResult> =>
35
+ new Promise((resolve) => {
36
+ const child = spawn(command, args, {
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ timeout: timeoutMs,
39
+ })
40
+ let stdout = ''
41
+ child.stdout.setEncoding('utf8')
42
+ child.stdout.on('data', (chunk: string) => {
43
+ stdout += chunk
44
+ })
45
+ child.on('error', () => resolve({ isOk: false, stdout: '' }))
46
+ child.on('close', (status) => resolve({ isOk: status === 0, stdout }))
47
+ })
48
+
49
+ /**
50
+ * Maps every item with at most `limit` in flight, keeping results in input
51
+ * order, so output stays stable however the reads finish.
52
+ */
53
+ export const mapPool = async <T, R>(
54
+ items: readonly T[],
55
+ limit: number,
56
+ map: (item: T) => Promise<R>,
57
+ ): Promise<R[]> => {
58
+ const results: R[] = Array.from({ length: items.length })
59
+ let next = 0
60
+ const worker = async (): Promise<void> => {
61
+ while (next < items.length) {
62
+ const index = next++
63
+ results[index] = await map(items[index] as T)
64
+ }
65
+ }
66
+ await Promise.all(
67
+ Array.from({ length: Math.min(limit, items.length) }, worker),
68
+ )
69
+
70
+ return results
71
+ }
72
+
21
73
  /**
22
74
  * The main worktree root rather than the current one, so a script started from
23
75
  * a linked worktree reads the same repository and state as one started from
@@ -21,7 +21,7 @@ kind: reference
21
21
  | `record-layout` | Review receipts, memory archives, feedback, renders, picks, evidence, and the design board into their current folders |
22
22
  | `session-defaults` | The seeded session defaults into an existing `.claude/settings.json`, listed below the table |
23
23
 
24
- `record-tree` follows citations in `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`. `session-defaults` denies `Agent`, `Task`, and both merge commands, turns auto memory off, and wires an installed `plan-guard.sh` into the PreToolUse hooks. The bullets below add detail per verb.
24
+ `record-tree` follows citations in `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`. `session-defaults` denies subagents and both merge commands, allows the two lease push forms workers run, turns auto memory off, and wires an installed `plan-guard.sh`. The bullets below add detail per verb.
25
25
 
26
26
  - `skill-names` takes no `--scope`, and skips the changelog, eval transcripts, replay fixtures, and the pointer each renamed `draft-` skill leaves at its old name
27
27
  - `surface-roots` moves `context/`, `wireframes/`, `ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`, and the stamp folder, and leaves `rules/`, `skills/`, `hooks/`, and `settings.json` in place
@@ -56,15 +56,15 @@ Three markers keep an old spelling on a line. Each also covers the nearest non-b
56
56
 
57
57
  ## Refusals
58
58
 
59
- | Verb | Refuses when |
60
- | ------------------ | --------------------------------------------------------------------------------------------------------- |
61
- | `records` | The project does not ignore `.canon/`. Run `canon tooling sync --write` first. |
62
- | `records` | A record folder already exists at the destination |
63
- | `surface-roots` | Any destination already exists under `canon/`, refusing the whole plan |
64
- | `scratch-evidence` | A destination is occupied |
65
- | `record-layout` | A destination already exists, each move refused on its own |
66
- | `rule-layout` | A destination holds different bytes. Neither file is touched and other moves apply. |
67
- | `session-defaults` | The settings file is not valid JSON, or `permissions`, `deny`, `hooks`, or `PreToolUse` has another shape |
59
+ | Verb | Refuses when |
60
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------ |
61
+ | `records` | The project does not ignore `.canon/`. Run `canon tooling sync --write` first. |
62
+ | `records` | A record folder already exists at the destination |
63
+ | `surface-roots` | Any destination already exists under `canon/`, refusing the whole plan |
64
+ | `scratch-evidence` | A destination is occupied |
65
+ | `record-layout` | A destination already exists, each move refused on its own |
66
+ | `rule-layout` | A destination holds different bytes. Neither file is touched and other moves apply. |
67
+ | `session-defaults` | The settings file is not valid JSON, or `permissions`, `deny`, `allow`, `hooks`, or `PreToolUse` has another shape |
68
68
 
69
69
  A `rule-layout` destination holding the same bytes marks a duplicate, which `--write` deletes.
70
70
 
@@ -9,7 +9,9 @@ kind: reference
9
9
  `canon targets list` reports the projects this toolkit installed into. `canon targets pulls [paths…]` reports each target's open pull requests, or every target's when none is named. Both take `--json`. Read the record's fields, since a wrapping shell function can flatten the exit.
10
10
 
11
11
  - `list` takes `--sweep <path…>`, `--depth <n>` (default 4), and `--record`, which seeds the index from swept stamps and needs `--sweep`
12
- - Each stamping sync records its target in `$XDG_STATE_HOME/canon/targets.json`, and no row is removed
12
+ - Each stamping sync records its target in `$XDG_STATE_HOME/canon/targets.json`, except a folder under the OS temp folder, the state folder, `~/.claude/jobs/`, or any `.canon/tmp/`
13
+ - Every write drops a row whose folder is gone or sits under one of those, and `list` leaves the same rows out. A target on an unmounted drive drops until its next sync records it again.
14
+ - `--record` counts a swept checkout under one of those folders as skipped, not failed
13
15
  - A sweep finds installs the index never held
14
16
 
15
17
  `canon changelog` prints the releases a target has not seen, from the `CHANGELOG.md` the installed package ships. It reads no registry and needs no `gh`.
@@ -75,8 +75,8 @@ Replace a citation of a skill with no successor by hand, or delete the sentence.
75
75
 
76
76
  - `canvas` is now `canvas-board`. The sweep rewrites only `canon:canvas` and `skills/canvas`, so replace a bare mention by hand. <!-- canon-keep-retired -->
77
77
  - `draft-ready` is rewritten only as `canon:draft-ready` and `skills/draft-ready`, since bare it also describes a pull request, so replace a bare mention by hand. <!-- canon-keep-retired -->
78
- - `design-extract` and `sketch-design` are replaced by `canon:canvas-board`. <!-- canon-keep-retired -->
79
- - `review-branch`, `seed-sync`, `restate-plainly`, `bash-cli-script`, `canon-operator`, `canon-rollout`, and `canon-feedback-triage` have no successor <!-- canon-keep-retired -->
78
+ - `design-extract` and `sketch-design` are replaced by `canon:canvas-board`, and `canon-feedback-triage` by `canon:issue-triage`. <!-- canon-keep-retired -->
79
+ - `review-branch`, `seed-sync`, `restate-plainly`, `bash-cli-script`, `canon-operator`, and `canon-rollout` have no successor <!-- canon-keep-retired -->
80
80
  - `create-standard`, `markdown-propose`, `memory-review`, `project-commands`, `repo-metadata`, `ux-walkthrough`, and `plan-ready` have no successor <!-- canon-keep-retired -->
81
81
 
82
82
  The `memory` record kind left `canon records`, so `canon records validate memory` refuses as an unknown kind and `canon records migrate` is gone.
@@ -115,6 +115,10 @@ canon migrate session-defaults --json
115
115
  canon migrate session-defaults --write --json
116
116
  ```
117
117
 
118
- The verb only adds a key that is absent. A person who wants auto memory back sets it in their own `.claude/settings.local.json`.
118
+ The verb only adds a key or entry that is absent, so a project migrated before an entry runs `--write` once more.
119
119
 
120
- The verb adds `Bash(canon pr merge:*)` and `Bash(gh pr merge:*)` so a session cannot merge. A project migrated before them runs `--write` once more, which also restores `Agent` and `Task` for a person who removed them to allow subagents, so remove them again after.
120
+ - A person who wants auto memory back sets it in their own `.claude/settings.local.json`
121
+ - `Bash(canon pr merge:*)` and `Bash(gh pr merge:*)` stop a session merging
122
+ - A rerun restores `Agent` and `Task` for a person who removed them to allow subagents, so remove them again after
123
+ - `git push --force-with-lease` and its `origin HEAD` form are allowed exactly, so a worker pushes after a rebase without waiting
124
+ - Any other lease push still goes through auto mode's review, and `canon claude sync` warns while either entry is missing
@@ -41,7 +41,7 @@ See [target projects](../target/projects.md).
41
41
 
42
42
  Plan and build in session 1, then review and ship in a fresh session 2 when you want a cold reviewer on the diff.
43
43
 
44
- - For a pile of findings, invoke `canon:plan-intake`, and for a grown backlog `canon:backlog-triage`
44
+ - For a pile of findings, invoke `canon:plan-intake`, for a grown backlog `canon:backlog-triage`, and for open GitHub issues `canon:issue-triage`
45
45
  - For an unmeasured state with live approaches, invoke `canon:plan-groundwork`
46
46
  - Invoke `canon:plan-feature`, implement, and run the `CLAUDE.md` commands until they pass
47
47
  - For UI changes, invoke `canon:ui-checklist`
@@ -22,6 +22,7 @@ Groups run in the order a project meets them. Every name `canon claude skills li
22
22
  | `canon:plan-intake` | The input is a pile of findings |
23
23
  | `canon:plan-intake-answer` | An intake folder holds unread slots |
24
24
  | `canon:backlog-triage` | The backlog needs pruning |
25
+ | `canon:issue-triage` | Open GitHub issues need measuring before planning |
25
26
  | `canon:plan-groundwork` | The state is unmeasured and approaches compete |
26
27
  | `canon:decision-escalate` | Open decisions turn on your preference |
27
28
  | `canon:proposal-critique` | A proposal needs questioning before it is built |
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.75.0",
4
+ "version": "5.77.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
8
- "canon": "./src/cli.ts"
8
+ "canon": "src/cli.ts"
9
9
  },
10
10
  "files": [
11
11
  "src",