@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
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import { constants, tmpdir } from 'node:os'
|
|
13
|
+
import { join, relative } from 'node:path'
|
|
14
|
+
import {
|
|
15
|
+
describeSession,
|
|
16
|
+
installDispatchShim,
|
|
17
|
+
processGroupOf,
|
|
18
|
+
reapProcessGroup,
|
|
19
|
+
sessionsBetween,
|
|
20
|
+
sessionsConcurrent,
|
|
21
|
+
sessionsDir,
|
|
22
|
+
snapshotSessions,
|
|
23
|
+
type ReapState,
|
|
24
|
+
} from '@/sandbox/headless/dispatch'
|
|
25
|
+
import {
|
|
26
|
+
escapeRoots,
|
|
27
|
+
snapshotRoot,
|
|
28
|
+
snapshotTree,
|
|
29
|
+
writesBetween,
|
|
30
|
+
} from '@/sandbox/headless/watch'
|
|
31
|
+
import { mintSandboxRunId, sandboxTree } from '@/sandbox/tree'
|
|
32
|
+
import {
|
|
33
|
+
intro,
|
|
34
|
+
logError,
|
|
35
|
+
logInfo,
|
|
36
|
+
logRemove,
|
|
37
|
+
logStep,
|
|
38
|
+
logWarn,
|
|
39
|
+
outro,
|
|
40
|
+
} from '@/ui'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The flags and budget every run takes, each overridable through its
|
|
44
|
+
* `CANON_SKILL_TEST_*` variable.
|
|
45
|
+
*
|
|
46
|
+
* `maxTurns` is the only budget. `max_turns` in an arm's `expect.toml` is a
|
|
47
|
+
* ceiling asserted after the run, not a cap enforced during it, so a
|
|
48
|
+
* declaration cannot raise what it runs under. An arm needing more than this
|
|
49
|
+
* truncates, and a truncated run fails the same assertions a reasoning miss
|
|
50
|
+
* does with nothing to separate them.
|
|
51
|
+
*
|
|
52
|
+
* `bypassPermissions` is the only mode that lets a run write under `.claude/`,
|
|
53
|
+
* which most arms need. The scoping the permission layer would have given
|
|
54
|
+
* comes from `write_scope` instead, asserted after the run rather than enforced
|
|
55
|
+
* during it, so a skill that writes where it should not still wrote there.
|
|
56
|
+
*/
|
|
57
|
+
export function runSettings(env: NodeJS.ProcessEnv): {
|
|
58
|
+
readonly model: string
|
|
59
|
+
readonly allowedTools: string
|
|
60
|
+
readonly maxTurns: string
|
|
61
|
+
readonly permissionMode: string
|
|
62
|
+
} {
|
|
63
|
+
const read = (name: string, fallback: string): string => {
|
|
64
|
+
const value = env[name]
|
|
65
|
+
return value === undefined || value === '' ? fallback : value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
model: read('CANON_SKILL_TEST_MODEL', 'sonnet'),
|
|
70
|
+
allowedTools: read(
|
|
71
|
+
'CANON_SKILL_TEST_TOOLS',
|
|
72
|
+
'Bash,Read,Glob,Grep,Edit,Write',
|
|
73
|
+
),
|
|
74
|
+
maxTurns: read('CANON_SKILL_TEST_MAX_TURNS', '30'),
|
|
75
|
+
permissionMode: read(
|
|
76
|
+
'CANON_SKILL_TEST_PERMISSION_MODE',
|
|
77
|
+
'bypassPermissions',
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Narrows the arguments to a run that can start, or names why they cannot. */
|
|
83
|
+
export function parseRunArgs(
|
|
84
|
+
target: string,
|
|
85
|
+
prompt: string | undefined,
|
|
86
|
+
):
|
|
87
|
+
| { readonly isValid: true; readonly prompt: string }
|
|
88
|
+
| { readonly isValid: false; readonly reason: string } {
|
|
89
|
+
if (!target.includes(':'))
|
|
90
|
+
return {
|
|
91
|
+
isValid: false,
|
|
92
|
+
reason: 'Invalid target. Use <category>:<command>, e.g. git:commit.',
|
|
93
|
+
}
|
|
94
|
+
if (prompt === undefined || prompt === '')
|
|
95
|
+
return {
|
|
96
|
+
isValid: false,
|
|
97
|
+
reason:
|
|
98
|
+
'Missing prompt. Pass the skill invocation, e.g. "/canon:git-commit".',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { isValid: true, prompt }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The four facts about nested sessions, kept in one field so a reader gets
|
|
106
|
+
* them together. `watched` false means the registry was absent, which makes an
|
|
107
|
+
* empty `new` say nothing at all rather than say the run dispatched nothing.
|
|
108
|
+
*
|
|
109
|
+
* `concurrent` is a witness rather than a verdict. A record present before and
|
|
110
|
+
* after the run rules that session out as this run's own dispatch and rules it
|
|
111
|
+
* in as a candidate explanation for a file `escapes` names with no attribution
|
|
112
|
+
* of its own, never a claim that it, rather than something else, made the
|
|
113
|
+
* write.
|
|
114
|
+
*
|
|
115
|
+
* `reap` carries `clear` for a group that was already empty, `reaped-term` or
|
|
116
|
+
* `reaped-kill` for one this run signalled, `survived` for one that outlived
|
|
117
|
+
* `SIGKILL`, `inherited` for a session that never led a group of its own, and
|
|
118
|
+
* `no-session` for a run that stopped before the session began.
|
|
119
|
+
*/
|
|
120
|
+
export interface SessionsReport {
|
|
121
|
+
readonly watched: boolean
|
|
122
|
+
readonly new: readonly string[]
|
|
123
|
+
readonly concurrent: readonly string[]
|
|
124
|
+
readonly reap: ReapState | 'inherited' | 'no-session'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The envelope stays on stdout so existing readers keep working, with the
|
|
129
|
+
* verdict merged in. A session that returned output that does not parse as an
|
|
130
|
+
* object still emits its verdict, since a silent stdout would read as a run
|
|
131
|
+
* that never happened, and `isParsed` false is what forces the exit to 1.
|
|
132
|
+
*
|
|
133
|
+
* An escape rides alongside the verdict rather than inside it. The verdict
|
|
134
|
+
* answers what the sandbox contains, and a file the session put somewhere else
|
|
135
|
+
* is not in that tree to be asserted over.
|
|
136
|
+
*/
|
|
137
|
+
export function mergeEnvelope(
|
|
138
|
+
out: string,
|
|
139
|
+
verdict: unknown,
|
|
140
|
+
escapes: readonly string[],
|
|
141
|
+
sessions: SessionsReport,
|
|
142
|
+
): { readonly merged: Record<string, unknown>; readonly isParsed: boolean } {
|
|
143
|
+
const envelope = parseObject(out)
|
|
144
|
+
if (envelope === undefined)
|
|
145
|
+
return {
|
|
146
|
+
merged: { is_error: true, verdict, escapes, sessions },
|
|
147
|
+
isParsed: false,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
merged: { ...envelope, verdict, escapes, sessions },
|
|
152
|
+
isParsed: true,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseObject(text: string): Record<string, unknown> | undefined {
|
|
157
|
+
try {
|
|
158
|
+
const parsed: unknown = JSON.parse(text)
|
|
159
|
+
return typeof parsed === 'object' &&
|
|
160
|
+
parsed !== null &&
|
|
161
|
+
!Array.isArray(parsed)
|
|
162
|
+
? (parsed as Record<string, unknown>)
|
|
163
|
+
: undefined
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One envelope field as the result line prints it, `null` when absent. */
|
|
170
|
+
function envelopeField(
|
|
171
|
+
envelope: Record<string, unknown> | undefined,
|
|
172
|
+
key: string,
|
|
173
|
+
fallback: string,
|
|
174
|
+
): string {
|
|
175
|
+
if (envelope === undefined) return fallback
|
|
176
|
+
const value = envelope[key]
|
|
177
|
+
if (value === undefined || value === null) return 'null'
|
|
178
|
+
|
|
179
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function denialCount(envelope: Record<string, unknown> | undefined): string {
|
|
183
|
+
if (envelope === undefined) return '?'
|
|
184
|
+
const denials = envelope.permission_denials
|
|
185
|
+
if (denials === undefined || denials === null) return '0'
|
|
186
|
+
if (Array.isArray(denials) || typeof denials === 'string')
|
|
187
|
+
return String(denials.length)
|
|
188
|
+
if (typeof denials === 'object') return String(Object.keys(denials).length)
|
|
189
|
+
|
|
190
|
+
return '?'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function stamp(now: Date): string {
|
|
194
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
195
|
+
|
|
196
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}T${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function recordPath(
|
|
200
|
+
projectRoot: string,
|
|
201
|
+
target: string,
|
|
202
|
+
arm: string | undefined,
|
|
203
|
+
): { readonly dir: string; readonly file: string } {
|
|
204
|
+
const dir = join(projectRoot, '.canon/tmp/runs/sandbox')
|
|
205
|
+
const name = `${target}${arm === undefined ? '' : `-${arm}`}`.replaceAll(
|
|
206
|
+
':',
|
|
207
|
+
'-',
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return { dir, file: join(dir, `${name}-${stamp(new Date())}.json`) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Writes one run record to stderr and disk only, since
|
|
215
|
+
* `docs/agents/output-shape.md` makes stdout the data contract, so a failure
|
|
216
|
+
* here warns and lets the verdict print regardless.
|
|
217
|
+
*/
|
|
218
|
+
function writeRecord(
|
|
219
|
+
projectRoot: string,
|
|
220
|
+
target: string,
|
|
221
|
+
arm: string | undefined,
|
|
222
|
+
record: Record<string, unknown>,
|
|
223
|
+
noun: 'run' | 'dead run',
|
|
224
|
+
): void {
|
|
225
|
+
const { dir, file } = recordPath(projectRoot, target, arm)
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(dir, { recursive: true })
|
|
228
|
+
} catch {
|
|
229
|
+
logWarn(`Could not create ${dir}. The ${noun} was not recorded.`)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
|
|
235
|
+
logInfo(
|
|
236
|
+
`${noun === 'run' ? 'Run' : 'Dead run'} recorded at ${relative(projectRoot, file)}`,
|
|
237
|
+
)
|
|
238
|
+
} catch {
|
|
239
|
+
rmSync(file, { force: true })
|
|
240
|
+
logWarn(`Could not record the ${noun} at ${file}.`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Exits through every path out of a run, the way a bash `trap ... EXIT` does:
|
|
246
|
+
* a provision failure, a dead session, a failed verdict, a thrown error, or an
|
|
247
|
+
* interrupt. Each step runs at most once and is synchronous, so a signal
|
|
248
|
+
* handler can run the whole of it before the process exits.
|
|
249
|
+
*/
|
|
250
|
+
class RunCleanup {
|
|
251
|
+
reap: SessionsReport['reap'] = 'no-session'
|
|
252
|
+
sessionPgid: number | undefined
|
|
253
|
+
harnessPgid: string | undefined
|
|
254
|
+
shimDir: string | undefined
|
|
255
|
+
workDir: string | undefined
|
|
256
|
+
private isReaped = false
|
|
257
|
+
private isClosed = false
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The one place that decides whether a group is safe to signal, so the
|
|
261
|
+
* ordinary path and an early exit cannot disagree about it. An absent group
|
|
262
|
+
* is `no-session` rather than a clean reap, since the two mean opposite
|
|
263
|
+
* things.
|
|
264
|
+
*/
|
|
265
|
+
reapSession(): void {
|
|
266
|
+
if (this.isReaped) return
|
|
267
|
+
this.isReaped = true
|
|
268
|
+
if (this.sessionPgid === undefined) return
|
|
269
|
+
|
|
270
|
+
if (String(this.sessionPgid) === this.harnessPgid) {
|
|
271
|
+
this.reap = 'inherited'
|
|
272
|
+
logWarn(
|
|
273
|
+
"The session ran in this process's own group, so nothing was signalled.",
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.reap = reapProcessGroup(this.sessionPgid)
|
|
279
|
+
if (this.reap === 'reaped-term' || this.reap === 'reaped-kill')
|
|
280
|
+
logWarn(
|
|
281
|
+
`The session left a process behind. Reaped its group (${this.reap}).`,
|
|
282
|
+
)
|
|
283
|
+
else if (this.reap === 'survived')
|
|
284
|
+
logWarn(
|
|
285
|
+
`A process in the session's group survived SIGKILL. Check pgid ${this.sessionPgid} by hand.`,
|
|
286
|
+
)
|
|
287
|
+
else if (this.reap === 'refused-own-group')
|
|
288
|
+
logWarn(
|
|
289
|
+
"The reap refused a group matching this process's own. Nothing was signalled.",
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
removeShim(): void {
|
|
294
|
+
if (this.shimDir !== undefined)
|
|
295
|
+
rmSync(this.shimDir, { force: true, recursive: true })
|
|
296
|
+
this.shimDir = undefined
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
close(): void {
|
|
300
|
+
if (this.isClosed) return
|
|
301
|
+
this.isClosed = true
|
|
302
|
+
this.reapSession()
|
|
303
|
+
this.removeShim()
|
|
304
|
+
if (this.workDir !== undefined)
|
|
305
|
+
rmSync(this.workDir, { force: true, recursive: true })
|
|
306
|
+
outro()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface RunRequest {
|
|
311
|
+
readonly projectRoot: string
|
|
312
|
+
readonly target: string
|
|
313
|
+
readonly prompt: string | undefined
|
|
314
|
+
readonly arm: string | undefined
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Provisions a scenario, drives one `claude -p` session over it, scores the
|
|
319
|
+
* result with `canon sandbox check`, and prints the envelope with the verdict
|
|
320
|
+
* merged in on stdout. Resolves to the exit code: provisioning's or the dead
|
|
321
|
+
* session's when either stopped the run, otherwise the verdict's.
|
|
322
|
+
*/
|
|
323
|
+
export async function runHeadless(request: RunRequest): Promise<number> {
|
|
324
|
+
const { projectRoot, target, prompt } = request
|
|
325
|
+
const arm = request.arm === '' ? undefined : request.arm
|
|
326
|
+
|
|
327
|
+
intro('canon skill-test')
|
|
328
|
+
const cleanup = new RunCleanup()
|
|
329
|
+
|
|
330
|
+
// The session leads a group of its own and so takes no terminal signal, which
|
|
331
|
+
// leaves the runner to reap it on the way out.
|
|
332
|
+
const onSignal = (signal: NodeJS.Signals): void => {
|
|
333
|
+
cleanup.close()
|
|
334
|
+
process.exit(128 + constants.signals[signal])
|
|
335
|
+
}
|
|
336
|
+
process.once('SIGINT', onSignal)
|
|
337
|
+
process.once('SIGTERM', onSignal)
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
return await drive(projectRoot, target, prompt, arm, cleanup)
|
|
341
|
+
} finally {
|
|
342
|
+
process.off('SIGINT', onSignal)
|
|
343
|
+
process.off('SIGTERM', onSignal)
|
|
344
|
+
cleanup.close()
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function drive(
|
|
349
|
+
projectRoot: string,
|
|
350
|
+
target: string,
|
|
351
|
+
rawPrompt: string | undefined,
|
|
352
|
+
arm: string | undefined,
|
|
353
|
+
cleanup: RunCleanup,
|
|
354
|
+
): Promise<number> {
|
|
355
|
+
if (!process.cwd().startsWith(projectRoot)) {
|
|
356
|
+
logError('Run this from inside the toolkit repository.')
|
|
357
|
+
return 1
|
|
358
|
+
}
|
|
359
|
+
const realClaude = Bun.which('claude')
|
|
360
|
+
if (realClaude === null) {
|
|
361
|
+
logError('claude CLI not found on PATH.')
|
|
362
|
+
return 1
|
|
363
|
+
}
|
|
364
|
+
const args = parseRunArgs(target, rawPrompt)
|
|
365
|
+
if (!args.isValid) {
|
|
366
|
+
logError(args.reason)
|
|
367
|
+
return 1
|
|
368
|
+
}
|
|
369
|
+
const { prompt } = args
|
|
370
|
+
|
|
371
|
+
// The agent pushes from inside the sandbox on its own, in the session this
|
|
372
|
+
// spawns, so the guard has to reach that environment and not only
|
|
373
|
+
// provisioning.
|
|
374
|
+
process.env.GIT_TERMINAL_PROMPT = '0'
|
|
375
|
+
|
|
376
|
+
// Minted into this process's env before any child starts, so provisioning,
|
|
377
|
+
// the session, and the check all resolve one tree.
|
|
378
|
+
mintSandboxRunId()
|
|
379
|
+
const settings = runSettings(process.env)
|
|
380
|
+
const cli = join(projectRoot, 'src/cli.ts')
|
|
381
|
+
|
|
382
|
+
const sandbox = sandboxTree()
|
|
383
|
+
logStep(`Sandbox: ${sandbox}`)
|
|
384
|
+
|
|
385
|
+
// The session below loads the branch's skills whole through --plugin-dir. A
|
|
386
|
+
// copied SKILL.md would outrank that and cite references beside it that were
|
|
387
|
+
// never copied, so provisioning injects nothing for a headless run.
|
|
388
|
+
logStep(`Provisioning ${target}`)
|
|
389
|
+
const provision = spawnSync(
|
|
390
|
+
process.execPath,
|
|
391
|
+
[cli, 'sandbox', '--no-header', target, arm ?? ''],
|
|
392
|
+
{
|
|
393
|
+
env: { ...process.env, SANDBOX_SKIP_SKILL_INJECT: '1' },
|
|
394
|
+
stdio: ['inherit', 2, 'inherit'],
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
const provisionCode = provision.status ?? 1
|
|
398
|
+
if (provisionCode !== 0) {
|
|
399
|
+
logWarn(
|
|
400
|
+
`Provisioning exited ${provisionCode} before the session could start.`,
|
|
401
|
+
)
|
|
402
|
+
return provisionCode
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const workDir = mkdtempSync(join(tmpdir(), 'canon-skill-test-'))
|
|
406
|
+
cleanup.workDir = workDir
|
|
407
|
+
const before = snapshotTree(sandbox)
|
|
408
|
+
|
|
409
|
+
let isEscapeWatched = false
|
|
410
|
+
const roots = escapeRoots(projectRoot)
|
|
411
|
+
.filter((root) => existsSync(root))
|
|
412
|
+
.map((root) => {
|
|
413
|
+
const snapshot = snapshotRoot(root)
|
|
414
|
+
isEscapeWatched ||= snapshot.isWatched
|
|
415
|
+
return { root, manifest: snapshot.manifest }
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// Taken after provisioning, so a scenario that stages a live session record
|
|
419
|
+
// of its own is already in the before snapshot rather than reported as a
|
|
420
|
+
// dispatch this run made. `canon/context/sandbox/isolation.md` carries why
|
|
421
|
+
// such a scenario writes to the real registry at all.
|
|
422
|
+
const registry = sessionsDir()
|
|
423
|
+
const sessionsBefore = snapshotSessions(registry)
|
|
424
|
+
|
|
425
|
+
// The session calls the real binary by its resolved path and puts the shim on
|
|
426
|
+
// the PATH the session inherits, so a `claude --bg` from inside the run meets
|
|
427
|
+
// the refusal and this invocation does not. Routing the session through the
|
|
428
|
+
// shim too would read the arm's own prompt as arguments, so a prompt naming
|
|
429
|
+
// `--bg` would refuse the run the shim exists to allow.
|
|
430
|
+
cleanup.shimDir = mkdtempSync(join(tmpdir(), 'canon-dispatch-shim-'))
|
|
431
|
+
installDispatchShim(cleanup.shimDir, realClaude)
|
|
432
|
+
|
|
433
|
+
logStep(`Running ${prompt} on ${settings.model}`)
|
|
434
|
+
|
|
435
|
+
// Detached, so the session leads a process group of its own and the reap
|
|
436
|
+
// after the verdict signals a group this run created rather than the
|
|
437
|
+
// operator's. Stdout goes to a file rather than a pipe, since a child the
|
|
438
|
+
// session leaves behind would hold a pipe open and stall the read until it
|
|
439
|
+
// exits. Stdin is `/dev/null`, since no command here may wait on a terminal.
|
|
440
|
+
const sessionOut = join(workDir, 'session.out')
|
|
441
|
+
const outFd = openSync(sessionOut, 'w')
|
|
442
|
+
const session: ChildProcess = spawn(
|
|
443
|
+
realClaude,
|
|
444
|
+
[
|
|
445
|
+
'-p',
|
|
446
|
+
prompt,
|
|
447
|
+
'--plugin-dir',
|
|
448
|
+
join(projectRoot, 'claude'),
|
|
449
|
+
'--model',
|
|
450
|
+
settings.model,
|
|
451
|
+
'--output-format',
|
|
452
|
+
'json',
|
|
453
|
+
'--permission-mode',
|
|
454
|
+
settings.permissionMode,
|
|
455
|
+
'--allowedTools',
|
|
456
|
+
settings.allowedTools,
|
|
457
|
+
'--max-turns',
|
|
458
|
+
settings.maxTurns,
|
|
459
|
+
],
|
|
460
|
+
{
|
|
461
|
+
cwd: sandbox,
|
|
462
|
+
detached: true,
|
|
463
|
+
env: {
|
|
464
|
+
...process.env,
|
|
465
|
+
PATH: `${cleanup.shimDir}:${process.env.PATH ?? ''}`,
|
|
466
|
+
},
|
|
467
|
+
stdio: ['ignore', outFd, 'inherit'],
|
|
468
|
+
},
|
|
469
|
+
)
|
|
470
|
+
closeSync(outFd)
|
|
471
|
+
|
|
472
|
+
const exit = new Promise<number>((resolve) => {
|
|
473
|
+
session.once('error', () => resolve(127))
|
|
474
|
+
session.once('exit', (code, signal) =>
|
|
475
|
+
resolve(code ?? 128 + constants.signals[signal ?? 'SIGTERM']),
|
|
476
|
+
)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// Read rather than assumed. A detached spawn makes the session lead its own
|
|
480
|
+
// group, so this equals its pid, and comparing it against this process's
|
|
481
|
+
// group before signalling is what keeps the reap off the operator's terminal.
|
|
482
|
+
if (session.pid !== undefined) {
|
|
483
|
+
cleanup.harnessPgid = processGroupOf(process.pid)
|
|
484
|
+
const pgid = processGroupOf(session.pid)
|
|
485
|
+
cleanup.sessionPgid = pgid === undefined ? session.pid : Number(pgid)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const sessionCode = await exit
|
|
489
|
+
const out = readFileSync(sessionOut, 'utf8').replace(/\n+$/, '')
|
|
490
|
+
|
|
491
|
+
if (sessionCode !== 0) {
|
|
492
|
+
logWarn(
|
|
493
|
+
`The session exited ${sessionCode} before a verdict could be taken.`,
|
|
494
|
+
)
|
|
495
|
+
// Stamped as text rather than as a parsed envelope, since a dying session's
|
|
496
|
+
// stdout can be partial or not JSON at all, which is why this run has no
|
|
497
|
+
// verdict to attach it to.
|
|
498
|
+
writeRecord(
|
|
499
|
+
projectRoot,
|
|
500
|
+
target,
|
|
501
|
+
arm,
|
|
502
|
+
{ is_error: true, exit_code: sessionCode, raw_output: out },
|
|
503
|
+
'dead run',
|
|
504
|
+
)
|
|
505
|
+
return sessionCode
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const envelope = parseObject(out)
|
|
509
|
+
const isError = envelopeField(envelope, 'is_error', 'unknown')
|
|
510
|
+
const turns = envelopeField(envelope, 'num_turns', '?')
|
|
511
|
+
const cost = envelopeField(envelope, 'total_cost_usd', '?')
|
|
512
|
+
logStep(
|
|
513
|
+
`Result: error=${isError} turns=${turns} cost=$${cost} denials=${denialCount(envelope)}`,
|
|
514
|
+
)
|
|
515
|
+
process.stderr.write(`${envelopeField(envelope, 'result', '')}\n`)
|
|
516
|
+
|
|
517
|
+
const writes = writesBetween(before, snapshotTree(sandbox))
|
|
518
|
+
|
|
519
|
+
// Taken before the verdict runs, since `canon sandbox check` and the run
|
|
520
|
+
// record both write under a watched root themselves.
|
|
521
|
+
const escapes = roots.flatMap(({ root, manifest }) => {
|
|
522
|
+
const after = snapshotRoot(root)
|
|
523
|
+
isEscapeWatched ||= after.isWatched
|
|
524
|
+
return writesBetween(manifest, after.manifest).map(
|
|
525
|
+
(path) => `${root.replace(/\/+$/, '')}/${path}`,
|
|
526
|
+
)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
// Beside the escape snapshot and ahead of the reap, so a session that
|
|
530
|
+
// outlived the run is recorded as what it was before anything signals it.
|
|
531
|
+
const sessionsAfter = snapshotSessions(registry)
|
|
532
|
+
const newSessions = sessionsBetween(sessionsBefore, sessionsAfter).map(
|
|
533
|
+
(name) => describeSession(registry, name),
|
|
534
|
+
)
|
|
535
|
+
const concurrent = sessionsConcurrent(sessionsBefore, sessionsAfter).map(
|
|
536
|
+
(name) => describeSession(registry, name),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
const verdict = takeVerdict(cli, target, arm, workDir, {
|
|
540
|
+
envelope: out,
|
|
541
|
+
writes,
|
|
542
|
+
escapes,
|
|
543
|
+
concurrent,
|
|
544
|
+
isEscapeWatched,
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
// After the verdict, which is the last thing that reads what the run left
|
|
548
|
+
// behind. Nothing here fails the run: a survivor is a fact about the machine
|
|
549
|
+
// rather than a claim about the skill, and the verdict is already taken.
|
|
550
|
+
cleanup.reapSession()
|
|
551
|
+
cleanup.removeShim()
|
|
552
|
+
|
|
553
|
+
// An escape reports without failing, the same relationship `write_scope` has
|
|
554
|
+
// to the writes it names. Nothing here distinguishes the spawned session's
|
|
555
|
+
// writes from the operator's own, and on a machine running parallel sessions
|
|
556
|
+
// the watched directories are rarely quiet: of five runs on 2026-08-02, three
|
|
557
|
+
// reported an escape and every one named a file another session was writing.
|
|
558
|
+
// What enforces the scope is that the sandbox sits outside the repository, so
|
|
559
|
+
// a correct run has nothing to escape with.
|
|
560
|
+
if (escapes.length > 0) {
|
|
561
|
+
logWarn('Shared scratch changed under a toolkit root during this run:')
|
|
562
|
+
for (const path of escapes) logRemove(path)
|
|
563
|
+
if (concurrent.length > 0) {
|
|
564
|
+
logWarn(
|
|
565
|
+
'A session was live throughout the run and may account for these:',
|
|
566
|
+
)
|
|
567
|
+
for (const record of concurrent) logRemove(record)
|
|
568
|
+
}
|
|
569
|
+
logWarn(
|
|
570
|
+
'No assertion covers these. Check them against what else was running.',
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (newSessions.length > 0) {
|
|
575
|
+
logWarn('A session record appeared while this run was in flight:')
|
|
576
|
+
for (const record of newSessions) logRemove(record)
|
|
577
|
+
logWarn(
|
|
578
|
+
'No arm dispatches a session. Read these against what else started.',
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const { merged, isParsed } = mergeEnvelope(out, verdict.record, escapes, {
|
|
583
|
+
watched: sessionsBefore.isWatched || sessionsAfter.isWatched,
|
|
584
|
+
new: newSessions,
|
|
585
|
+
concurrent,
|
|
586
|
+
reap: cleanup.reap,
|
|
587
|
+
})
|
|
588
|
+
if (!isParsed)
|
|
589
|
+
logWarn('Envelope was not valid JSON. Emitting the verdict alone.')
|
|
590
|
+
|
|
591
|
+
writeRecord(projectRoot, target, arm, { ...merged, writes }, 'run')
|
|
592
|
+
cleanup.close()
|
|
593
|
+
process.stdout.write(`${JSON.stringify(merged, null, 2)}\n`)
|
|
594
|
+
|
|
595
|
+
return isParsed ? verdict.code : 1
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The verdict decides the outcome. The envelope can only fail a run the
|
|
600
|
+
* expectations would otherwise have passed, never pass one on its own.
|
|
601
|
+
*/
|
|
602
|
+
function takeVerdict(
|
|
603
|
+
cli: string,
|
|
604
|
+
target: string,
|
|
605
|
+
arm: string | undefined,
|
|
606
|
+
workDir: string,
|
|
607
|
+
run: {
|
|
608
|
+
readonly envelope: string
|
|
609
|
+
readonly writes: readonly string[]
|
|
610
|
+
readonly escapes: readonly string[]
|
|
611
|
+
readonly concurrent: readonly string[]
|
|
612
|
+
readonly isEscapeWatched: boolean
|
|
613
|
+
},
|
|
614
|
+
): { readonly record: unknown; readonly code: number } {
|
|
615
|
+
const file = (name: string, body: string): string => {
|
|
616
|
+
const path = join(workDir, name)
|
|
617
|
+
writeFileSync(path, body, 'utf8')
|
|
618
|
+
return path
|
|
619
|
+
}
|
|
620
|
+
const lines = (list: readonly string[]): string =>
|
|
621
|
+
list.map((line) => `${line}\n`).join('')
|
|
622
|
+
|
|
623
|
+
const check = spawnSync(
|
|
624
|
+
process.execPath,
|
|
625
|
+
[
|
|
626
|
+
cli,
|
|
627
|
+
'sandbox',
|
|
628
|
+
'check',
|
|
629
|
+
target,
|
|
630
|
+
arm ?? '',
|
|
631
|
+
'--envelope',
|
|
632
|
+
file('envelope.json', run.envelope),
|
|
633
|
+
'--writes',
|
|
634
|
+
file('writes.txt', lines(run.writes)),
|
|
635
|
+
'--escapes',
|
|
636
|
+
file('escapes.txt', lines(run.escapes)),
|
|
637
|
+
'--concurrent-sessions',
|
|
638
|
+
file('concurrent.txt', lines(run.concurrent)),
|
|
639
|
+
...(run.isEscapeWatched ? ['--escapes-watched'] : []),
|
|
640
|
+
'--json',
|
|
641
|
+
],
|
|
642
|
+
{ encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] },
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
let record: unknown = null
|
|
646
|
+
try {
|
|
647
|
+
record = JSON.parse(check.stdout ?? '')
|
|
648
|
+
} catch {
|
|
649
|
+
record = null
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return { record, code: check.status ?? 1 }
|
|
653
|
+
}
|