@erclx/canon 5.78.0 → 5.80.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 +40 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/canon-cli/SKILL.md +13 -10
- package/claude/skills/design-pick/SKILL.md +8 -0
- package/claude/skills/design-pick/references/design-config.md +9 -6
- package/claude/skills/design-references/REQUIREMENT.md +32 -0
- package/claude/skills/design-references/SKILL.md +120 -0
- package/claude/skills/design-references/references/films.md +90 -0
- package/claude/skills/design-references/references/sites.md +36 -0
- package/claude/skills/design-taste/SKILL.md +16 -4
- package/claude/skills/design-taste/references/adopted.md +5 -1
- package/claude/skills/design-taste/references/kinds.md +1 -0
- package/claude/skills/design-taste/references/preflight.md +27 -1
- package/claude/skills/design-taste/references/tells.md +2 -0
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/review-ui/references/driving.md +33 -1
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +1 -1
- package/claude/skills/search-craft/REQUIREMENT.md +3 -3
- package/claude/skills/search-craft/SKILL.md +12 -1
- package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
- package/claude/skills/target-scaffold/SKILL.md +44 -0
- package/claude/skills/target-setup/SKILL.md +5 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/ui-checklist/SKILL.md +5 -1
- package/claude/skills/ui-craft/SKILL.md +9 -9
- package/claude/skills/ui-craft/references/adopted.md +4 -2
- package/claude/skills/ui-craft/references/capture.md +8 -0
- package/claude/skills/ui-craft/references/controls.md +6 -0
- package/claude/skills/ui-craft/references/motion.md +6 -0
- package/claude/skills/ui-film/references/take-craft.md +2 -0
- package/docs/agents/output-shape.md +2 -1
- package/docs/target/projects.md +2 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/skill-map.md +7 -5
- package/docs/workflow/visual-design-workflow.md +4 -0
- package/governance/ledger/skills/design-references/SKILL.md +38 -0
- package/governance/ledger/skills/design-taste/SKILL.md +36 -30
- package/governance/ledger/skills/design-taste/kinds.md +1 -0
- package/governance/ledger/skills/design-taste/tells.md +22 -20
- package/governance/ledger/skills/search-craft/SKILL.md +6 -0
- package/governance/ledger/skills/ui-craft/capture.md +10 -0
- package/governance/ledger/skills/ui-craft/controls.md +8 -0
- package/governance/ledger/skills/ui-craft/motion.md +9 -1
- package/package.json +3 -3
- package/src/capture/render.ts +154 -2
- package/src/capture/sections.ts +257 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/commands/capture.ts +149 -4
- package/src/commands/pr.ts +138 -15
- package/src/commands/sessions.ts +88 -6
- package/src/commands/tasks.ts +41 -2
- package/src/commands/tooling.ts +72 -0
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +38 -9
- package/src/pr/health.ts +63 -15
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/encode.ts +16 -42
- package/src/take/frame-clock.ts +46 -0
- package/src/take/perform.ts +29 -4
- package/src/take/screencast.ts +114 -11
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/tooling/list.ts +3 -0
- package/src/tooling/scaffold.ts +193 -0
- package/tooling/cloudflare/manifest.toml +1 -1
- package/tooling/cloudflare/reference.md +8 -0
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/commands/pr.ts
CHANGED
|
@@ -113,6 +113,7 @@ import {
|
|
|
113
113
|
type LocalRefusal,
|
|
114
114
|
type LocalRunner,
|
|
115
115
|
} from '@/pr/local'
|
|
116
|
+
import { linkKeyChanges } from '@/pr/links'
|
|
116
117
|
import { KEY_CHANGES } from '@/pr/paths'
|
|
117
118
|
import {
|
|
118
119
|
findDeployWorkflow,
|
|
@@ -161,6 +162,7 @@ const GH_TIMEOUT_MS = 30_000
|
|
|
161
162
|
const UNNAMED_PRINT_LIMIT = 10
|
|
162
163
|
|
|
163
164
|
interface KeyChangesOptions {
|
|
165
|
+
readonly link?: boolean
|
|
164
166
|
readonly body?: string
|
|
165
167
|
readonly base?: string
|
|
166
168
|
readonly root?: string
|
|
@@ -279,7 +281,7 @@ const LOCAL_REFUSALS: Record<
|
|
|
279
281
|
'Nothing listening inside this worktree served an HTML page. Start the dev server here and re-run to get a link.',
|
|
280
282
|
unhealthy:
|
|
281
283
|
'A page answered inside this worktree, but a health path it declares failed. Start what the page reaches and re-run to get a link.',
|
|
282
|
-
'invalid-config': `${LOCAL_CONFIG_REL} cannot be read, so no health path was checked and no link is given. Fix or delete it and re-run.`,
|
|
284
|
+
'invalid-config': `${LOCAL_CONFIG_REL} cannot be read, so no health path was checked and no link is given. Fix or delete it and re-run. health takes paths starting with /, and page-only takes folders relative to the repository.`,
|
|
283
285
|
'no-listener-reader':
|
|
284
286
|
'This machine offers neither lsof nor /proc, so no listening socket could be read. Nothing is posted.',
|
|
285
287
|
'gh-failed':
|
|
@@ -503,13 +505,17 @@ export function register(program: Command): void {
|
|
|
503
505
|
.helpOption('-h, --help', 'Show this help message')
|
|
504
506
|
.option(
|
|
505
507
|
'--body <path>',
|
|
506
|
-
'Read the body from a file rather than the API, ignoring any number',
|
|
508
|
+
'Read the body from a file rather than the API, ignoring any number except the one --link needs',
|
|
507
509
|
)
|
|
508
510
|
.option(
|
|
509
511
|
'--base <ref>',
|
|
510
512
|
'Far side of the range when --body supplies the body',
|
|
511
513
|
)
|
|
512
514
|
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
515
|
+
.option(
|
|
516
|
+
'--link',
|
|
517
|
+
`Print the body with each ## ${KEY_CHANGES} path linked to its diff, needing a number`,
|
|
518
|
+
)
|
|
513
519
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
514
520
|
.addHelpText(
|
|
515
521
|
'after',
|
|
@@ -538,6 +544,13 @@ export function register(program: Command): void {
|
|
|
538
544
|
'points the change at a locative the path does not name. Read the bullet on',
|
|
539
545
|
"the record's preview before filing an unmet path as a stale claim.",
|
|
540
546
|
'',
|
|
547
|
+
'--link prints the body and nothing else, with every path the section',
|
|
548
|
+
'names that the diff changes written as a link to its row in the Files',
|
|
549
|
+
'tab. The anchor is `diff-` and the SHA-256 of the path. Pass the pull',
|
|
550
|
+
'request number, since the link needs it. It also rewrites the anchor',
|
|
551
|
+
'of a link opening another file, and a plain run reports that link as',
|
|
552
|
+
"mislinked when its label is a changed path and its anchor hashes another's.",
|
|
553
|
+
'',
|
|
541
554
|
'Refusals, read off `reason`:',
|
|
542
555
|
' no-section the body carries no `## Key changes` heading, so it',
|
|
543
556
|
' claims nothing',
|
|
@@ -555,14 +568,18 @@ export function register(program: Command): void {
|
|
|
555
568
|
' gh-missing `gh` is not on the path, so no pull request could be',
|
|
556
569
|
' resolved',
|
|
557
570
|
' gh-failed `gh` could not answer for this repository or branch',
|
|
571
|
+
' no-number --link was given with no pull request number',
|
|
572
|
+
' no-repo-url --link could not read the repository address',
|
|
573
|
+
' link-json --link was given with --json, though it prints the body',
|
|
558
574
|
'',
|
|
559
575
|
'Exit codes:',
|
|
560
|
-
' 0 every claimed path is in the diff',
|
|
576
|
+
' 0 every claimed path is in the diff, and every link opens its own file',
|
|
561
577
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
562
|
-
' 2
|
|
578
|
+
' 2 a claimed path is absent from the diff, or a link opens another file',
|
|
563
579
|
'',
|
|
564
580
|
'Examples:',
|
|
565
581
|
' canon pr key-changes',
|
|
582
|
+
' canon pr key-changes 1265 --link --body .canon/tmp/body.md',
|
|
566
583
|
' canon pr key-changes 1265 --json',
|
|
567
584
|
' canon pr key-changes --body .canon/tmp/body.md --base origin/main',
|
|
568
585
|
'',
|
|
@@ -1301,6 +1318,15 @@ export function register(program: Command): void {
|
|
|
1301
1318
|
"refusal names the lowest page port's first failing path. An absent",
|
|
1302
1319
|
'file or an empty list checks the page alone.',
|
|
1303
1320
|
'',
|
|
1321
|
+
'A page served by a static server answers none of those paths, so',
|
|
1322
|
+
'page-only = ["web"] lists repository-relative folders whose',
|
|
1323
|
+
'listeners skip them and are found on the page probe alone. The match',
|
|
1324
|
+
"reads the listener's working directory on whole path segments, so web",
|
|
1325
|
+
'does not cover webapp, and a server started from the root with a',
|
|
1326
|
+
'working-directory flag still takes the health check. Keep health',
|
|
1327
|
+
'beside page-only until the binary a project runs carries this key,',
|
|
1328
|
+
'since an older one refuses a file holding page-only alone.',
|
|
1329
|
+
'',
|
|
1304
1330
|
'A process in a sibling worktree, in the main checkout seen from a',
|
|
1305
1331
|
'linked worktree, or in a linked worktree seen from the main checkout',
|
|
1306
1332
|
'is never reported, since its server shows another branch. Sockets are',
|
|
@@ -1326,8 +1352,9 @@ export function register(program: Command): void {
|
|
|
1326
1352
|
' unhealthy a page answered but a declared health path did',
|
|
1327
1353
|
' not, with `port`, `path`, and `status`, which',
|
|
1328
1354
|
' is null on a timeout',
|
|
1329
|
-
' invalid-config canon/config/local.toml is not valid TOML,
|
|
1330
|
-
' health is not a list of paths starting with
|
|
1355
|
+
' invalid-config canon/config/local.toml is not valid TOML,',
|
|
1356
|
+
' health is not a list of paths starting with /,',
|
|
1357
|
+
' or page-only is not a list of relative folders',
|
|
1331
1358
|
' removed --remove dropped the line',
|
|
1332
1359
|
' no-comment --remove found no marked comment, a no-op',
|
|
1333
1360
|
' no-line --remove found no address line, a no-op',
|
|
@@ -1865,6 +1892,8 @@ async function runKeyChanges(
|
|
|
1865
1892
|
const root = resolve(opts.root ?? process.cwd())
|
|
1866
1893
|
const emitJson = opts.json ?? false
|
|
1867
1894
|
|
|
1895
|
+
if (opts.link === true) return runKeyChangesLink(number, opts, root)
|
|
1896
|
+
|
|
1868
1897
|
intro('canon pr key-changes')
|
|
1869
1898
|
|
|
1870
1899
|
const source =
|
|
@@ -1949,6 +1978,14 @@ async function runKeyChanges(
|
|
|
1949
1978
|
for (const claim of report.unresolved) logInfo(claim.path)
|
|
1950
1979
|
}
|
|
1951
1980
|
|
|
1981
|
+
if (report.mislinked.length > 0) {
|
|
1982
|
+
logStep('Mislinked')
|
|
1983
|
+
logWarn(
|
|
1984
|
+
`${plural(report.mislinked.length, 'link')} open another file's diff than the path they name. Re-run with --link to print the body with them corrected.`,
|
|
1985
|
+
)
|
|
1986
|
+
for (const link of report.mislinked) logWarn(link.path)
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1952
1989
|
outro()
|
|
1953
1990
|
|
|
1954
1991
|
if (emitJson) {
|
|
@@ -1966,11 +2003,71 @@ async function runKeyChanges(
|
|
|
1966
2003
|
incidental: report.incidental,
|
|
1967
2004
|
unresolved: report.unresolved,
|
|
1968
2005
|
evidenceUnread: report.evidenceUnread,
|
|
2006
|
+
mislinked: report.mislinked,
|
|
1969
2007
|
})}\n`,
|
|
1970
2008
|
)
|
|
1971
2009
|
}
|
|
1972
2010
|
|
|
1973
|
-
return report.unmet.length === 0 ? 0 : 2
|
|
2011
|
+
return report.unmet.length === 0 && report.mislinked.length === 0 ? 0 : 2
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/**
|
|
2015
|
+
* Prints the body with each changed path in `## Key changes` linked to its
|
|
2016
|
+
* diff, and nothing else, so stdout pipes straight into a body file.
|
|
2017
|
+
*
|
|
2018
|
+
* Skips the frame `intro` opens, which would land in the piped body. Refusals
|
|
2019
|
+
* keep the framed shape on stdout like every other mode.
|
|
2020
|
+
*/
|
|
2021
|
+
async function runKeyChangesLink(
|
|
2022
|
+
number: string | undefined,
|
|
2023
|
+
opts: KeyChangesOptions,
|
|
2024
|
+
root: string,
|
|
2025
|
+
): Promise<number> {
|
|
2026
|
+
const emitJson = opts.json ?? false
|
|
2027
|
+
|
|
2028
|
+
if (emitJson) {
|
|
2029
|
+
return refuseWith(
|
|
2030
|
+
'link-json',
|
|
2031
|
+
'--link prints the body itself, so it takes no --json record.',
|
|
2032
|
+
emitJson,
|
|
2033
|
+
root,
|
|
2034
|
+
)
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
const source =
|
|
2038
|
+
opts.body === undefined
|
|
2039
|
+
? await readFromApi(root, number)
|
|
2040
|
+
: await readFromFile(root, process.cwd(), opts.body, opts.base)
|
|
2041
|
+
if (source.kind === 'refused') return refuse(source.reason, emitJson, root)
|
|
2042
|
+
|
|
2043
|
+
const pull = number ?? source.source.number?.toString()
|
|
2044
|
+
if (pull === undefined) {
|
|
2045
|
+
return refuseWith(
|
|
2046
|
+
'no-number',
|
|
2047
|
+
'The link needs the pull request number, so pass it as the argument.',
|
|
2048
|
+
emitJson,
|
|
2049
|
+
root,
|
|
2050
|
+
)
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const url = await readRepoUrl(root)
|
|
2054
|
+
if (url === undefined) {
|
|
2055
|
+
return refuseWith(
|
|
2056
|
+
'no-repo-url',
|
|
2057
|
+
'gh could not read the repository address, so no link was built.',
|
|
2058
|
+
emitJson,
|
|
2059
|
+
root,
|
|
2060
|
+
)
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
process.stdout.write(
|
|
2064
|
+
linkKeyChanges(
|
|
2065
|
+
source.source.body,
|
|
2066
|
+
source.source.changed,
|
|
2067
|
+
`${url}/pull/${pull}/files`,
|
|
2068
|
+
),
|
|
2069
|
+
)
|
|
2070
|
+
return 0
|
|
1974
2071
|
}
|
|
1975
2072
|
|
|
1976
2073
|
type IdentityRead =
|
|
@@ -2494,14 +2591,18 @@ interface PullFiles {
|
|
|
2494
2591
|
readonly present: ReadonlyMap<string, boolean>
|
|
2495
2592
|
/** Each path the pull request removed, which a deploy's push filter still counts. */
|
|
2496
2593
|
readonly removed: readonly string[]
|
|
2594
|
+
/** Each renamed path mapped to the path it sat at before the pull request. */
|
|
2595
|
+
readonly renamedFrom: ReadonlyMap<string, string>
|
|
2497
2596
|
}
|
|
2498
2597
|
|
|
2499
2598
|
/**
|
|
2500
2599
|
* The paths a pull request changed, read through the paginated files endpoint
|
|
2501
2600
|
* so the set is the same from any checkout and never capped at the first
|
|
2502
|
-
* view. `
|
|
2503
|
-
*
|
|
2504
|
-
*
|
|
2601
|
+
* view. `copied` counts as added, since a copy leaves its source in place and
|
|
2602
|
+
* has no single base. `renamed` counts as added in `present` too, so the
|
|
2603
|
+
* changed set stays the same, and `renamedFrom` carries the old path of each
|
|
2604
|
+
* rename for a reader that compares against the base. Returns undefined when
|
|
2605
|
+
* the read fails, which refuses rather than rendering a short set.
|
|
2505
2606
|
*/
|
|
2506
2607
|
async function listPullFiles(
|
|
2507
2608
|
cwd: string,
|
|
@@ -2512,18 +2613,22 @@ async function listPullFiles(
|
|
|
2512
2613
|
'--paginate',
|
|
2513
2614
|
`repos/{owner}/{repo}/pulls/${number}/files`,
|
|
2514
2615
|
'--jq',
|
|
2515
|
-
'.[] | [.status, .filename] | @tsv',
|
|
2616
|
+
'.[] | [.status, .filename, (.previous_filename // "")] | @tsv',
|
|
2516
2617
|
])
|
|
2517
2618
|
if (stdout === null) return undefined
|
|
2518
2619
|
const present = new Map<string, boolean>()
|
|
2519
2620
|
const removed: string[] = []
|
|
2621
|
+
const renamedFrom = new Map<string, string>()
|
|
2520
2622
|
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
2521
|
-
const [status, path] = line.split('\t')
|
|
2623
|
+
const [status, path, previous] = line.split('\t')
|
|
2522
2624
|
if (status === undefined || path === undefined) continue
|
|
2523
2625
|
if (status === 'removed') removed.push(path)
|
|
2524
2626
|
else present.set(path, status !== 'modified' && status !== 'changed')
|
|
2627
|
+
if (status === 'renamed' && previous !== undefined && previous !== '') {
|
|
2628
|
+
renamedFrom.set(path, previous)
|
|
2629
|
+
}
|
|
2525
2630
|
}
|
|
2526
|
-
return { present, removed }
|
|
2631
|
+
return { present, removed, renamedFrom }
|
|
2527
2632
|
}
|
|
2528
2633
|
|
|
2529
2634
|
function everyChangedPath(files: PullFiles): string[] {
|
|
@@ -2671,13 +2776,14 @@ async function measureItem(
|
|
|
2671
2776
|
head: string,
|
|
2672
2777
|
): Promise<MeasuredItem> {
|
|
2673
2778
|
const headRead = await readImage(cwd, item.path, head)
|
|
2779
|
+
const basePath = item.basePath ?? item.path
|
|
2674
2780
|
const baseRead =
|
|
2675
2781
|
item.added || base === undefined
|
|
2676
2782
|
? undefined
|
|
2677
|
-
: await readImage(cwd,
|
|
2783
|
+
: await readImage(cwd, basePath, base)
|
|
2678
2784
|
const unread = [
|
|
2679
2785
|
...(headRead.kind === 'failed' ? [`head:${item.path}`] : []),
|
|
2680
|
-
...(baseRead?.kind === 'failed' ? [`base:${
|
|
2786
|
+
...(baseRead?.kind === 'failed' ? [`base:${basePath}`] : []),
|
|
2681
2787
|
]
|
|
2682
2788
|
const headBytes = headRead.kind === 'read' ? headRead.bytes : undefined
|
|
2683
2789
|
const baseBytes = baseRead?.kind === 'read' ? baseRead.bytes : undefined
|
|
@@ -2691,6 +2797,9 @@ async function measureItem(
|
|
|
2691
2797
|
...(headBytes !== undefined &&
|
|
2692
2798
|
baseBytes !== undefined && {
|
|
2693
2799
|
withinTolerance: isWithinTolerance(baseBytes, headBytes),
|
|
2800
|
+
...(item.basePath !== undefined && {
|
|
2801
|
+
identical: Buffer.from(baseBytes).equals(headBytes),
|
|
2802
|
+
}),
|
|
2694
2803
|
}),
|
|
2695
2804
|
},
|
|
2696
2805
|
unread,
|
|
@@ -2811,6 +2920,7 @@ async function runEvidence(
|
|
|
2811
2920
|
const grouped = await groupEvidence(
|
|
2812
2921
|
[...pullFiles.present.keys()],
|
|
2813
2922
|
async (path) => pullFiles.present.get(path) === false,
|
|
2923
|
+
pullFiles.renamedFrom,
|
|
2814
2924
|
)
|
|
2815
2925
|
const uncaptured = readUncaptured(
|
|
2816
2926
|
everyChangedPath(pullFiles),
|
|
@@ -3412,6 +3522,7 @@ async function runLocal(opts: LocalOptions): Promise<number> {
|
|
|
3412
3522
|
worktree,
|
|
3413
3523
|
machineLocalRunner(),
|
|
3414
3524
|
declaration.kind === 'paths' ? declaration.paths : [],
|
|
3525
|
+
declaration.pageOnly,
|
|
3415
3526
|
)
|
|
3416
3527
|
if (result.kind === 'refused' && result.reason === 'unhealthy') {
|
|
3417
3528
|
const { port, path, status } = result
|
|
@@ -4028,6 +4139,18 @@ async function readRepoName(root: string): Promise<string | undefined> {
|
|
|
4028
4139
|
}
|
|
4029
4140
|
}
|
|
4030
4141
|
|
|
4142
|
+
/** The repository's web address off the REST repository read, for the same reason as `readRepoName`. */
|
|
4143
|
+
async function readRepoUrl(root: string): Promise<string | undefined> {
|
|
4144
|
+
const row = await gh(root, ['api', 'repos/{owner}/{repo}'])
|
|
4145
|
+
if (row === null) return undefined
|
|
4146
|
+
try {
|
|
4147
|
+
const url = (JSON.parse(row) as { html_url?: unknown }).html_url
|
|
4148
|
+
return typeof url === 'string' && url !== '' ? url : undefined
|
|
4149
|
+
} catch {
|
|
4150
|
+
return undefined
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4031
4154
|
async function runFrames(
|
|
4032
4155
|
number: string | undefined,
|
|
4033
4156
|
opts: FramesOptions,
|
package/src/commands/sessions.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type SessionReport,
|
|
12
12
|
selfOf,
|
|
13
13
|
} from '@/sessions/resolve'
|
|
14
|
+
import { readStop, type StopReading } from '@/sessions/stop'
|
|
14
15
|
import {
|
|
15
16
|
type Behind,
|
|
16
17
|
exportSession,
|
|
@@ -32,8 +33,11 @@ interface ListCommandOptions {
|
|
|
32
33
|
readonly branch?: string
|
|
33
34
|
readonly repository?: string
|
|
34
35
|
readonly self?: boolean
|
|
36
|
+
readonly stopped?: boolean
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
type ListedSession = ResolvedSession & StopReading
|
|
40
|
+
|
|
37
41
|
const REASONS: Record<string, string> = {
|
|
38
42
|
'not-a-repository': 'working outside any git repository',
|
|
39
43
|
'detached-head': 'detached HEAD, so the worktree holds no branch name',
|
|
@@ -67,6 +71,7 @@ export function register(program: Command): void {
|
|
|
67
71
|
'--self',
|
|
68
72
|
"Report the caller's own row, and refuse where the roster holds none",
|
|
69
73
|
)
|
|
74
|
+
.option('--stopped', 'Report only the sessions a usage limit stopped')
|
|
70
75
|
.addHelpText(
|
|
71
76
|
'after',
|
|
72
77
|
[
|
|
@@ -161,12 +166,30 @@ export function register(program: Command): void {
|
|
|
161
166
|
'framed listing renders the same dwell beside the status, at the',
|
|
162
167
|
'coarsest unit that keeps it a whole number.',
|
|
163
168
|
'',
|
|
169
|
+
'Each row also carries "stopped" and "stopResets", read from the tail of',
|
|
170
|
+
'the session transcript. "stopped" is "usage-limit" when the last',
|
|
171
|
+
'assistant entry is a limit stop, passing over a synthetic entry with no',
|
|
172
|
+
'error, such as the one a client restart writes. "stopResets" holds the',
|
|
173
|
+
'text the client wrote after "resets", or null when it wrote none.',
|
|
174
|
+
'"stopped" is "unknown" when the transcript could not be read or a',
|
|
175
|
+
'limit-worded entry has another shape, so the row may be stopped, and',
|
|
176
|
+
'null otherwise. A "busy" row reads',
|
|
177
|
+
'null without opening its transcript, since a turn is running.',
|
|
178
|
+
'',
|
|
179
|
+
'--stopped keeps the "usage-limit" rows after --branch and --self apply.',
|
|
180
|
+
'It drops "unknown" rows, and "stopsUnread" counts them before it does,',
|
|
181
|
+
'so a count above zero means a row here may be stopped and is not listed.',
|
|
182
|
+
'"foreignDead" above zero leaves the same doubt about a session this',
|
|
183
|
+
'reader could not check, and an empty result says so on stderr.',
|
|
184
|
+
'An empty result exits 0. "claimed" still reads the whole roster.',
|
|
185
|
+
'',
|
|
164
186
|
'Examples:',
|
|
165
187
|
' canon sessions list',
|
|
166
188
|
' canon sessions list --json',
|
|
167
189
|
' canon sessions list --branch feat/parser --json',
|
|
168
190
|
' canon sessions list --branch chore/agents --repository ../caret --json',
|
|
169
191
|
' canon sessions list --self --json',
|
|
192
|
+
' canon sessions list --stopped --json',
|
|
170
193
|
'',
|
|
171
194
|
].join('\n'),
|
|
172
195
|
)
|
|
@@ -477,15 +500,37 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
477
500
|
? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
|
|
478
501
|
: null
|
|
479
502
|
|
|
503
|
+
const read: readonly ListedSession[] = shown.map((session) => ({
|
|
504
|
+
...session,
|
|
505
|
+
...readStop(session),
|
|
506
|
+
}))
|
|
507
|
+
const stopsUnread = read.filter(
|
|
508
|
+
(session) => session.stopped === 'unknown',
|
|
509
|
+
).length
|
|
510
|
+
const listed = opts.stopped
|
|
511
|
+
? read.filter((session) => session.stopped === 'usage-limit')
|
|
512
|
+
: read
|
|
513
|
+
|
|
480
514
|
intro('canon sessions list')
|
|
481
|
-
reportConfidence(
|
|
515
|
+
reportConfidence(listed, report)
|
|
482
516
|
reportSessions(
|
|
483
|
-
|
|
517
|
+
listed,
|
|
484
518
|
opts.branch,
|
|
485
519
|
repository,
|
|
486
520
|
own !== null,
|
|
487
521
|
report.foreignDead,
|
|
522
|
+
opts.stopped ? stopsUnread : null,
|
|
488
523
|
)
|
|
524
|
+
if (opts.stopped && stopsUnread > 0) {
|
|
525
|
+
logWarn(
|
|
526
|
+
`${plural(stopsUnread, 'row')} could not be read and may be stopped, so this list can be short.`,
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
if (opts.stopped && own === null && report.foreignDead > 0) {
|
|
530
|
+
logWarn(
|
|
531
|
+
`${report.foreignDead} records from another pid namespace have no answering socket, so a stopped session may be missing from this list.`,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
489
534
|
if (claim) reportClaim(claim)
|
|
490
535
|
outro()
|
|
491
536
|
|
|
@@ -494,8 +539,9 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
494
539
|
`${JSON.stringify({
|
|
495
540
|
dir: report.dir,
|
|
496
541
|
// Over the rows shown, so an unrelated row --branch dropped cannot weaken it.
|
|
497
|
-
confidence: confidenceOf(
|
|
542
|
+
confidence: confidenceOf(listed),
|
|
498
543
|
foreignDead: report.foreignDead,
|
|
544
|
+
stopsUnread,
|
|
499
545
|
branch: opts.branch ?? null,
|
|
500
546
|
repository,
|
|
501
547
|
worktree: claim?.worktree ?? null,
|
|
@@ -503,7 +549,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
503
549
|
claimed: claim?.claimed ?? null,
|
|
504
550
|
sessionsReadable: claim?.sessionsReadable ?? null,
|
|
505
551
|
refsReadable: claim?.refsReadable ?? null,
|
|
506
|
-
sessions:
|
|
552
|
+
sessions: listed,
|
|
507
553
|
})}\n`,
|
|
508
554
|
)
|
|
509
555
|
}
|
|
@@ -578,11 +624,12 @@ function reportConfidence(
|
|
|
578
624
|
}
|
|
579
625
|
|
|
580
626
|
function reportSessions(
|
|
581
|
-
sessions: readonly
|
|
627
|
+
sessions: readonly ListedSession[],
|
|
582
628
|
branch: string | undefined,
|
|
583
629
|
repository: string | null,
|
|
584
630
|
scoped: boolean,
|
|
585
631
|
foreignDead: number,
|
|
632
|
+
stopsUnread: number | null,
|
|
586
633
|
): void {
|
|
587
634
|
logStep('Sessions')
|
|
588
635
|
|
|
@@ -596,6 +643,11 @@ function reportSessions(
|
|
|
596
643
|
// was narrowed to one row before the branch filter ran. Reporting the wider
|
|
597
644
|
// answer there would claim a reading this run never took.
|
|
598
645
|
if (sessions.length === 0) {
|
|
646
|
+
if (stopsUnread !== null) {
|
|
647
|
+
logInfo(noneStopped(branch, scoped, foreignDead, stopsUnread))
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
599
651
|
if (branch) {
|
|
600
652
|
logInfo(
|
|
601
653
|
scoped
|
|
@@ -630,12 +682,42 @@ function reportSessions(
|
|
|
630
682
|
`unresolved: ${REASONS[session.unresolved ?? ''] ?? 'unknown'}`
|
|
631
683
|
const dwell = formatDwell(session.statusDwellMs)
|
|
632
684
|
const status = dwell ? `${session.status} ${dwell}` : session.status
|
|
633
|
-
return
|
|
685
|
+
return [
|
|
686
|
+
`${session.name} ${status} ${held}`,
|
|
687
|
+
` ${session.cwd}`,
|
|
688
|
+
...stopLine(session),
|
|
689
|
+
].join('\n')
|
|
634
690
|
})
|
|
635
691
|
.join('\n'),
|
|
636
692
|
)
|
|
637
693
|
}
|
|
638
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Words an empty `--stopped` result by what was read, since "no session is
|
|
697
|
+
* stopped" is a claim about the machine that a row this run could not read, or
|
|
698
|
+
* a record it could not check, leaves open.
|
|
699
|
+
*/
|
|
700
|
+
function noneStopped(
|
|
701
|
+
branch: string | undefined,
|
|
702
|
+
scoped: boolean,
|
|
703
|
+
foreignDead: number,
|
|
704
|
+
stopsUnread: number,
|
|
705
|
+
): string {
|
|
706
|
+
if (stopsUnread > 0 || (!scoped && foreignDead > 0)) {
|
|
707
|
+
return 'No session this reader could check is stopped.'
|
|
708
|
+
}
|
|
709
|
+
if (scoped) return 'This session is not stopped.'
|
|
710
|
+
return branch
|
|
711
|
+
? `No session holding ${branch} is stopped.`
|
|
712
|
+
: 'No session is stopped.'
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function stopLine(session: StopReading): string[] {
|
|
716
|
+
if (session.stopped === null) return []
|
|
717
|
+
const resets = session.stopResets ? `, resets ${session.stopResets}` : ''
|
|
718
|
+
return [` stopped: ${session.stopped}${resets}`]
|
|
719
|
+
}
|
|
720
|
+
|
|
639
721
|
/**
|
|
640
722
|
* Renders the dwell at the coarsest unit that keeps it a whole number, since a
|
|
641
723
|
* reader scanning a roster wants an age at a glance rather than a millisecond
|
package/src/commands/tasks.ts
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
type ValidateOutcome,
|
|
73
73
|
validateBoard,
|
|
74
74
|
} from '@/tasks/validate'
|
|
75
|
+
import { liveReaders } from '@/tasks/holders'
|
|
75
76
|
import {
|
|
76
77
|
intro,
|
|
77
78
|
logAdd,
|
|
@@ -110,6 +111,7 @@ interface DeclineCommandOptions {
|
|
|
110
111
|
interface ValidateCommandOptions {
|
|
111
112
|
readonly json?: boolean
|
|
112
113
|
readonly root?: string
|
|
114
|
+
readonly live?: boolean
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
interface CitationsCommandOptions {
|
|
@@ -323,6 +325,10 @@ export function register(program: Command): void {
|
|
|
323
325
|
.helpOption('-h, --help', 'Show this help message')
|
|
324
326
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
325
327
|
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
328
|
+
.option(
|
|
329
|
+
'--live',
|
|
330
|
+
'Also read the session roster and open pull requests for held rows',
|
|
331
|
+
)
|
|
326
332
|
.addHelpText(
|
|
327
333
|
'after',
|
|
328
334
|
[
|
|
@@ -338,6 +344,12 @@ export function register(program: Command): void {
|
|
|
338
344
|
` no Waiting on cell renders past ${WAITING_LIMIT} characters`,
|
|
339
345
|
' a blocker citing a bare vNNN.N label settles like a linked one',
|
|
340
346
|
' a file two Run now rows share is listed, never failed',
|
|
347
|
+
' with --live, no Up next or Needs a plan row is held by a live session',
|
|
348
|
+
" or an open pull request on its plan's branch",
|
|
349
|
+
'',
|
|
350
|
+
'The `live` field states each --live source as read, unread, or not-asked.',
|
|
351
|
+
'A source that fails reads as unread and moves no exit code, so read that',
|
|
352
|
+
'field rather than taking exit 0 as clean. A run without --live reads neither.',
|
|
341
353
|
'',
|
|
342
354
|
'Refusals, read off `reason`:',
|
|
343
355
|
' no-board the repository holds no `.canon/tasks/` board',
|
|
@@ -356,6 +368,7 @@ export function register(program: Command): void {
|
|
|
356
368
|
'Examples:',
|
|
357
369
|
' canon tasks validate',
|
|
358
370
|
' canon tasks validate --json',
|
|
371
|
+
' canon tasks validate --live --json',
|
|
359
372
|
'',
|
|
360
373
|
].join('\n'),
|
|
361
374
|
)
|
|
@@ -1911,7 +1924,10 @@ function reportNextLabel(
|
|
|
1911
1924
|
|
|
1912
1925
|
async function runValidate(opts: ValidateCommandOptions): Promise<number> {
|
|
1913
1926
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
1914
|
-
const outcome = await validateBoard(
|
|
1927
|
+
const outcome = await validateBoard(
|
|
1928
|
+
root,
|
|
1929
|
+
opts.live ? { live: liveReaders(root) } : {},
|
|
1930
|
+
)
|
|
1915
1931
|
|
|
1916
1932
|
return reportValidation(outcome, opts.json ?? false, root)
|
|
1917
1933
|
}
|
|
@@ -2390,8 +2406,30 @@ function reportValidation(
|
|
|
2390
2406
|
} else {
|
|
2391
2407
|
for (const task of outcome.unplaced) logWarn(describeUnplaced(task))
|
|
2392
2408
|
}
|
|
2393
|
-
|
|
2409
|
+
|
|
2410
|
+
if (outcome.live.sessions !== 'not-asked') {
|
|
2411
|
+
logStep('Held rows')
|
|
2412
|
+
if (
|
|
2413
|
+
outcome.live.sessions === 'read' &&
|
|
2414
|
+
outcome.live.pulls === 'read' &&
|
|
2415
|
+
!outcome.findings.some((finding) => finding.kind === 'row-held')
|
|
2416
|
+
) {
|
|
2417
|
+
logInfo('no row outside run now is held by a session or a pull request')
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// Unread is a warning in both modes, since diagnostics reach stderr with --json too.
|
|
2423
|
+
if (outcome.live.sessions === 'unread') {
|
|
2424
|
+
logWarn('the session roster could not be read, so no session was checked')
|
|
2394
2425
|
}
|
|
2426
|
+
if (outcome.live.pulls === 'unread') {
|
|
2427
|
+
logWarn(
|
|
2428
|
+
'pull requests could not be read, so a hold may be missed or already merged',
|
|
2429
|
+
)
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
if (!emitJson) outro()
|
|
2395
2433
|
|
|
2396
2434
|
if (emitJson) {
|
|
2397
2435
|
process.stdout.write(
|
|
@@ -2408,6 +2446,7 @@ function reportValidation(
|
|
|
2408
2446
|
shared: outcome.shared,
|
|
2409
2447
|
wide: outcome.wide,
|
|
2410
2448
|
unplaced: outcome.unplaced,
|
|
2449
|
+
live: outcome.live,
|
|
2411
2450
|
})}\n`,
|
|
2412
2451
|
)
|
|
2413
2452
|
}
|
package/src/commands/tooling.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
38
38
|
import { recordToolingChain } from '@/tooling/stamp'
|
|
39
39
|
import { subfolderPath } from '@/tooling/subfolder'
|
|
40
|
+
import { scaffoldStack } from '@/tooling/scaffold'
|
|
40
41
|
import { verifyStack } from '@/tooling/verify-stack'
|
|
41
42
|
import {
|
|
42
43
|
intro,
|
|
@@ -377,6 +378,77 @@ export function register(program: Command): void {
|
|
|
377
378
|
})
|
|
378
379
|
process.exitCode = outcome.exitCode
|
|
379
380
|
})
|
|
381
|
+
|
|
382
|
+
tooling
|
|
383
|
+
.command('scaffold')
|
|
384
|
+
.description("Run a stack's starter command into an empty folder")
|
|
385
|
+
.argument('<stack>', 'Tooling stack name (e.g. vite-react, astro)')
|
|
386
|
+
.argument('[target]', 'Target directory, created when missing', '.')
|
|
387
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
388
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
389
|
+
.addHelpText(
|
|
390
|
+
'after',
|
|
391
|
+
[
|
|
392
|
+
'',
|
|
393
|
+
"Runs the stack's manifest scaffold in a scratch folder, with the",
|
|
394
|
+
"target's folder name as the project name, then moves the result into",
|
|
395
|
+
'the target. A target holding anything but .git is refused, and a',
|
|
396
|
+
'failed command moves nothing and leaves the scratch folder named.',
|
|
397
|
+
'Run `canon init` or the target-setup skill afterward to sync tooling.',
|
|
398
|
+
'',
|
|
399
|
+
'Exit codes:',
|
|
400
|
+
' 0 scaffolded',
|
|
401
|
+
' 1 refused: excluded, no-manifest, no-scaffold, not-empty, bad-name,',
|
|
402
|
+
' scaffold-failed, or no-output',
|
|
403
|
+
'',
|
|
404
|
+
'Examples:',
|
|
405
|
+
' canon tooling scaffold vite-react',
|
|
406
|
+
' canon tooling scaffold astro my-site --json',
|
|
407
|
+
'',
|
|
408
|
+
].join('\n'),
|
|
409
|
+
)
|
|
410
|
+
.action(async (stack: string, target: string, opts: ListOptions) => {
|
|
411
|
+
process.exitCode = await runScaffold(stack, target, opts)
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A `--json` run writes no frame, so a skill reads the record alone, and a
|
|
417
|
+
* refusal carries its reason on stdout beside the exit 1.
|
|
418
|
+
*/
|
|
419
|
+
async function runScaffold(
|
|
420
|
+
stack: string,
|
|
421
|
+
target: string,
|
|
422
|
+
opts: ListOptions,
|
|
423
|
+
): Promise<number> {
|
|
424
|
+
const json = opts.json === true
|
|
425
|
+
if (!json) intro(`canon tooling scaffold ${stack}`)
|
|
426
|
+
|
|
427
|
+
const result = await scaffoldStack({ root: PROJECT_ROOT, stack, target })
|
|
428
|
+
|
|
429
|
+
if (!result.ok) {
|
|
430
|
+
logError(result.message)
|
|
431
|
+
if (result.scratch !== undefined) {
|
|
432
|
+
if (result.output !== undefined && result.output !== '') {
|
|
433
|
+
process.stderr.write(
|
|
434
|
+
`${result.output.split('\n').slice(-20).join('\n')}\n`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
logInfo(`Kept ${result.scratch}`)
|
|
438
|
+
}
|
|
439
|
+
if (json) process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
440
|
+
else outro()
|
|
441
|
+
return 1
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (json) {
|
|
445
|
+
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
446
|
+
return 0
|
|
447
|
+
}
|
|
448
|
+
logStep(`Scaffolded ${result.target}`)
|
|
449
|
+
for (const file of result.files) logAdd(file)
|
|
450
|
+
outroSuccess('Scaffolded', `Next: canon init ${target}`)
|
|
451
|
+
return 0
|
|
380
452
|
}
|
|
381
453
|
|
|
382
454
|
/**
|