@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.
- package/CHANGELOG.md +29 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-rule/SKILL.md +5 -1
- package/claude/skills/create-rule/references/ledger.md +73 -0
- package/claude/skills/create-rule/references/rule.md +1 -1
- package/claude/skills/create-skill/references/skill-practice.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +1 -1
- package/claude/skills/design-identity/SKILL.md +13 -4
- package/claude/skills/design-identity/references/card-route.md +11 -4
- package/claude/skills/git-issue/REQUIREMENT.md +4 -2
- package/claude/skills/git-issue/SKILL.md +32 -3
- package/claude/skills/git-issue/references/issue.md +7 -0
- package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
- package/claude/skills/issue-triage/SKILL.md +130 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -9
- package/claude/skills/proposal-critique/references/adopted.md +44 -0
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
- package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
- package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
- package/claude/skills/ui-film/SKILL.md +8 -7
- package/docs/agents/audits.md +24 -24
- package/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +10 -4
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +4 -0
- package/governance/ledger/claude/rule.md +16 -0
- package/governance/rules/claude/rule.md +13 -0
- package/package.json +1 -1
- package/scripts/ci/refresh-frames-pr.sh +11 -12
- package/src/audits/catalog.ts +7 -2
- package/src/capture/render.ts +30 -5
- package/src/capture/stamp.ts +3 -5
- package/src/claude/cases/git.ts +5 -0
- package/src/claude/cases/workflow.ts +4 -0
- package/src/claude/skills-audit.ts +65 -0
- package/src/cli/help.ts +1 -1
- package/src/commands/capture.ts +38 -1
- package/src/commands/claude.ts +10 -2
- package/src/commands/gov.ts +12 -7
- package/src/commands/migrate.ts +6 -2
- package/src/commands/take.ts +36 -7
- package/src/counts/catalogs.ts +3 -5
- package/src/gate/measures.ts +1 -144
- package/src/gate/stages.ts +0 -23
- package/src/gov/audit.ts +59 -13
- package/src/markdown/scope.ts +4 -0
- package/src/migrate/session-defaults.ts +146 -8
- package/src/take/encode.ts +9 -1
- package/src/take/film.ts +17 -2
- package/src/take/fixtures/busy.html +107 -0
- package/src/take/perform.ts +99 -25
- package/src/take/screencast.ts +4 -3
- package/src/take/stepper.ts +33 -20
- package/tooling/claude/reference.md +4 -2
- package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
- package/tooling/claude/seeds/.claude/settings.json +12 -0
- package/scripts/core/regen-hero.ts +0 -113
- 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
|
-
|
|
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
|
|
29
|
-
-
|
|
30
|
-
- The poll is session-scoped and dies with its session, so restart it after a compaction, taking the
|
|
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
|
-
|
|
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
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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 =
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
`issues/${number}/comments?per_page=100`,
|
|
556
|
-
|
|
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
|
-
|
|
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 =
|
|
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 = (
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
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 = (
|
|
626
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
767
|
-
|
|
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
|
|
771
|
-
const
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
924
|
-
|
|
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
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1055
|
+
out.push(`GONE #${entry.number} merged or closed`)
|
|
1017
1056
|
isChanged = true
|
|
1018
1057
|
}
|
|
1019
1058
|
|
|
1020
|
-
if (
|
|
1021
|
-
|
|
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
|
-
| `
|
|
79
|
-
|
|
|
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
|
|