@erclx/canon 5.25.0 → 5.26.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 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canvas/SKILL.md +1 -1
- package/claude/skills/context-fold/REQUIREMENT.md +6 -2
- package/claude/skills/context-fold/SKILL.md +8 -6
- package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
- package/claude/skills/design-taste/REQUIREMENT.md +1 -1
- package/claude/skills/design-taste/SKILL.md +4 -2
- package/claude/skills/design-taste/references/adopted.md +40 -0
- package/claude/skills/design-taste/references/kinds.md +1 -1
- package/claude/skills/design-taste/references/systems.md +1 -7
- package/claude/skills/design-taste/references/tells.md +2 -24
- package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
- package/claude/skills/document-health/REQUIREMENT.md +2 -0
- package/claude/skills/document-health/SKILL.md +21 -0
- package/claude/skills/document-health/references/axes.md +12 -0
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/draft-identity/SKILL.md +1 -1
- package/claude/skills/draft-screencast/SKILL.md +9 -7
- package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
- package/claude/skills/draft-slides/SKILL.md +31 -20
- package/claude/skills/git-issue/references/issue.md +10 -0
- package/claude/skills/git-pr/references/evidence.md +8 -0
- package/claude/skills/git-pr/references/labels.md +12 -0
- package/claude/skills/git-split/SKILL.md +19 -8
- package/claude/skills/markdown-propose/references/format.md +9 -0
- package/claude/skills/memory-review/references/later-phases.md +7 -0
- package/claude/skills/review-ui/references/post.md +4 -2
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
- package/claude/skills/search-craft/REQUIREMENT.md +1 -1
- package/claude/skills/search-craft/SKILL.md +3 -1
- package/claude/skills/search-craft/references/adopted.md +30 -0
- package/claude/skills/target-setup/references/detect.md +8 -0
- package/claude/skills/target-setup/references/indexes.md +13 -0
- package/claude/skills/target-setup/references/verify.md +8 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/test-first/references/adopted.md +36 -0
- package/claude/skills/write-human/SKILL.md +1 -1
- package/claude/skills/write-human/references/machine-tells.md +13 -0
- package/docs/agents/canvas.md +3 -1
- package/docs/agents/capture.md +1 -1
- package/docs/agents/commands.md +3 -3
- package/docs/agents/index.md +2 -2
- package/docs/agents/records-stale.md +22 -9
- package/docs/agents/records.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/skills-audit.md +10 -3
- package/docs/agents/slides.md +15 -3
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/index.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +42 -0
- package/docs/workflow/skill-map.md +1 -34
- package/package.json +1 -1
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-git.sh +1 -9
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/sandbox-hook.sh +99 -0
- package/src/canvas/capture.ts +1 -9
- package/src/canvas/client/inspector/color-field.tsx +368 -0
- package/src/canvas/client/inspector/fields.css +163 -22
- package/src/canvas/client/inspector/values.ts +123 -0
- package/src/canvas/client/inspector.tsx +46 -69
- package/src/capture/render.ts +45 -7
- package/src/capture/sources.ts +43 -0
- package/src/census/count.ts +1 -1
- package/src/claude/cases/workflow.ts +5 -0
- package/src/claude/seeds.ts +1 -1
- package/src/claude/skills-audit.ts +68 -14
- package/src/client-commands.ts +1 -1
- package/src/commands/claude.ts +27 -2
- package/src/commands/feedback.ts +3 -3
- package/src/commands/records.ts +88 -6
- package/src/commands/sandbox.ts +50 -10
- package/src/commands/slides.ts +31 -77
- package/src/context/audit.ts +1 -1
- package/src/design/tokens.ts +2 -18
- package/src/docs/read.ts +1 -1
- package/src/{github.ts → feedback/github.ts} +1 -1
- package/src/gate/measures.ts +6 -4
- package/src/gov/citations.ts +1 -1
- package/src/gov/payload.ts +1 -1
- package/src/gov/superseded.ts +2 -2
- package/src/markdown/ceiling.ts +1 -1
- package/src/record-root.ts +1 -0
- package/src/records/backup.ts +1 -0
- package/src/records/push-guard.ts +1 -1
- package/src/records/stale-canonical.ts +146 -0
- package/src/records/stale.ts +1 -1
- package/src/sandbox/hooks.ts +128 -0
- package/src/sandbox/provision.ts +596 -0
- package/src/sandbox/tree.ts +103 -4
- package/src/secrets/marker.ts +2 -2
- package/src/secrets/scan.ts +1 -1
- package/src/shipped/references.ts +2 -2
- package/src/slides/convert/export.ts +2 -16
- package/src/slides/decks.ts +110 -0
- package/src/standards/read.ts +1 -1
- package/src/sync/seeds-report.ts +1 -1
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/teach/nav.ts +1 -1
- package/src/tooling/inject.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/standards/architecture.md +1 -0
- package/standards/canvas.md +1 -1
- package/standards/requirements.md +2 -0
- package/standards/skill-practice.md +2 -1
- package/standards/skill.md +1 -0
- package/scripts/manage-sandbox.sh +0 -472
- package/src/slides/layouts.ts +0 -478
- package/src/slides/parse.ts +0 -84
- package/src/slides/render.ts +0 -110
- package/src/slides/styles.ts +0 -96
- /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
- /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
- /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
- /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
- /package/src/{binary.ts → sweep/binary.ts} +0 -0
- /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
- /package/src/{paths.ts → tasks/paths.ts} +0 -0
- /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { spawnSync, type StdioOptions } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
13
|
+
import { probeScenario, stageScenario } from '@/sandbox/hooks'
|
|
14
|
+
import { assertSandboxDirSafe, sandboxRoots, sandboxTree } from '@/sandbox/tree'
|
|
15
|
+
import {
|
|
16
|
+
intro,
|
|
17
|
+
logError,
|
|
18
|
+
logInfo,
|
|
19
|
+
logRemove,
|
|
20
|
+
logStep,
|
|
21
|
+
logWarn,
|
|
22
|
+
outro,
|
|
23
|
+
palette,
|
|
24
|
+
select,
|
|
25
|
+
} from '@/ui'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Provisions one scenario into the sandbox tree, and resets or cleans that tree.
|
|
29
|
+
* Each scenario's hooks run in bash through `sandbox-hook.sh`, and everything
|
|
30
|
+
* between them runs here, in the order the retired bash dispatcher held.
|
|
31
|
+
*
|
|
32
|
+
* Narration is part of the contract. `canon sandbox equivalence` compares the
|
|
33
|
+
* log line for line against the bash harness, so every message, its frame
|
|
34
|
+
* glyph, and its position in the order stays as that harness wrote it.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const ANCHOR_FIXTURE_PARTS = ['sandbox', 'fixtures', 'anchor', 'create']
|
|
38
|
+
const FIXTURE_SUFFIX = '.fixture'
|
|
39
|
+
const SKILL_PATHSPEC = 'claude/skills/**/SKILL.md'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ends the run with a status. The frame closes once, at the top, for every
|
|
43
|
+
* stop, which is what the bash harness's `trap close_timeline EXIT` did.
|
|
44
|
+
*/
|
|
45
|
+
class Halt extends Error {
|
|
46
|
+
constructor(
|
|
47
|
+
readonly status: number,
|
|
48
|
+
readonly isFrameOpen = true,
|
|
49
|
+
) {
|
|
50
|
+
super(`halted with ${status}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fail(message: string): never {
|
|
55
|
+
logError(message)
|
|
56
|
+
throw new Halt(1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function must(status: number): void {
|
|
60
|
+
if (status !== 0) throw new Halt(status)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface RunOptions {
|
|
64
|
+
readonly cwd?: string
|
|
65
|
+
readonly env?: NodeJS.ProcessEnv
|
|
66
|
+
readonly stdout?: 'ignore' | 'inherit'
|
|
67
|
+
readonly stderr?: 'ignore' | 'inherit'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function run(command: string, args: string[], options: RunOptions): number {
|
|
71
|
+
const stdio: StdioOptions = [
|
|
72
|
+
'ignore',
|
|
73
|
+
options.stdout ?? 'inherit',
|
|
74
|
+
options.stderr ?? 'inherit',
|
|
75
|
+
]
|
|
76
|
+
const result = spawnSync(command, args, {
|
|
77
|
+
cwd: options.cwd,
|
|
78
|
+
env: options.env,
|
|
79
|
+
stdio,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return result.status ?? 1
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readGit(cwd: string, args: string[]): string | undefined {
|
|
86
|
+
const result = spawnSync('git', args, {
|
|
87
|
+
cwd,
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return result.status === 0 ? result.stdout : undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function finish(message: string): void {
|
|
96
|
+
const { GREEN, NC } = palette(process.stderr)
|
|
97
|
+
outro()
|
|
98
|
+
process.stderr.write(`\n${GREEN}${message}${NC}\n`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function listFixtureFiles(dir: string): string[] {
|
|
102
|
+
if (!existsSync(dir)) return []
|
|
103
|
+
|
|
104
|
+
return (readdirSync(dir, { recursive: true, encoding: 'utf8' }) as string[])
|
|
105
|
+
.map((path) => join(dir, path))
|
|
106
|
+
.filter((path) => statSync(path).isFile())
|
|
107
|
+
.sort()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function stageAnchorTree(
|
|
111
|
+
root: string,
|
|
112
|
+
sandbox: string,
|
|
113
|
+
env: NodeJS.ProcessEnv,
|
|
114
|
+
): void {
|
|
115
|
+
// The anchor scenarios build their remote URL inside a command substitution,
|
|
116
|
+
// where a failure would leave an empty remote, so the guard fires here first.
|
|
117
|
+
if ((env.GITHUB_ORG ?? '') === '')
|
|
118
|
+
fail(
|
|
119
|
+
'GITHUB_ORG is empty. Export GITHUB_ORG=<org>, or run from a clone whose remote.origin.url points at GitHub.',
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
logStep('Staging anchor tree from fixture')
|
|
123
|
+
|
|
124
|
+
const fixtureDir = join(root, ...ANCHOR_FIXTURE_PARTS)
|
|
125
|
+
const files = listFixtureFiles(fixtureDir)
|
|
126
|
+
const stray = files.filter((path) => !path.endsWith(FIXTURE_SUFFIX))
|
|
127
|
+
if (stray.length > 0)
|
|
128
|
+
fail(`Fixture missing the ${FIXTURE_SUFFIX} suffix: ${stray.join(' ')} `)
|
|
129
|
+
if (files.length === 0)
|
|
130
|
+
fail(`Anchor fixture provisions nothing: ${fixtureDir}`)
|
|
131
|
+
|
|
132
|
+
rmSync(sandbox, { recursive: true, force: true })
|
|
133
|
+
mkdirSync(sandbox, { recursive: true })
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
const target = join(
|
|
136
|
+
sandbox,
|
|
137
|
+
relative(fixtureDir, file).slice(0, -FIXTURE_SUFFIX.length),
|
|
138
|
+
)
|
|
139
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
140
|
+
copyFileSync(file, target)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
must(run('git', ['init'], { cwd: sandbox, env, stdout: 'ignore' }))
|
|
144
|
+
must(run('git', ['add', '.'], { cwd: sandbox, env }))
|
|
145
|
+
must(
|
|
146
|
+
run(
|
|
147
|
+
'git',
|
|
148
|
+
[
|
|
149
|
+
'commit',
|
|
150
|
+
'-m',
|
|
151
|
+
'feat(sandbox): initial sandbox setup from anchor',
|
|
152
|
+
'--no-verify',
|
|
153
|
+
],
|
|
154
|
+
{ cwd: sandbox, env, stdout: 'ignore' },
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
logInfo(`Anchor tree staged from fixture, remote stays ${env.ANCHOR_REPO}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function initEmptyTree(sandbox: string, env: NodeJS.ProcessEnv): void {
|
|
161
|
+
rmSync(sandbox, { recursive: true, force: true })
|
|
162
|
+
mkdirSync(sandbox, { recursive: true })
|
|
163
|
+
writeFileSync(join(sandbox, '.gitignore'), '.canon/tmp/\nnode_modules\n')
|
|
164
|
+
|
|
165
|
+
must(run('git', ['init'], { cwd: sandbox, env, stdout: 'ignore' }))
|
|
166
|
+
must(
|
|
167
|
+
run('git', ['add', '.gitignore'], {
|
|
168
|
+
cwd: sandbox,
|
|
169
|
+
env,
|
|
170
|
+
stdout: 'ignore',
|
|
171
|
+
stderr: 'ignore',
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
must(
|
|
175
|
+
run(
|
|
176
|
+
'git',
|
|
177
|
+
[
|
|
178
|
+
'commit',
|
|
179
|
+
'-m',
|
|
180
|
+
'feat(sandbox): initial empty sandbox setup',
|
|
181
|
+
'--no-verify',
|
|
182
|
+
],
|
|
183
|
+
{ cwd: sandbox, env, stdout: 'ignore' },
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Scoped to the throwaway tree so the operator's global config is never
|
|
190
|
+
* written. The empty value resets the helper list git concatenates across
|
|
191
|
+
* scopes, so an operator's stale helper cannot win ahead of `gh`.
|
|
192
|
+
*/
|
|
193
|
+
function configureCredentials(sandbox: string, env: NodeJS.ProcessEnv): void {
|
|
194
|
+
must(run('git', ['config', 'credential.helper', ''], { cwd: sandbox, env }))
|
|
195
|
+
must(
|
|
196
|
+
run(
|
|
197
|
+
'git',
|
|
198
|
+
['config', '--add', 'credential.helper', '!gh auth git-credential'],
|
|
199
|
+
{ cwd: sandbox, env },
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Runs the real installer rather than copying the source tree, so a change to
|
|
206
|
+
* what install produces reaches the sandbox. Its own frame stays captured
|
|
207
|
+
* unless it fails, where it is the only thing naming the cause.
|
|
208
|
+
*/
|
|
209
|
+
function install(
|
|
210
|
+
root: string,
|
|
211
|
+
label: string,
|
|
212
|
+
args: string[],
|
|
213
|
+
env: NodeJS.ProcessEnv,
|
|
214
|
+
): void {
|
|
215
|
+
const result = spawnSync('bun', [join(root, 'src', 'cli.ts'), ...args], {
|
|
216
|
+
env: { ...env, CANON_NON_INTERACTIVE: '1' },
|
|
217
|
+
encoding: 'utf8',
|
|
218
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
219
|
+
})
|
|
220
|
+
if (result.status === 0) return
|
|
221
|
+
|
|
222
|
+
process.stderr.write(result.stderr ?? '')
|
|
223
|
+
fail(`Could not install ${label} into the sandbox.`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* No standards install. The corpus installs into no target, so a scenario
|
|
228
|
+
* driving a skill that reads one takes the `canon standards <name>` path a real
|
|
229
|
+
* project takes.
|
|
230
|
+
*/
|
|
231
|
+
function setupAssets(
|
|
232
|
+
root: string,
|
|
233
|
+
sandbox: string,
|
|
234
|
+
env: NodeJS.ProcessEnv,
|
|
235
|
+
): void {
|
|
236
|
+
if (
|
|
237
|
+
(env.SANDBOX_INJECT_SEEDS ?? '') !== '' &&
|
|
238
|
+
existsSync(join(root, 'tooling', 'claude', 'seeds'))
|
|
239
|
+
)
|
|
240
|
+
install(root, 'seeds', ['claude', 'init', sandbox], env)
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
(env.SANDBOX_INJECT_GOV ?? '') !== '' &&
|
|
244
|
+
existsSync(join(root, 'governance', 'rules'))
|
|
245
|
+
)
|
|
246
|
+
install(
|
|
247
|
+
root,
|
|
248
|
+
'gov rules',
|
|
249
|
+
['gov', 'install', env.SANDBOX_GOV_STACK || 'base', sandbox],
|
|
250
|
+
env,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
must(run('git', ['add', '.'], { cwd: sandbox, env }))
|
|
254
|
+
const isStaged =
|
|
255
|
+
run('git', ['diff', '--cached', '--quiet'], { cwd: sandbox, env }) !== 0
|
|
256
|
+
if (isStaged)
|
|
257
|
+
must(
|
|
258
|
+
run(
|
|
259
|
+
'git',
|
|
260
|
+
[
|
|
261
|
+
'commit',
|
|
262
|
+
'-m',
|
|
263
|
+
'chore(sandbox): initial environment setup',
|
|
264
|
+
'--no-verify',
|
|
265
|
+
],
|
|
266
|
+
{ cwd: sandbox, env, stdout: 'ignore' },
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Copies every skill body this checkout changed against the merge base into the
|
|
273
|
+
* tree, so a scenario exercises the branch's skill rather than the installed
|
|
274
|
+
* plugin's. The merge base against `origin/main` comes first, so a local `main`
|
|
275
|
+
* trailing the remote does not pull in skills other merged branches changed.
|
|
276
|
+
*/
|
|
277
|
+
export function injectChangedSkills(
|
|
278
|
+
root: string,
|
|
279
|
+
sandbox: string,
|
|
280
|
+
isSkipAutoCommit: boolean,
|
|
281
|
+
): string[] {
|
|
282
|
+
const base = (
|
|
283
|
+
readGit(root, ['merge-base', 'HEAD', 'origin/main']) ??
|
|
284
|
+
readGit(root, ['merge-base', 'HEAD', 'main']) ??
|
|
285
|
+
'main'
|
|
286
|
+
).trim()
|
|
287
|
+
|
|
288
|
+
const changed =
|
|
289
|
+
readGit(root, ['diff', base, '--name-only', '--', SKILL_PATHSPEC]) ?? ''
|
|
290
|
+
const untracked =
|
|
291
|
+
readGit(root, [
|
|
292
|
+
'ls-files',
|
|
293
|
+
'--others',
|
|
294
|
+
'--exclude-standard',
|
|
295
|
+
'--',
|
|
296
|
+
SKILL_PATHSPEC,
|
|
297
|
+
]) ?? ''
|
|
298
|
+
const paths = [
|
|
299
|
+
...new Set(
|
|
300
|
+
`${changed}\n${untracked}`.split('\n').filter((p) => p.trim() !== ''),
|
|
301
|
+
),
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
const injected: string[] = []
|
|
305
|
+
for (const path of paths) {
|
|
306
|
+
// A deleted skill lists beside a changed one, and nothing is left to inject.
|
|
307
|
+
if (!existsSync(join(root, path))) continue
|
|
308
|
+
|
|
309
|
+
const name = basename(dirname(path))
|
|
310
|
+
const target = join(sandbox, '.claude', 'skills', name)
|
|
311
|
+
mkdirSync(target, { recursive: true })
|
|
312
|
+
copyFileSync(join(root, path), join(target, 'SKILL.md'))
|
|
313
|
+
if (isSkipAutoCommit)
|
|
314
|
+
appendFileSync(
|
|
315
|
+
join(sandbox, '.git', 'info', 'exclude'),
|
|
316
|
+
`.claude/skills/${name}/SKILL.md\n`,
|
|
317
|
+
)
|
|
318
|
+
logInfo(`Injected dev skill: ${name}`)
|
|
319
|
+
injected.push(name)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return injected
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Commits what the scenario staged, unless it asked to stay uncommitted. A
|
|
327
|
+
* scenario that staged nothing fails the commit, and the status passes through
|
|
328
|
+
* as the bash harness's did.
|
|
329
|
+
*/
|
|
330
|
+
export function commitScenarioChanges(
|
|
331
|
+
sandbox: string,
|
|
332
|
+
isSkipAutoCommit: boolean,
|
|
333
|
+
env: NodeJS.ProcessEnv,
|
|
334
|
+
): number {
|
|
335
|
+
if (isSkipAutoCommit) {
|
|
336
|
+
logInfo('Skipping auto-commit')
|
|
337
|
+
return 0
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
logStep('Staging environment changes')
|
|
341
|
+
const quiet = {
|
|
342
|
+
cwd: sandbox,
|
|
343
|
+
env,
|
|
344
|
+
stdout: 'ignore',
|
|
345
|
+
stderr: 'ignore',
|
|
346
|
+
} as const
|
|
347
|
+
const added = run('git', ['add', '.'], quiet)
|
|
348
|
+
if (added !== 0) return added
|
|
349
|
+
const committed = run(
|
|
350
|
+
'git',
|
|
351
|
+
[
|
|
352
|
+
'commit',
|
|
353
|
+
'-m',
|
|
354
|
+
'chore(sandbox): apply scenario specific setup',
|
|
355
|
+
'--no-verify',
|
|
356
|
+
],
|
|
357
|
+
{ cwd: sandbox, env, stdout: 'ignore' },
|
|
358
|
+
)
|
|
359
|
+
if (committed !== 0) return committed
|
|
360
|
+
logInfo('Git state clean after setup')
|
|
361
|
+
|
|
362
|
+
return 0
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function tagBaseline(sandbox: string, env: NodeJS.ProcessEnv): void {
|
|
366
|
+
const quiet = {
|
|
367
|
+
cwd: sandbox,
|
|
368
|
+
env,
|
|
369
|
+
stdout: 'ignore',
|
|
370
|
+
stderr: 'ignore',
|
|
371
|
+
} as const
|
|
372
|
+
must(run('git', ['update-ref', 'refs/sandbox/baseline', 'HEAD'], quiet))
|
|
373
|
+
const tree = spawnSync('git', ['write-tree'], {
|
|
374
|
+
cwd: sandbox,
|
|
375
|
+
env,
|
|
376
|
+
encoding: 'utf8',
|
|
377
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
378
|
+
})
|
|
379
|
+
must(tree.status ?? 1)
|
|
380
|
+
must(
|
|
381
|
+
run(
|
|
382
|
+
'git',
|
|
383
|
+
['update-ref', 'refs/sandbox/baseline-index', tree.stdout.trim()],
|
|
384
|
+
quiet,
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
interface Scenario {
|
|
390
|
+
readonly category: string
|
|
391
|
+
readonly command: string
|
|
392
|
+
readonly arm: string | undefined
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function provisionScenario(
|
|
396
|
+
root: string,
|
|
397
|
+
sandbox: string,
|
|
398
|
+
scenario: Scenario,
|
|
399
|
+
baseEnv: NodeJS.ProcessEnv,
|
|
400
|
+
): void {
|
|
401
|
+
const env: NodeJS.ProcessEnv = { ...baseEnv }
|
|
402
|
+
if (scenario.arm !== undefined) {
|
|
403
|
+
env.SANDBOX_SCENARIO = scenario.arm
|
|
404
|
+
env.CANON_NON_INTERACTIVE = '1'
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const sandboxDir = join(root, 'sandbox')
|
|
408
|
+
const file = join(sandboxDir, scenario.category, `${scenario.command}.sh`)
|
|
409
|
+
if (!existsSync(file) || !statSync(file).isFile())
|
|
410
|
+
fail(`Sandbox script not found: ${scenario.category}/${scenario.command}`)
|
|
411
|
+
|
|
412
|
+
const probe = probeScenario(file, env)
|
|
413
|
+
if (!probe.ok) throw new Halt(probe.status)
|
|
414
|
+
Object.assign(env, probe.exports)
|
|
415
|
+
|
|
416
|
+
if (!existsSync(sandboxDir))
|
|
417
|
+
fail(`Sandbox directory not found at: ${sandboxDir}`)
|
|
418
|
+
|
|
419
|
+
const cwd = process.cwd()
|
|
420
|
+
if (cwd === sandbox || cwd.startsWith(`${sandbox}/`)) {
|
|
421
|
+
logWarn(
|
|
422
|
+
'Detected execution inside the sandbox. Switching to project root...',
|
|
423
|
+
)
|
|
424
|
+
process.chdir(root)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
logStep(`Provisioning ${scenario.category}:${scenario.command}`)
|
|
428
|
+
if (probe.isAnchor) stageAnchorTree(root, sandbox, env)
|
|
429
|
+
else initEmptyTree(sandbox, env)
|
|
430
|
+
configureCredentials(sandbox, env)
|
|
431
|
+
setupAssets(root, sandbox, env)
|
|
432
|
+
|
|
433
|
+
// A scenario that execs a verb as its last step hands the run to that verb,
|
|
434
|
+
// whose own frame is the last one written, so nothing follows it here.
|
|
435
|
+
const staged = stageScenario(file, env)
|
|
436
|
+
if (staged.ending === 'replaced') throw new Halt(staged.status, false)
|
|
437
|
+
if (staged.ending === 'exited') throw new Halt(staged.status)
|
|
438
|
+
Object.assign(env, staged.exports)
|
|
439
|
+
|
|
440
|
+
const isSkipAutoCommit = (env.SANDBOX_SKIP_AUTO_COMMIT ?? '') !== ''
|
|
441
|
+
injectChangedSkills(root, sandbox, isSkipAutoCommit)
|
|
442
|
+
must(commitScenarioChanges(sandbox, isSkipAutoCommit, env))
|
|
443
|
+
tagBaseline(sandbox, env)
|
|
444
|
+
|
|
445
|
+
finish('✓ Sandbox Ready')
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function cleanSandbox(sandbox: string): void {
|
|
449
|
+
logStep('Removing sandbox')
|
|
450
|
+
rmSync(sandbox, { recursive: true, force: true })
|
|
451
|
+
logRemove(sandbox)
|
|
452
|
+
finish('✓ Sandbox clean')
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function isAtBaseline(sandbox: string): boolean {
|
|
456
|
+
const head = readGit(sandbox, ['rev-parse', 'HEAD'])
|
|
457
|
+
const baseline = readGit(sandbox, ['rev-parse', 'refs/sandbox/baseline'])
|
|
458
|
+
if (head === undefined || head !== baseline) return false
|
|
459
|
+
|
|
460
|
+
const baselineIndex = readGit(sandbox, [
|
|
461
|
+
'rev-parse',
|
|
462
|
+
'refs/sandbox/baseline-index',
|
|
463
|
+
])
|
|
464
|
+
const index = readGit(sandbox, ['write-tree'])
|
|
465
|
+
if (baselineIndex !== undefined && index !== baselineIndex) return false
|
|
466
|
+
|
|
467
|
+
const quiet = { cwd: sandbox, stdout: 'ignore', stderr: 'ignore' } as const
|
|
468
|
+
if (run('git', ['diff', '--quiet'], quiet) !== 0) return false
|
|
469
|
+
|
|
470
|
+
const untracked = readGit(sandbox, [
|
|
471
|
+
'ls-files',
|
|
472
|
+
'--others',
|
|
473
|
+
'--exclude-standard',
|
|
474
|
+
])
|
|
475
|
+
|
|
476
|
+
return untracked === undefined || untracked === ''
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function resetSandbox(sandbox: string): Promise<void> {
|
|
480
|
+
if (!existsSync(join(sandbox, '.git')))
|
|
481
|
+
fail('No sandbox found. Run `canon sandbox` first.')
|
|
482
|
+
|
|
483
|
+
logStep('Sandbox state')
|
|
484
|
+
|
|
485
|
+
if (readGit(sandbox, ['rev-parse', 'refs/sandbox/baseline']) === undefined)
|
|
486
|
+
fail('No baseline found. Re-provision with `canon sandbox <cat>:<cmd>`.')
|
|
487
|
+
|
|
488
|
+
if (isAtBaseline(sandbox)) {
|
|
489
|
+
logInfo('At baseline')
|
|
490
|
+
finish('✓ Sandbox at baseline')
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
logWarn('Uncommitted changes detected')
|
|
495
|
+
|
|
496
|
+
const isConfirmed = await select({
|
|
497
|
+
message: 'Reset sandbox to initial state?',
|
|
498
|
+
options: [
|
|
499
|
+
{ value: true, label: 'Yes' },
|
|
500
|
+
{ value: false, label: 'No' },
|
|
501
|
+
],
|
|
502
|
+
nonInteractiveDefault: true,
|
|
503
|
+
})
|
|
504
|
+
if (!isConfirmed) {
|
|
505
|
+
logWarn('Reset cancelled')
|
|
506
|
+
outro()
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
logStep('Resetting sandbox')
|
|
511
|
+
const quiet = { cwd: sandbox } as const
|
|
512
|
+
must(
|
|
513
|
+
run('git', ['reset', '--hard', 'refs/sandbox/baseline', '--quiet'], quiet),
|
|
514
|
+
)
|
|
515
|
+
must(run('git', ['clean', '-fd', '--quiet'], quiet))
|
|
516
|
+
const baselineIndex = readGit(sandbox, [
|
|
517
|
+
'rev-parse',
|
|
518
|
+
'refs/sandbox/baseline-index',
|
|
519
|
+
])?.trim()
|
|
520
|
+
const index = readGit(sandbox, ['write-tree'])?.trim()
|
|
521
|
+
if (baselineIndex !== undefined && baselineIndex !== index) {
|
|
522
|
+
must(run('git', ['read-tree', baselineIndex], quiet))
|
|
523
|
+
must(run('git', ['checkout-index', '-a', '-f'], quiet))
|
|
524
|
+
}
|
|
525
|
+
logInfo('Sandbox reset to baseline')
|
|
526
|
+
finish('✓ Sandbox reset complete')
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Splits `<category>:<command>` at the first colon, as `IFS=: read` did, so a
|
|
531
|
+
* command name holding a colon keeps the rest.
|
|
532
|
+
*/
|
|
533
|
+
function parseTarget(target: string): { category: string; command: string } {
|
|
534
|
+
const split = target.indexOf(':')
|
|
535
|
+
if (split < 0)
|
|
536
|
+
fail(
|
|
537
|
+
"Invalid format. Use <category>:<command>, 'reset', 'clean', or --help",
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
return { category: target.slice(0, split), command: target.slice(split + 1) }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export interface SandboxRequest {
|
|
544
|
+
/** False where a caller already opened the frame, as `run.sh` and the picker do. */
|
|
545
|
+
readonly isHeader: boolean
|
|
546
|
+
/** `<category>:<command>`, `reset`, or `clean`. */
|
|
547
|
+
readonly target: string
|
|
548
|
+
readonly arm: string | undefined
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Runs one `canon sandbox` request and returns its exit status. Every stop
|
|
553
|
+
* closes the frame on the way out, success or not.
|
|
554
|
+
*/
|
|
555
|
+
export async function runSandbox(
|
|
556
|
+
root: string,
|
|
557
|
+
request: SandboxRequest,
|
|
558
|
+
): Promise<number> {
|
|
559
|
+
if (request.isHeader) intro('canon sandbox')
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
if (!process.cwd().startsWith(root))
|
|
563
|
+
fail(
|
|
564
|
+
'Context error: you must run this command from inside the toolkit repository.',
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
const sandbox = sandboxTree()
|
|
568
|
+
const unsafe = assertSandboxDirSafe(sandbox, sandboxRoots(root))
|
|
569
|
+
if (unsafe !== undefined) fail(unsafe)
|
|
570
|
+
|
|
571
|
+
if (request.target === 'reset') {
|
|
572
|
+
await resetSandbox(sandbox)
|
|
573
|
+
return 0
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (request.target === 'clean') {
|
|
577
|
+
cleanSandbox(sandbox)
|
|
578
|
+
return 0
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const { category, command } = parseTarget(request.target)
|
|
582
|
+
provisionScenario(
|
|
583
|
+
root,
|
|
584
|
+
sandbox,
|
|
585
|
+
{ category, command, arm: request.arm },
|
|
586
|
+
{ ...process.env, PROJECT_ROOT: root, GIT_TERMINAL_PROMPT: '0' },
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
return 0
|
|
590
|
+
} catch (error) {
|
|
591
|
+
if (!(error instanceof Halt)) throw error
|
|
592
|
+
if (error.isFrameOpen) outro()
|
|
593
|
+
|
|
594
|
+
return error.status
|
|
595
|
+
}
|
|
596
|
+
}
|
package/src/sandbox/tree.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
1
2
|
import { randomBytes } from 'node:crypto'
|
|
2
3
|
import { homedir } from 'node:os'
|
|
3
4
|
import { join } from 'node:path'
|
|
@@ -5,10 +6,11 @@ import { join } from 'node:path'
|
|
|
5
6
|
/**
|
|
6
7
|
* Mints a short per-run identifier the first time it is asked for and holds it
|
|
7
8
|
* in `CANON_SANDBOX_RUN_ID` for the rest of this process, so a script that
|
|
8
|
-
* spawns a child inheriting `process.env` — `run.sh` calling `
|
|
9
|
-
* and then `canon sandbox check` — resolves the same tree in every one
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* spawns a child inheriting `process.env` — `run.sh` calling `canon sandbox` to
|
|
10
|
+
* provision and then `canon sandbox check` — resolves the same tree in every one
|
|
11
|
+
* of them, as do the hook and installer children provisioning spawns. A process
|
|
12
|
+
* that already carries the variable, inherited from such a parent, reuses it
|
|
13
|
+
* rather than minting a new one.
|
|
12
14
|
*
|
|
13
15
|
* Twin of `mint_sandbox_run_id` in `scripts/lib/sandbox-path.sh`.
|
|
14
16
|
*/
|
|
@@ -45,3 +47,100 @@ export function sandboxTree(): string {
|
|
|
45
47
|
|
|
46
48
|
return join(base, 'canon', `sandbox-${mintSandboxRunId()}`)
|
|
47
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Twin of `normalize_sandbox_path` in `scripts/lib/sandbox-path.sh`, which the
|
|
53
|
+
* headless runner still sources. Lexical rather than `realpath`, since the guard
|
|
54
|
+
* runs before the tree exists, so nothing here follows a symlink.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeSandboxPath(path: string): string {
|
|
57
|
+
if (path === '') return ''
|
|
58
|
+
|
|
59
|
+
const isAbsolute = path.startsWith('/')
|
|
60
|
+
const resolved: string[] = []
|
|
61
|
+
let climbed = ''
|
|
62
|
+
|
|
63
|
+
for (const segment of path.split('/')) {
|
|
64
|
+
if (segment === '' || segment === '.') continue
|
|
65
|
+
if (segment !== '..') resolved.push(segment)
|
|
66
|
+
else if (resolved.length > 0) resolved.pop()
|
|
67
|
+
else if (!isAbsolute) climbed += '../'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (isAbsolute) return `/${resolved.join('/')}`
|
|
71
|
+
|
|
72
|
+
const relative = `${climbed}${resolved.join('/')}`.replace(/\/+$/, '')
|
|
73
|
+
|
|
74
|
+
return relative === '' ? '.' : relative
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether `candidate` is `target` or a directory containing it. */
|
|
78
|
+
function isAtOrAbove(candidate: string, target: string): boolean {
|
|
79
|
+
return candidate === target || target.startsWith(`${candidate}/`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SandboxRoots {
|
|
83
|
+
readonly home: string
|
|
84
|
+
readonly temp: string
|
|
85
|
+
readonly mainRoot: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The roots the guard measures against. The repository test reads the main
|
|
90
|
+
* worktree rather than `PROJECT_ROOT`, since a linked worktree sits inside it.
|
|
91
|
+
*/
|
|
92
|
+
export function sandboxRoots(projectRoot: string): SandboxRoots {
|
|
93
|
+
const listing = spawnSync('git', ['worktree', 'list', '--porcelain'], {
|
|
94
|
+
cwd: projectRoot,
|
|
95
|
+
encoding: 'utf8',
|
|
96
|
+
})
|
|
97
|
+
const line = (listing.stdout ?? '')
|
|
98
|
+
.split('\n')
|
|
99
|
+
.find((l) => l.startsWith('worktree '))
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
home: process.env.HOME || '/root',
|
|
103
|
+
temp: process.env.TMPDIR || '/tmp',
|
|
104
|
+
mainRoot: line === undefined ? projectRoot : line.slice('worktree '.length),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns the reason a location cannot hold the tree, or undefined when it can.
|
|
110
|
+
* Provisioning removes the tree before staging, so the test is an allowlist: a
|
|
111
|
+
* strict descendant of the home folder or the temp root, outside the main
|
|
112
|
+
* worktree in both directions.
|
|
113
|
+
*
|
|
114
|
+
* Twin of `assert_sandbox_dir_safe` in `scripts/lib/sandbox-path.sh`, message
|
|
115
|
+
* for message, since the headless runner still guards through that copy.
|
|
116
|
+
*/
|
|
117
|
+
export function assertSandboxDirSafe(
|
|
118
|
+
raw: string,
|
|
119
|
+
roots: SandboxRoots,
|
|
120
|
+
): string | undefined {
|
|
121
|
+
if (!raw.startsWith('/'))
|
|
122
|
+
return `CANON_SANDBOX_DIR must be an absolute path, got: ${raw || '<empty>'}`
|
|
123
|
+
|
|
124
|
+
if (raw.length > 4096)
|
|
125
|
+
return `Refusing the sandbox path. It is ${raw.length} characters, past the longest path any filesystem here accepts.`
|
|
126
|
+
|
|
127
|
+
const dir = normalizeSandboxPath(raw)
|
|
128
|
+
const home = normalizeSandboxPath(roots.home)
|
|
129
|
+
const temp = normalizeSandboxPath(roots.temp)
|
|
130
|
+
const mainRoot = normalizeSandboxPath(roots.mainRoot)
|
|
131
|
+
const resolution = dir === raw ? '' : ` It resolves to ${dir}.`
|
|
132
|
+
|
|
133
|
+
if (!isAtOrAbove(home, dir) && !isAtOrAbove(temp, dir))
|
|
134
|
+
return `Refusing ${raw} as the sandbox. Provisioning removes the tree first, so the path has to sit under ${home} or ${temp}.${resolution}`
|
|
135
|
+
|
|
136
|
+
if (dir === home || dir === temp)
|
|
137
|
+
return `Refusing ${raw} as the sandbox. Provisioning removes the tree first, so the path has to sit under ${dir} rather than be it.`
|
|
138
|
+
|
|
139
|
+
if (isAtOrAbove(dir, mainRoot))
|
|
140
|
+
return `Refusing ${raw} as the sandbox. Provisioning removes the tree first, and that path contains ${mainRoot}.${resolution}`
|
|
141
|
+
|
|
142
|
+
if (dir.startsWith(`${mainRoot}/`))
|
|
143
|
+
return `Sandbox at ${raw} sits inside ${mainRoot}, which puts the toolkit CLAUDE.md back on the session ancestor chain.${resolution} Point CANON_SANDBOX_DIR outside the repository.`
|
|
144
|
+
|
|
145
|
+
return undefined
|
|
146
|
+
}
|