@erclx/canon 5.75.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 +11 -0
- package/claude/.claude-plugin/plugin.json +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/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/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +8 -4
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/package.json +1 -1
- package/src/capture/render.ts +30 -5
- package/src/claude/cases/git.ts +5 -0
- package/src/claude/cases/workflow.ts +4 -0
- package/src/commands/capture.ts +38 -1
- package/src/commands/claude.ts +4 -0
- package/src/markdown/scope.ts +2 -0
- package/src/migrate/session-defaults.ts +36 -8
- package/tooling/claude/seeds/.claude/settings.json +4 -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 {
|
|
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
|
package/docs/agents/migrate.md
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -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`,
|
|
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
|
|
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
|
-
|
|
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`,
|
|
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 |
|