@erclx/canon 5.74.0 → 5.76.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 (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-rule/SKILL.md +5 -1
  4. package/claude/skills/create-rule/references/ledger.md +73 -0
  5. package/claude/skills/create-rule/references/rule.md +1 -1
  6. package/claude/skills/create-skill/references/skill-practice.md +1 -1
  7. package/claude/skills/design-identity/REQUIREMENT.md +1 -1
  8. package/claude/skills/design-identity/SKILL.md +13 -4
  9. package/claude/skills/design-identity/references/card-route.md +11 -4
  10. package/claude/skills/git-issue/REQUIREMENT.md +4 -2
  11. package/claude/skills/git-issue/SKILL.md +32 -3
  12. package/claude/skills/git-issue/references/issue.md +7 -0
  13. package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
  14. package/claude/skills/issue-triage/SKILL.md +130 -0
  15. package/claude/skills/pr-evidence/references/reel.md +9 -9
  16. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  17. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
  20. package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
  21. package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
  22. package/claude/skills/ui-film/SKILL.md +8 -7
  23. package/docs/agents/audits.md +24 -24
  24. package/docs/agents/migrate.md +10 -10
  25. package/docs/migrations/by-verb.md +10 -4
  26. package/docs/workflow/ai-workflow.md +1 -1
  27. package/docs/workflow/skill-map.md +1 -0
  28. package/governance/detect.toml +4 -0
  29. package/governance/ledger/claude/rule.md +16 -0
  30. package/governance/rules/claude/rule.md +13 -0
  31. package/package.json +1 -1
  32. package/scripts/ci/refresh-frames-pr.sh +11 -12
  33. package/src/audits/catalog.ts +7 -2
  34. package/src/capture/render.ts +30 -5
  35. package/src/capture/stamp.ts +3 -5
  36. package/src/claude/cases/git.ts +5 -0
  37. package/src/claude/cases/workflow.ts +4 -0
  38. package/src/claude/skills-audit.ts +65 -0
  39. package/src/cli/help.ts +1 -1
  40. package/src/commands/capture.ts +38 -1
  41. package/src/commands/claude.ts +10 -2
  42. package/src/commands/gov.ts +12 -7
  43. package/src/commands/migrate.ts +6 -2
  44. package/src/commands/take.ts +36 -7
  45. package/src/counts/catalogs.ts +3 -5
  46. package/src/gate/measures.ts +1 -144
  47. package/src/gate/stages.ts +0 -23
  48. package/src/gov/audit.ts +59 -13
  49. package/src/markdown/scope.ts +4 -0
  50. package/src/migrate/session-defaults.ts +146 -8
  51. package/src/take/encode.ts +9 -1
  52. package/src/take/film.ts +17 -2
  53. package/src/take/fixtures/busy.html +107 -0
  54. package/src/take/perform.ts +99 -25
  55. package/src/take/screencast.ts +4 -3
  56. package/src/take/stepper.ts +33 -20
  57. package/tooling/claude/reference.md +4 -2
  58. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  59. package/tooling/claude/seeds/.claude/settings.json +12 -0
  60. package/scripts/core/regen-hero.ts +0 -113
  61. package/src/capture/frame-values.ts +0 -326
@@ -23,28 +23,28 @@ The script reads pull requests and a building worker has none, so every interval
23
23
 
24
24
  ### Who starts and stops it
25
25
 
26
- A session holding a recurring-prompt scheduler starts the loop on that condition and cancels it on the same test, without waiting for the operator. A runbook stating only the start leaves the always-on failure unaddressed on the side that causes it.
26
+ The orchestrator starts the poll on that condition and stops re-arming it on the same test, without waiting for the operator. A runbook stating only the start leaves the always-on failure unaddressed on the side that causes it.
27
27
 
28
- - Nothing enforces this. No hook starts the poll and no check stops it, so the condition holds only while the loop's holder applies it.
29
- - Left always-on, it fires into an empty board through every gap between a dispatch and its push
30
- - The poll is session-scoped and dies with its session, so restart it after a compaction, taking the prompt from this file rather than a transcript
31
-
32
- ### Editing the prompt
33
-
34
- A running loop holds whatever wording it was started with, and a scheduled prompt cannot be edited, so every routing fix is a cancel and a re-create. Three fixes made inside one session once reached the running loop and none reached this file.
35
-
36
- Edit the block here first and re-create the loop from what this file then says. Rewriting the block from memory drops what the shipped one covered, which is how a `SEEN` report once fell through to no rule at all.
28
+ - Nothing enforces this. No hook starts the poll and no check stops it, so the condition holds only while the session holding it applies it.
29
+ - Stop it at once by stopping that background command, or let the running wait end and start no other
30
+ - The poll is session-scoped and dies with its session, so restart it after a compaction, taking the routing from this file rather than a transcript
37
31
 
38
32
  ## The prompt
39
33
 
40
- The requirement is a recurring prompt at roughly three minutes carrying the block below. `/loop 3m <the block>` is this repository's mechanism and one example among the schedulers a client may hold, so a client without it schedules whatever recurring prompt it can.
34
+ Start the poll in the background, act on what it prints when it exits by the block below, then start it again. It reads every 180 seconds by itself and exits on movement, so an idle board costs no turn and no message queues behind a run. The block is read on every wake, so edit it here and never from memory.
35
+
36
+ ```bash
37
+ bun <POLL_SCRIPT> --wait 1200
38
+ ```
41
39
 
42
- - The interval belongs to the schedule, since one run is a six-second snapshot, and relaunching on completion once fired every few seconds for 35 minutes
43
- - Paste the absolute path of `${CLAUDE_SKILL_DIR}/scripts/poll.ts` in place of `<POLL_SCRIPT>` below
44
- - The variable expands while this runbook renders and not in a standalone loop turn, so a block carrying it reaches the session as a literal and the run improvises
40
+ - Run it with the client's background option, `run_in_background` here, and start the next one as the last step of every wake, a `No movement.` at the cap included
41
+ - A failed read, of one pull request or the open list, holds to the cap with its `poll:` lines rather than spinning the restart
42
+ - Start with `--wait 180` while a cloud reviewer holds a pull request, since the rule below reads its pass once per wake
43
+ - Paste the absolute path of `${CLAUDE_SKILL_DIR}/scripts/poll.ts` for `<POLL_SCRIPT>`, since the variable expands only while this runbook renders
44
+ - Add `--no-record` to time or inspect a run without writing the baseline, and a client with no background command schedules a plain run every three minutes instead
45
45
 
46
46
  ```plaintext
47
- Poll GitHub for pull request movement by running `bun <POLL_SCRIPT>`, then act on what it reports.
47
+ Act on what the poll printed when it exited, then start it again.
48
48
 
49
49
  - A release pull request, whatever state follows it: report it and stop. Its sweep carries no findings, so no pass is owed. Test this before any rule below, since a release pull request is reported OPENED like any other and would otherwise match that rule first.
50
50
  - MOVED or RESPONSE on a pull request already reviewed: when a live local reviewer holds it, report it and stop, since the worker asks that reviewer for the re-review directly. When none does, which a cloud reviewer always leaves, launch a fresh reviewer under the review dispatch runbook of the canon:role-orchestrator skill. Never run the canon:review-pr skill here. Re-reviews read prior..head and gain nothing from waiting.
@@ -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
@@ -70,13 +70,14 @@ canon take <url> .canon/video/<slug>/plan.json --out .canon/video/<slug>/takes -
70
70
 
71
71
  Branch on the record's `reason`, never on the exit code, which a shell function wrapping `canon` can flatten to zero.
72
72
 
73
- | Record | Route |
74
- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
75
- | No `reason` | Continue to the stills |
76
- | `target-missing` with `fallback` | Check the access before the selector, per the note below the table |
77
- | `target-missing` | Open the record's `still`, find the target the record's `beat` names on the page, fix the plan, and film once more |
78
- | `server-unreachable` | Restart the server from Step 1 and film once more |
79
- | Any other `reason` | Report the `reason` and `message` to the operator and stop, since each is a setup step |
73
+ | Record | Route |
74
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
75
+ | No `reason` | Continue to the stills |
76
+ | `target-missing` with `fallback` | Check the access before the selector, per the note below the table |
77
+ | `target-missing` | Open the record's `still`, find the target the record's `beat` names on the page, fix the plan, and film once more |
78
+ | `target-offscreen` | Add a setup step that brings the target into the window, being a scroll over an element already inside it, such as a fixed header, with `dy` covering the distance, or a click on a link to it, and film once more |
79
+ | `server-unreachable` | Restart the server from Step 1 and film once more |
80
+ | Any other `reason` | Report the `reason` and `message` to the operator and stop, since each is a setup step |
80
81
 
81
82
  A refusal carrying `fallback` means the screencast opened the address a second time, so a single-use link was spent before the step ran. Read `${CLAUDE_SKILL_DIR}/references/config.md` for the access the take opens with, and fix that before touching the target.
82
83