@erclx/canon 5.26.0 → 5.28.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/README.md +2 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +2 -0
- package/claude/skills/auto-ship/SKILL.md +3 -11
- package/claude/skills/auto-ship/references/review-findings.md +20 -0
- package/claude/skills/backlog-triage/SKILL.md +2 -0
- package/claude/skills/bash-cli-script/SKILL.md +2 -0
- package/claude/skills/build-in-slices/SKILL.md +2 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/canon-feedback/SKILL.md +2 -0
- package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
- package/claude/skills/canon-operator/SKILL.md +2 -0
- package/claude/skills/canon-rollout/SKILL.md +3 -11
- package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
- package/claude/skills/canvas/SKILL.md +13 -0
- package/claude/skills/ci-workflow/SKILL.md +2 -0
- package/claude/skills/code-craft/SKILL.md +2 -0
- package/claude/skills/codebase-layout/SKILL.md +2 -0
- package/claude/skills/context-fold/SKILL.md +3 -17
- package/claude/skills/context-fold/references/completion-report.md +26 -0
- package/claude/skills/create-rule/SKILL.md +2 -0
- package/claude/skills/create-skill/SKILL.md +2 -0
- package/claude/skills/create-standard/SKILL.md +2 -0
- package/claude/skills/decision-escalate/SKILL.md +2 -0
- package/claude/skills/deploy-app/SKILL.md +2 -0
- package/claude/skills/deprecation-migration/SKILL.md +2 -0
- package/claude/skills/design-extract/SKILL.md +2 -0
- package/claude/skills/design-taste/SKILL.md +2 -0
- package/claude/skills/docs-sync/SKILL.md +2 -0
- package/claude/skills/document-health/SKILL.md +2 -0
- package/claude/skills/draft-and-pick/SKILL.md +2 -0
- package/claude/skills/draft-doc/SKILL.md +2 -0
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +2 -0
- package/claude/skills/draft-identity/SKILL.md +2 -0
- package/claude/skills/draft-ready/SKILL.md +5 -1
- package/claude/skills/draft-screencast/SKILL.md +2 -0
- package/claude/skills/draft-slides/SKILL.md +2 -0
- package/claude/skills/git-branch/SKILL.md +2 -0
- package/claude/skills/git-commit/SKILL.md +2 -0
- package/claude/skills/git-followup/SKILL.md +2 -0
- package/claude/skills/git-issue/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/evidence.md +7 -7
- package/claude/skills/git-ship/SKILL.md +2 -0
- package/claude/skills/git-split/SKILL.md +2 -0
- package/claude/skills/git-stage/SKILL.md +2 -0
- package/claude/skills/git-worktree/SKILL.md +2 -0
- package/claude/skills/index-lookup/SKILL.md +2 -0
- package/claude/skills/markdown-propose/SKILL.md +2 -0
- package/claude/skills/memory-capture/SKILL.md +2 -0
- package/claude/skills/memory-review/SKILL.md +2 -0
- package/claude/skills/plan-feature/SKILL.md +2 -0
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/plan-intake/SKILL.md +2 -0
- package/claude/skills/plan-intake-answer/SKILL.md +2 -0
- package/claude/skills/project-commands/SKILL.md +2 -0
- package/claude/skills/read-frames/SKILL.md +2 -0
- package/claude/skills/record-screencast/SKILL.md +2 -0
- package/claude/skills/repo-metadata/SKILL.md +2 -0
- package/claude/skills/restate-plainly/SKILL.md +2 -0
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +19 -14
- package/claude/skills/review-address/references/post-review-findings.md +16 -0
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-branch/SKILL.md +2 -0
- package/claude/skills/review-craft/SKILL.md +2 -0
- package/claude/skills/review-craft/references/ui.md +6 -6
- package/claude/skills/review-pr/SKILL.md +4 -8
- package/claude/skills/review-pr/references/body-variants.md +52 -0
- package/claude/skills/review-pr/references/key-changes.md +16 -0
- package/claude/skills/review-pr/references/post.md +15 -43
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +16 -12
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +13 -3
- package/claude/skills/review-ui/references/tick.md +22 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
- package/claude/skills/role-orchestrator/SKILL.md +31 -42
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +12 -23
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
- package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +4 -2
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +3 -1
- package/claude/skills/role-worker/SKILL.md +5 -2
- package/claude/skills/search-craft/SKILL.md +2 -0
- package/claude/skills/seed-sync/SKILL.md +2 -0
- package/claude/skills/session-compact/SKILL.md +2 -0
- package/claude/skills/session-map/SKILL.md +2 -0
- package/claude/skills/session-relay/SKILL.md +2 -0
- package/claude/skills/session-resume/SKILL.md +2 -0
- package/claude/skills/session-worktree/SKILL.md +3 -11
- package/claude/skills/session-worktree/references/install-command.md +20 -0
- package/claude/skills/sketch-design/SKILL.md +2 -0
- package/claude/skills/systematic-debugging/SKILL.md +2 -0
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/task-board/SKILL.md +2 -0
- package/claude/skills/teach-workspace/SKILL.md +5 -15
- package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
- package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
- package/claude/skills/teach-workspace/references/revisit.md +22 -0
- package/claude/skills/test-craft/SKILL.md +2 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/ui-checklist/SKILL.md +3 -1
- package/claude/skills/ux-audit/SKILL.md +2 -0
- package/claude/skills/ux-measure/SKILL.md +2 -0
- package/claude/skills/ux-walkthrough/SKILL.md +2 -0
- package/claude/skills/video-craft/SKILL.md +2 -0
- package/claude/skills/write-human/SKILL.md +2 -0
- package/claude/skills/youtube-transcripts/SKILL.md +2 -0
- package/docs/agents/canvas.md +24 -8
- package/docs/agents/capture.md +5 -5
- package/docs/agents/commands.md +2 -2
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +5 -4
- package/docs/agents/install-and-sync.md +7 -0
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/pr-evidence.md +47 -23
- package/docs/agents/pr-local.md +19 -13
- package/docs/agents/pr-preview.md +44 -21
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +30 -6
- package/docs/agents/scripting.md +8 -1
- package/docs/agents/skills-audit.md +2 -1
- package/docs/agents/tasks-plans.md +1 -1
- package/docs/agents/tasks-validate.md +18 -18
- package/docs/agents/tasks.md +1 -1
- package/docs/workflow/operating-model.md +15 -21
- package/docs/workflow/parallel-features.md +24 -3
- package/docs/workflow/skill-map.md +3 -1
- package/docs/workflow/zshrc-aliases.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.ts +1 -1
- package/scripts/lib/sandbox-path.sh +6 -162
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +2 -2
- package/src/canvas/address.ts +12 -3
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/inspector/align-grid.tsx +162 -0
- package/src/canvas/client/inspector/color-field.tsx +201 -171
- package/src/canvas/client/inspector/color-inputs.tsx +139 -0
- package/src/canvas/client/inspector/color-picker.tsx +668 -0
- package/src/canvas/client/inspector/color-space.ts +240 -0
- package/src/canvas/client/inspector/field.tsx +66 -3
- package/src/canvas/client/inspector/fields.css +473 -6
- package/src/canvas/client/inspector/size-field.tsx +69 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +117 -3
- package/src/canvas/client/inspector.tsx +311 -15
- package/src/canvas/client/keys.ts +54 -0
- package/src/canvas/client/selection.tsx +199 -0
- package/src/canvas/client/shell.css +210 -12
- package/src/canvas/client/state.ts +165 -14
- package/src/canvas/client/surface.tsx +341 -39
- package/src/canvas/client/tools.tsx +64 -0
- package/src/canvas/content.ts +51 -2
- package/src/canvas/edit.ts +49 -6
- package/src/canvas/editing.ts +203 -0
- package/src/canvas/global.d.ts +15 -0
- package/src/canvas/server.ts +149 -12
- package/src/canvas/tokens.ts +60 -18
- package/src/census/count.ts +1 -1
- package/src/claude/seeds.ts +3 -3
- package/src/claude/skills-audit.ts +28 -0
- package/src/claude/skills-drift.ts +1 -1
- package/src/claude/skills-families.ts +36 -0
- package/src/claude/skills-list.ts +76 -15
- package/src/claude/skills-reach.ts +1 -1
- package/src/commands/audits.ts +2 -2
- package/src/commands/canvas.ts +180 -3
- package/src/commands/claude.ts +70 -12
- package/src/commands/design.ts +145 -5
- package/src/commands/docs.ts +1 -1
- package/src/commands/feedback.ts +2 -2
- package/src/commands/gate.ts +9 -2
- package/src/commands/gov.ts +3 -3
- package/src/commands/hooks.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +2 -2
- package/src/commands/pr.ts +288 -65
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +64 -15
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +1 -1
- package/src/commands/sync.ts +2 -2
- package/src/commands/tasks.ts +21 -4
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +1 -1
- package/src/commands/transcripts.ts +2 -2
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/wiki.ts +1 -1
- package/src/commands/worktrees.ts +1 -1
- package/src/comments/scan.ts +1 -1
- package/src/comments/trend.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +3 -3
- package/src/context/classify/extract.ts +3 -3
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/design/project-fonts.ts +392 -0
- package/src/docs/read.ts +1 -1
- package/src/exec.ts +1 -1
- package/src/feedback/github.ts +2 -2
- package/src/gate/measures.ts +48 -10
- package/src/gate/sequencer.ts +56 -4
- package/src/gate/stages.ts +17 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/{git-files.ts → git/files.ts} +2 -2
- package/src/{git-ignore.ts → git/ignore.ts} +1 -1
- package/src/{worktree.ts → git/worktree.ts} +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/install.ts +1 -1
- package/src/gov/superseded.ts +1 -1
- package/src/gov/test-order.ts +2 -2
- package/src/help.ts +1 -1
- package/src/hooks/post-merge.ts +1 -1
- package/src/indexes/walk.ts +1 -1
- package/src/intake/folder.ts +1 -1
- package/src/labels/audit.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/markdown/files.ts +1 -1
- package/src/migrate/apply.ts +1 -1
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/records.ts +11 -11
- package/src/migrate/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/bijection.ts +1 -1
- package/src/pr/evidence.ts +250 -59
- package/src/pr/local.ts +16 -10
- package/src/pr/preview.ts +78 -2
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +2 -2
- package/src/records/ordinal.ts +1 -1
- package/src/records/prune.ts +1 -1
- package/src/records/size.ts +1 -1
- package/src/records/stale-canonical.ts +2 -2
- package/src/records/stale.ts +1 -1
- package/src/records/validate.ts +2 -2
- package/src/{project-root.ts → roots/project.ts} +1 -1
- package/src/{record-root.ts → roots/record.ts} +1 -1
- package/src/sandbox/coverage.ts +11 -12
- package/src/sandbox/equivalence.ts +57 -26
- package/src/sandbox/headless/dispatch.ts +264 -0
- package/src/sandbox/headless/run.ts +653 -0
- package/src/sandbox/headless/watch.ts +145 -0
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +90 -29
- package/src/sandbox/scenario.ts +588 -0
- package/src/sandbox/tree.ts +4 -8
- package/src/secrets/scan.ts +1 -1
- package/src/sessions/claim.ts +1 -1
- package/src/sessions/live.ts +4 -1
- package/src/sessions/resolve.ts +1 -1
- package/src/sessions/transfer/bundle.ts +2 -2
- package/src/slides/decks.ts +1 -1
- package/src/slides/package/fonts.ts +6 -2
- package/src/standards/audit.ts +2 -2
- package/src/standards/read.ts +1 -1
- package/src/sync/check.ts +2 -2
- package/src/sync/engine.ts +3 -3
- package/src/sync/git.ts +1 -1
- package/src/sync/history.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/reverse.ts +1 -1
- package/src/sync/stamp.ts +1 -1
- package/src/sync/target.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/pulls.ts +2 -2
- package/src/targets/sweep.ts +2 -2
- package/src/{target.ts → targets/validate.ts} +1 -1
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/tasks/label.ts +1 -1
- package/src/tasks/reach.ts +2 -2
- package/src/tasks/trunk.ts +1 -1
- package/src/tasks/validate.ts +38 -14
- package/src/teach/nav.ts +1 -1
- package/src/teach/workspace.ts +1 -1
- package/src/tooling/inject.ts +2 -2
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.ts +1 -1
- package/src/tooling/subfolder.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/src/worktrees/bare-flag.ts +1 -1
- package/src/worktrees/reclaim.ts +2 -2
- package/src/worktrees/remove.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/canvas.md +2 -1
- package/standards/plan.md +13 -7
- package/standards/skill.md +1 -0
- package/standards/wiki.md +12 -3
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
- package/tsconfig.json +1 -1
- package/scripts/lib/sandbox-dispatch.sh +0 -190
- /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
- /package/src/{git-base.ts → git/base.ts} +0 -0
- /package/src/{git-env.ts → git/env.ts} +0 -0
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
- /package/src/{copy.ts → sync/copy.ts} +0 -0
package/src/records/size.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, type Stats } from 'node:fs'
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record
|
|
4
|
+
import { RECORD_ROOTS, recordDir, SCRATCH } from '@/roots/record'
|
|
5
5
|
import { candidateFolders } from '@/records/backup'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -2,9 +2,9 @@ import { $ } from 'bun'
|
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
4
|
import { relative } from 'node:path'
|
|
5
|
-
import { gitEnv } from '@/git
|
|
5
|
+
import { gitEnv } from '@/git/env'
|
|
6
6
|
import { readReviewed } from '@/records/stale'
|
|
7
|
-
import { surfaceDir } from '@/surface
|
|
7
|
+
import { surfaceDir } from '@/roots/surface'
|
|
8
8
|
|
|
9
9
|
/** The two records whose drift is identity rather than a stale path. */
|
|
10
10
|
const CANONICAL_DOCS = ['REQUIREMENTS.md', 'ARCHITECTURE.md'] as const
|
package/src/records/stale.ts
CHANGED
package/src/records/validate.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { linesOutsideFences } from '@/markdown/scan'
|
|
|
6
6
|
import {
|
|
7
7
|
recordDir as resolveRecordDir,
|
|
8
8
|
recordDirs as resolveRecordDirs,
|
|
9
|
-
} from '@/record
|
|
9
|
+
} from '@/roots/record'
|
|
10
10
|
import { ATTRIBUTE_MARKER, governedPaths, readScope } from '@/standards/scope'
|
|
11
11
|
import {
|
|
12
12
|
TEACH_GLOSSARY,
|
|
@@ -39,7 +39,7 @@ export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
|
39
39
|
* the second, so one order serves both.
|
|
40
40
|
*
|
|
41
41
|
* Every other kind is a session record and takes the record roots instead,
|
|
42
|
-
* resolved by `@/record
|
|
42
|
+
* resolved by `@/roots/record`. Standards is the one kind that is tracked, so it
|
|
43
43
|
* does not move and spells its own two candidates here.
|
|
44
44
|
*/
|
|
45
45
|
const STANDARDS_FOLDERS: readonly string[] = [
|
|
@@ -57,7 +57,7 @@ const CANON_SCRATCH = 'tmp'
|
|
|
57
57
|
* record root, which is the rule the move ran on. `rules`, `skills`, and
|
|
58
58
|
* `hooks` stay under `.claude/` because the vendor reads them there, while
|
|
59
59
|
* `context`, `wireframes`, and the loose documents are tracked surfaces that
|
|
60
|
-
* `surface
|
|
60
|
+
* `surface.ts` resolves under `canon/` instead. A seed and a
|
|
61
61
|
* superseded-layout report each ask this rather than assuming a root.
|
|
62
62
|
*/
|
|
63
63
|
export const RECORD_ENTRIES: readonly string[] = [
|
package/src/sandbox/coverage.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
FIXTURES_DIR,
|
|
5
|
+
listCategories,
|
|
6
|
+
listScenarioFiles,
|
|
7
|
+
} from '@/sandbox/scenario'
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* The arm a scenario declares when it carries no named arm. `expectFilePath`
|
|
@@ -8,9 +13,6 @@ import { join } from 'node:path'
|
|
|
8
13
|
*/
|
|
9
14
|
export const DEFAULT_ARM = '(default)'
|
|
10
15
|
|
|
11
|
-
/** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
|
|
12
|
-
const FIXTURES_DIR = 'fixtures'
|
|
13
|
-
|
|
14
16
|
export interface ScenarioCoverage {
|
|
15
17
|
readonly category: string
|
|
16
18
|
readonly command: string
|
|
@@ -41,15 +43,12 @@ function directories(path: string): string[] {
|
|
|
41
43
|
function listScenarios(sandboxDir: string): ScenarioCoverage[] {
|
|
42
44
|
const scenarios: ScenarioCoverage[] = []
|
|
43
45
|
|
|
44
|
-
for (const category of
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith('.sh'))
|
|
51
|
-
.map((entry) => entry.name.replace(/\.sh$/, ''))
|
|
52
|
-
.sort()
|
|
46
|
+
for (const category of listCategories(sandboxDir)) {
|
|
47
|
+
// A stem in both forms counts once, and provisioning refuses it by name.
|
|
48
|
+
const { scenarios: files, ambiguous } = listScenarioFiles(
|
|
49
|
+
join(sandboxDir, category),
|
|
50
|
+
)
|
|
51
|
+
const commands = [...files.map((f) => f.command), ...ambiguous].sort()
|
|
53
52
|
|
|
54
53
|
for (const command of commands) {
|
|
55
54
|
scenarios.push({ category, command, armed: [] })
|
|
@@ -17,11 +17,15 @@ import {
|
|
|
17
17
|
import { tmpdir } from 'node:os'
|
|
18
18
|
import { join } from 'node:path'
|
|
19
19
|
import { collectCoverage, DEFAULT_ARM } from '@/sandbox/coverage'
|
|
20
|
+
import {
|
|
21
|
+
armNames,
|
|
22
|
+
listCategories,
|
|
23
|
+
listScenarioFiles,
|
|
24
|
+
loadScenario,
|
|
25
|
+
type ScenarioKind,
|
|
26
|
+
} from '@/sandbox/scenario'
|
|
20
27
|
import { createStubRemote, readStubManifest } from '@/sandbox/stub-remote'
|
|
21
28
|
|
|
22
|
-
/** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
|
|
23
|
-
const FIXTURES_DIR = 'fixtures'
|
|
24
|
-
|
|
25
29
|
/** One arm provisions inside this budget on each side, or reads as a failed run. */
|
|
26
30
|
const PROVISION_TIMEOUT_MS = 120_000
|
|
27
31
|
|
|
@@ -149,13 +153,6 @@ export function armKey(arm: Arm): string {
|
|
|
149
153
|
return arm.arm === undefined ? scenario : `${scenario}/${arm.arm}`
|
|
150
154
|
}
|
|
151
155
|
|
|
152
|
-
function directories(path: string): string[] {
|
|
153
|
-
return readdirSync(path, { withFileTypes: true })
|
|
154
|
-
.filter((entry) => entry.isDirectory())
|
|
155
|
-
.map((entry) => entry.name)
|
|
156
|
-
.sort()
|
|
157
|
-
}
|
|
158
|
-
|
|
159
156
|
/**
|
|
160
157
|
* Reads the quoted arguments of `select_or_route_scenario`, joining a call that
|
|
161
158
|
* continues across backslash-newline pairs. The first argument is the prompt and
|
|
@@ -201,6 +198,42 @@ function matchesArm(arm: Arm, targets: readonly string[]): boolean {
|
|
|
201
198
|
return scoped.includes(armKey(arm))
|
|
202
199
|
}
|
|
203
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Reads a scenario's arm names and anchor flag: off a bash file's source, and
|
|
203
|
+
* off a TypeScript module's own keys, which is what its stage routes on.
|
|
204
|
+
*/
|
|
205
|
+
function readArms(
|
|
206
|
+
file: string,
|
|
207
|
+
kind: ScenarioKind,
|
|
208
|
+
):
|
|
209
|
+
| { ok: true; anchor: boolean; names: string[] }
|
|
210
|
+
| { ok: false; error: string } {
|
|
211
|
+
if (kind === 'sh') {
|
|
212
|
+
const source = readFileSync(file, 'utf8')
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
anchor: /^use_anchor\s*\(\)/m.test(source),
|
|
217
|
+
names: parseArmNames(source),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const definition = loadScenario(file)
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
anchor: definition.anchor === true,
|
|
227
|
+
names: armNames(definition),
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: error instanceof Error ? error.message : String(error),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
204
237
|
/**
|
|
205
238
|
* Lists every provision a target names, from the scenario source. An armed arm
|
|
206
239
|
* that `canon sandbox coverage` reports and this list lacks is returned as an
|
|
@@ -216,25 +249,23 @@ export function enumerateArms(
|
|
|
216
249
|
const errors: string[] = []
|
|
217
250
|
const coverage = collectCoverage(root)
|
|
218
251
|
|
|
219
|
-
for (const category of
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.map((f) => f.name.replace(/\.sh$/, ''))
|
|
227
|
-
.sort()
|
|
252
|
+
for (const category of listCategories(sandboxDir)) {
|
|
253
|
+
const { scenarios, ambiguous } = listScenarioFiles(
|
|
254
|
+
join(sandboxDir, category),
|
|
255
|
+
)
|
|
256
|
+
for (const command of ambiguous)
|
|
257
|
+
if (matchesTarget(category, command, targets))
|
|
258
|
+
errors.push(`${category}:${command} exists as both .sh and .ts`)
|
|
228
259
|
|
|
229
|
-
for (const command of
|
|
260
|
+
for (const { command, file, kind } of scenarios) {
|
|
230
261
|
if (!matchesTarget(category, command, targets)) continue
|
|
231
262
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const names =
|
|
263
|
+
const declared = readArms(file, kind)
|
|
264
|
+
if (!declared.ok) {
|
|
265
|
+
errors.push(`${category}:${command} did not load: ${declared.error}`)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
const { anchor, names } = declared
|
|
238
269
|
|
|
239
270
|
const found: Arm[] =
|
|
240
271
|
names.length === 0
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* The bound on a nested background dispatch, and the recording behind it.
|
|
14
|
+
*
|
|
15
|
+
* An arm invoked without the narration its fixture states dispatched a real
|
|
16
|
+
* `claude --bg` session against the machine's own process table, and nothing in
|
|
17
|
+
* the harness noticed. The write and escape watches in `watch.ts` read the
|
|
18
|
+
* sandbox and four scratch directories, and a dispatched session writes into
|
|
19
|
+
* none of them, so the escape watch reported clean throughout and was right to.
|
|
20
|
+
* The run spent real cost until a person found it, and `SIGTERM` alone did not
|
|
21
|
+
* end it.
|
|
22
|
+
*
|
|
23
|
+
* The three mechanisms sit here rather than in `run.ts`, the only caller, so
|
|
24
|
+
* each is reachable from a test that never launches a session: `dispatch.test.ts`
|
|
25
|
+
* drives the shim against a stub binary and the reap against a `sleep`, where
|
|
26
|
+
* proving either through the runner would mean spawning the thing the bound
|
|
27
|
+
* exists to prevent.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The prevention. Writes a `claude` that refuses a background dispatch by flag
|
|
32
|
+
* name and delegates everything else, for the runner to place first on the PATH
|
|
33
|
+
* of the session it spawns. It stays a bash file, since it stands in for a
|
|
34
|
+
* binary any shell in the session may exec.
|
|
35
|
+
*
|
|
36
|
+
* The real binary is baked into the file rather than passed through the
|
|
37
|
+
* environment. An env var carrying it would sit in the spawned session's own
|
|
38
|
+
* environment, which hands any arm the exact string that walks around the shim.
|
|
39
|
+
*
|
|
40
|
+
* It names itself in its refusal. No arm shells to `claude` today, and one
|
|
41
|
+
* written tomorrow would meet this and read it as the harness being broken
|
|
42
|
+
* unless the text says which layer refused and why.
|
|
43
|
+
*
|
|
44
|
+
* It is not the whole bound. A dispatch reaching the binary by an absolute path
|
|
45
|
+
* never resolves through PATH at all, which is what `reapProcessGroup`
|
|
46
|
+
* backstops.
|
|
47
|
+
*/
|
|
48
|
+
export function installDispatchShim(dir: string, real: string): void {
|
|
49
|
+
const shim = [
|
|
50
|
+
'#!/usr/bin/env bash',
|
|
51
|
+
'# Generated by installDispatchShim in src/sandbox/headless/dispatch.ts.',
|
|
52
|
+
'for arg in "$@"; do',
|
|
53
|
+
' case "$arg" in',
|
|
54
|
+
' --bg | --background)',
|
|
55
|
+
' echo "canon sandbox harness: refusing $arg. A sandbox run may not dispatch a background session, so this shim stands first on PATH in place of the real claude binary. Drive the refusal the fixture asks for, or run this outside the harness." >&2',
|
|
56
|
+
' exit 64',
|
|
57
|
+
' ;;',
|
|
58
|
+
' esac',
|
|
59
|
+
'done',
|
|
60
|
+
`exec ${shellQuote(real)} "$@"`,
|
|
61
|
+
'',
|
|
62
|
+
].join('\n')
|
|
63
|
+
|
|
64
|
+
writeFileSync(join(dir, 'claude'), shim, 'utf8')
|
|
65
|
+
chmodSync(join(dir, 'claude'), 0o755)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shellQuote(value: string): string {
|
|
69
|
+
return `'${value.replaceAll("'", `'\\''`)}'`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The process group `pid` belongs to, or undefined once it has exited. */
|
|
73
|
+
export function processGroupOf(pid: number): string | undefined {
|
|
74
|
+
const ps = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], {
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
})
|
|
77
|
+
const pgid = (ps.stdout ?? '').trim()
|
|
78
|
+
|
|
79
|
+
return pgid === '' ? undefined : pgid
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True while any live process still carries this group id. Reads the whole
|
|
84
|
+
* table rather than `ps -g`, which selects by session on some builds.
|
|
85
|
+
*
|
|
86
|
+
* A zombie does not count. A leader this process spawned and has not yet
|
|
87
|
+
* waited on stays in the table as one after the signal lands, and the wait
|
|
88
|
+
* below blocks the event loop that would collect it, so signal 0 alone reports
|
|
89
|
+
* a group that is already dead as a survivor.
|
|
90
|
+
*/
|
|
91
|
+
function processGroupAlive(pgid: number): boolean {
|
|
92
|
+
const ps = spawnSync('ps', ['-eo', 'pgid=,stat='], { encoding: 'utf8' })
|
|
93
|
+
|
|
94
|
+
return (ps.stdout ?? '').split('\n').some((line) => {
|
|
95
|
+
const [group, stat] = line.trim().split(/\s+/)
|
|
96
|
+
return group === String(pgid) && stat !== undefined && !stat.startsWith('Z')
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Five seconds per signal. A shorter window reports a survivor for a process
|
|
102
|
+
* that was already exiting, and a longer one stalls a run whose verdict is
|
|
103
|
+
* already taken.
|
|
104
|
+
*/
|
|
105
|
+
function waitForGroupExit(pgid: number): void {
|
|
106
|
+
for (let waited = 0; waited < 20 && processGroupAlive(pgid); waited++)
|
|
107
|
+
Bun.sleepSync(250)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type ReapState =
|
|
111
|
+
| 'clear'
|
|
112
|
+
| 'reaped-term'
|
|
113
|
+
| 'reaped-kill'
|
|
114
|
+
| 'survived'
|
|
115
|
+
| 'refused-own-group'
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The backstop. Signals a process group the runner created, escalating rather
|
|
119
|
+
* than sending one signal and reporting success, since the dispatch that
|
|
120
|
+
* produced this bound took two signals to reap.
|
|
121
|
+
*
|
|
122
|
+
* Returns its outcome and never throws. A survivor is something to report
|
|
123
|
+
* rather than a reason to abandon the verdict the run already took.
|
|
124
|
+
*
|
|
125
|
+
* The group has to be one the run created, and the refusal below is what makes
|
|
126
|
+
* that property travel with the function rather than living in one caller. The
|
|
127
|
+
* runner reports `inherited` when the session failed to lead a group of its
|
|
128
|
+
* own, which is a diagnosis; this is the floor under it, and under every caller
|
|
129
|
+
* after it, because the cost of getting the argument wrong is the operator's
|
|
130
|
+
* own shell.
|
|
131
|
+
*
|
|
132
|
+
* Synchronous, so a signal handler can run it before the process exits.
|
|
133
|
+
*
|
|
134
|
+
* What it cannot reach is a dispatch that called `setsid` on its way out, which
|
|
135
|
+
* leaves the group before this reads it. That is the half the shim covers.
|
|
136
|
+
*/
|
|
137
|
+
export function reapProcessGroup(pgid: number): ReapState {
|
|
138
|
+
if (String(pgid) === processGroupOf(process.pid)) return 'refused-own-group'
|
|
139
|
+
if (!processGroupAlive(pgid)) return 'clear'
|
|
140
|
+
|
|
141
|
+
signalGroup(pgid, 'SIGTERM')
|
|
142
|
+
waitForGroupExit(pgid)
|
|
143
|
+
if (!processGroupAlive(pgid)) return 'reaped-term'
|
|
144
|
+
|
|
145
|
+
signalGroup(pgid, 'SIGKILL')
|
|
146
|
+
waitForGroupExit(pgid)
|
|
147
|
+
|
|
148
|
+
return processGroupAlive(pgid) ? 'survived' : 'reaped-kill'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function signalGroup(pgid: number, signal: NodeJS.Signals): void {
|
|
152
|
+
try {
|
|
153
|
+
process.kill(-pgid, signal)
|
|
154
|
+
} catch {
|
|
155
|
+
// The group emptied between the liveness read and the signal.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The client's own session registry, which is the only surface a nested
|
|
161
|
+
* dispatch leaves a trace on that this harness can read. It is client-owned and
|
|
162
|
+
* carries no contract, so a client that stops writing a record per session
|
|
163
|
+
* breaks the detection silently. That is why what reads it reports rather than
|
|
164
|
+
* asserts.
|
|
165
|
+
*/
|
|
166
|
+
export function sessionsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
167
|
+
const config = env.CLAUDE_CONFIG_DIR
|
|
168
|
+
const base =
|
|
169
|
+
config !== undefined && config !== ''
|
|
170
|
+
? config
|
|
171
|
+
: join(env.HOME || homedir(), '.claude')
|
|
172
|
+
|
|
173
|
+
return join(base, 'sessions')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface SessionSnapshot {
|
|
177
|
+
/** Record names, sorted. */
|
|
178
|
+
readonly names: readonly string[]
|
|
179
|
+
/**
|
|
180
|
+
* The registry directory existed. An absent registry produces the same empty
|
|
181
|
+
* list a quiet run does, and the two mean opposite things, so the report
|
|
182
|
+
* carries this rather than letting an unwatched run read as a clean one.
|
|
183
|
+
*/
|
|
184
|
+
readonly isWatched: boolean
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Record names only, never hashes. A live session rewrites its own record on
|
|
189
|
+
* every status change, so a content manifest would name every session running
|
|
190
|
+
* beside this one, which is the noise the escape watch was already narrowed to
|
|
191
|
+
* avoid. A record that appears between the two snapshots is a session that
|
|
192
|
+
* started during the run, and that is the whole question.
|
|
193
|
+
*/
|
|
194
|
+
export function snapshotSessions(dir: string): SessionSnapshot {
|
|
195
|
+
if (!existsSync(dir)) return { names: [], isWatched: false }
|
|
196
|
+
|
|
197
|
+
const names = readdirSync(dir, { withFileTypes: true })
|
|
198
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
199
|
+
.map((entry) => entry.name)
|
|
200
|
+
.sort()
|
|
201
|
+
|
|
202
|
+
return { names, isWatched: true }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The new side alone, unlike `writesBetween`. A record that vanished is a
|
|
207
|
+
* session that ended and a record that changed is one that was already
|
|
208
|
+
* running, and neither is a dispatch this run made.
|
|
209
|
+
*/
|
|
210
|
+
export function sessionsBetween(
|
|
211
|
+
before: SessionSnapshot,
|
|
212
|
+
after: SessionSnapshot,
|
|
213
|
+
): string[] {
|
|
214
|
+
const seen = new Set(before.names)
|
|
215
|
+
|
|
216
|
+
return after.names.filter((name) => !seen.has(name))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The side `sessionsBetween` drops. A record present on both sides is a
|
|
221
|
+
* session that was already running before this run started and still was
|
|
222
|
+
* after, which rules it out as this run's own dispatch and rules it in as a
|
|
223
|
+
* witness able to explain a write the escape watch attributes to nobody.
|
|
224
|
+
*/
|
|
225
|
+
export function sessionsConcurrent(
|
|
226
|
+
before: SessionSnapshot,
|
|
227
|
+
after: SessionSnapshot,
|
|
228
|
+
): string[] {
|
|
229
|
+
const seen = new Set(before.names)
|
|
230
|
+
|
|
231
|
+
return after.names.filter((name) => seen.has(name))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Turns a record name into a line a person can act on. The file can be gone by
|
|
236
|
+
* the time this reads it, since a session that started and exited inside the
|
|
237
|
+
* run takes its record with it, so an unreadable record still reports its name
|
|
238
|
+
* rather than dropping out of the list.
|
|
239
|
+
*
|
|
240
|
+
* One record is one line. The name is written by whatever peer claimed the
|
|
241
|
+
* session, so a newline inside it would split one record across two entries in
|
|
242
|
+
* a list every reader takes as one line each.
|
|
243
|
+
*/
|
|
244
|
+
export function describeSession(dir: string, name: string): string {
|
|
245
|
+
let record: unknown
|
|
246
|
+
try {
|
|
247
|
+
record = JSON.parse(readFileSync(join(dir, name), 'utf8'))
|
|
248
|
+
} catch {
|
|
249
|
+
return `${name}: record already gone`
|
|
250
|
+
}
|
|
251
|
+
if (typeof record !== 'object' || record === null || Array.isArray(record))
|
|
252
|
+
return `${name}: record already gone`
|
|
253
|
+
|
|
254
|
+
const fields = record as Record<string, unknown>
|
|
255
|
+
const label = (value: unknown, fallback: string): string =>
|
|
256
|
+
value === undefined || value === null || value === false
|
|
257
|
+
? fallback
|
|
258
|
+
: typeof value === 'string'
|
|
259
|
+
? value
|
|
260
|
+
: JSON.stringify(value)
|
|
261
|
+
const desc = `${label(fields.name, 'unnamed')} in ${label(fields.cwd, 'an unrecorded directory')}`
|
|
262
|
+
|
|
263
|
+
return `${name}: ${desc.replace(/\s+/g, ' ')}`
|
|
264
|
+
}
|