@erclx/canon 5.27.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-ready/SKILL.md +3 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +16 -7
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-craft/references/ui.md +4 -4
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +13 -11
- 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 +24 -26
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
- 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 +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- 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 +2 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +3 -2
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/docs/agents/canvas.md +4 -4
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +2 -1
- package/docs/agents/pr-evidence.md +9 -8
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +2 -2
- 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 +10 -15
- package/docs/workflow/parallel-features.md +7 -7
- package/package.json +1 -1
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +1 -1
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/inspector/field.tsx +61 -2
- package/src/canvas/client/inspector/fields.css +78 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +74 -2
- package/src/canvas/client/inspector.tsx +121 -11
- package/src/canvas/client/keys.ts +54 -0
- package/src/canvas/client/selection.tsx +3 -1
- package/src/canvas/client/shell.css +108 -2
- package/src/canvas/client/state.ts +96 -5
- package/src/canvas/client/surface.tsx +194 -17
- package/src/canvas/client/tools.tsx +64 -0
- package/src/canvas/content.ts +1 -1
- package/src/canvas/edit.ts +12 -2
- package/src/canvas/tokens.ts +1 -1
- package/src/claude/seeds.ts +2 -2
- package/src/claude/skills-reach.ts +1 -1
- package/src/commands/canvas.ts +1 -1
- package/src/commands/claude.ts +3 -3
- package/src/commands/design.ts +3 -3
- 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 +2 -2
- package/src/commands/init.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +183 -7
- package/src/commands/sandbox.ts +10 -13
- package/src/commands/slides.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/tasks.ts +19 -2
- package/src/commands/tooling.ts +1 -1
- package/src/commands/transcripts.ts +1 -1
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/wiki.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +2 -2
- package/src/context/classify/extract.ts +1 -1
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/docs/read.ts +1 -1
- package/src/exec.ts +1 -1
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +1 -1
- package/src/gate/sequencer.ts +55 -3
- package/src/gate/stages.ts +8 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/intake/folder.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- 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/evidence.ts +154 -16
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +1 -1
- 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 +1 -1
- 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/hooks.ts +1 -1
- package/src/sandbox/provision.ts +78 -28
- package/src/sandbox/scenario.ts +588 -0
- package/src/sessions/live.ts +4 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/decks.ts +1 -1
- package/src/standards/read.ts +1 -1
- package/src/sync/engine.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/stamp.ts +1 -1
- package/src/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 +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 +1 -1
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/plan.md +13 -7
- package/tsconfig.json +1 -1
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { spawnSync, type StdioOptions } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
13
|
+
import { dirname, join, relative } from 'node:path'
|
|
14
|
+
import { logError, logInfo, logStep, logWarn, palette, select } from '@/ui'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The TypeScript form of a sandbox scenario: `sandbox/<category>/<command>.ts`,
|
|
18
|
+
* whose default export declares what the bash form split across `use_config`,
|
|
19
|
+
* `use_anchor`, and `stage_setup`. Provisioning, the catalog, coverage, census,
|
|
20
|
+
* and equivalence all read a scenario through this module, so a stem holding
|
|
21
|
+
* either extension reads the same everywhere.
|
|
22
|
+
*
|
|
23
|
+
* Arms run in the harness process rather than a bash child, so nothing here
|
|
24
|
+
* spawns without the tree as its `cwd`. A scenario reaches the tree through the
|
|
25
|
+
* context alone.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Holds fixture content rather than scenarios, so no listing reads it as a category. */
|
|
29
|
+
export const FIXTURES_DIR = 'fixtures'
|
|
30
|
+
|
|
31
|
+
export const FIXTURE_SUFFIX = '.fixture'
|
|
32
|
+
|
|
33
|
+
/** A scenario whose only arm carries this name routes none, as a bash file with no picker did. */
|
|
34
|
+
export const DEFAULT_ARM_NAME = 'default'
|
|
35
|
+
|
|
36
|
+
/** Every bash scenario passed this prompt to `select_or_route_scenario`. */
|
|
37
|
+
const ROUTE_PROMPT = 'Which scenario?'
|
|
38
|
+
|
|
39
|
+
/** Every scenario that sets the anchor pushes to this one throwaway repository. */
|
|
40
|
+
export const SANDBOX_ANCHOR_REPO = 'canon-sandbox'
|
|
41
|
+
|
|
42
|
+
const EXTENSIONS = ['.ts', '.sh'] as const
|
|
43
|
+
|
|
44
|
+
export type ScenarioKind = 'ts' | 'sh'
|
|
45
|
+
|
|
46
|
+
export type ArmSetup = (ctx: StageContext) => void | Promise<void>
|
|
47
|
+
|
|
48
|
+
export interface ScenarioDefinition {
|
|
49
|
+
/** The `use_config` exports: presence flags such as `SANDBOX_SKIP_AUTO_COMMIT`. */
|
|
50
|
+
readonly config?: Readonly<Record<string, string>>
|
|
51
|
+
/** Stages the tree from the anchor fixture and names the throwaway remote. */
|
|
52
|
+
readonly anchor?: boolean
|
|
53
|
+
/** In routing order, so the first arm is the one a headless caller gets. */
|
|
54
|
+
readonly arms: Readonly<Record<string, ArmSetup>>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function scenario(definition: ScenarioDefinition): ScenarioDefinition {
|
|
58
|
+
return definition
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ScenarioFile {
|
|
62
|
+
readonly command: string
|
|
63
|
+
readonly file: string
|
|
64
|
+
readonly kind: ScenarioKind
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Lists the scenarios in one category folder, one per stem. A stem present in
|
|
69
|
+
* both forms is reported apart rather than listed twice, since picking one
|
|
70
|
+
* without saying so is the failure the refusal exists to prevent.
|
|
71
|
+
*/
|
|
72
|
+
export function listScenarioFiles(categoryDir: string): {
|
|
73
|
+
scenarios: ScenarioFile[]
|
|
74
|
+
ambiguous: string[]
|
|
75
|
+
} {
|
|
76
|
+
const byStem = new Map<string, ScenarioFile[]>()
|
|
77
|
+
for (const entry of readdirSync(categoryDir, { withFileTypes: true })) {
|
|
78
|
+
if (!entry.isFile()) continue
|
|
79
|
+
const kind = EXTENSIONS.find((ext) => entry.name.endsWith(ext))
|
|
80
|
+
if (kind === undefined) continue
|
|
81
|
+
const command = entry.name.slice(0, -kind.length)
|
|
82
|
+
const found = byStem.get(command) ?? []
|
|
83
|
+
found.push({
|
|
84
|
+
command,
|
|
85
|
+
file: join(categoryDir, entry.name),
|
|
86
|
+
kind: kind === '.ts' ? 'ts' : 'sh',
|
|
87
|
+
})
|
|
88
|
+
byStem.set(command, found)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const scenarios: ScenarioFile[] = []
|
|
92
|
+
const ambiguous: string[] = []
|
|
93
|
+
for (const [command, found] of [...byStem].sort(([a], [b]) =>
|
|
94
|
+
a.localeCompare(b),
|
|
95
|
+
)) {
|
|
96
|
+
if (found.length > 1) ambiguous.push(command)
|
|
97
|
+
else if (found[0] !== undefined) scenarios.push(found[0])
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { scenarios, ambiguous }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every category folder under `sandbox/`, fixtures excluded. */
|
|
104
|
+
export function listCategories(sandboxDir: string): string[] {
|
|
105
|
+
if (!existsSync(sandboxDir)) return []
|
|
106
|
+
|
|
107
|
+
return readdirSync(sandboxDir, { withFileTypes: true })
|
|
108
|
+
.filter((entry) => entry.isDirectory() && entry.name !== FIXTURES_DIR)
|
|
109
|
+
.map((entry) => entry.name)
|
|
110
|
+
.sort()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type ResolvedScenario =
|
|
114
|
+
| ({ readonly ok: true } & ScenarioFile)
|
|
115
|
+
| { readonly ok: false; readonly reason: 'missing' | 'ambiguous' }
|
|
116
|
+
|
|
117
|
+
export function resolveScenarioFile(
|
|
118
|
+
sandboxDir: string,
|
|
119
|
+
category: string,
|
|
120
|
+
command: string,
|
|
121
|
+
): ResolvedScenario {
|
|
122
|
+
const found = EXTENSIONS.map((ext) =>
|
|
123
|
+
join(sandboxDir, category, `${command}${ext}`),
|
|
124
|
+
).filter((path) => existsSync(path) && statSync(path).isFile())
|
|
125
|
+
if (found.length > 1) return { ok: false, reason: 'ambiguous' }
|
|
126
|
+
const file = found[0]
|
|
127
|
+
if (file === undefined) return { ok: false, reason: 'missing' }
|
|
128
|
+
|
|
129
|
+
return { ok: true, command, file, kind: file.endsWith('.ts') ? 'ts' : 'sh' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
133
|
+
return typeof value === 'object' && value !== null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Imports a scenario module and checks its shape, throwing on one that would
|
|
138
|
+
* provision something other than what it declares. Synchronous, since the
|
|
139
|
+
* catalog readers that call it are.
|
|
140
|
+
*/
|
|
141
|
+
export function loadScenario(file: string): ScenarioDefinition {
|
|
142
|
+
const module: unknown = createRequire(import.meta.url)(file)
|
|
143
|
+
const definition = isRecord(module) ? module.default : undefined
|
|
144
|
+
if (!isRecord(definition))
|
|
145
|
+
throw new Error(`${file} has no default export from scenario()`)
|
|
146
|
+
|
|
147
|
+
const { arms, config, anchor } = definition
|
|
148
|
+
if (!isRecord(arms) || Object.keys(arms).length === 0)
|
|
149
|
+
throw new Error(`${file} declares no arms`)
|
|
150
|
+
for (const [name, setup] of Object.entries(arms))
|
|
151
|
+
if (typeof setup !== 'function')
|
|
152
|
+
throw new Error(`${file} arm "${name}" is not a function`)
|
|
153
|
+
if (
|
|
154
|
+
config !== undefined &&
|
|
155
|
+
(!isRecord(config) ||
|
|
156
|
+
Object.values(config).some((v) => typeof v !== 'string'))
|
|
157
|
+
)
|
|
158
|
+
throw new Error(`${file} config holds a value that is not a string`)
|
|
159
|
+
if (anchor !== undefined && typeof anchor !== 'boolean')
|
|
160
|
+
throw new Error(`${file} anchor is not a boolean`)
|
|
161
|
+
|
|
162
|
+
return definition as unknown as ScenarioDefinition
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The arm names a caller routes to, empty for a scenario that routes none. */
|
|
166
|
+
export function armNames(definition: ScenarioDefinition): string[] {
|
|
167
|
+
const names = Object.keys(definition.arms)
|
|
168
|
+
|
|
169
|
+
return names.length === 1 && names[0] === DEFAULT_ARM_NAME ? [] : names
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Twin of `scripts/config.sh`, which derives the organization from the toolkit's own remote. */
|
|
173
|
+
function deriveGithubOrg(root: string, env: NodeJS.ProcessEnv): string {
|
|
174
|
+
if ((env.GITHUB_ORG ?? '') !== '') return env.GITHUB_ORG ?? ''
|
|
175
|
+
|
|
176
|
+
const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], {
|
|
177
|
+
cwd: root,
|
|
178
|
+
env,
|
|
179
|
+
encoding: 'utf8',
|
|
180
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
181
|
+
})
|
|
182
|
+
const url = origin.status === 0 ? origin.stdout.trim() : ''
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
/^(?:git@github\.com:|https:\/\/github\.com\/)([^/]+)\/.+/.exec(url)?.[1] ??
|
|
186
|
+
''
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* What the bash probe reported for a scenario: its config, the organization
|
|
192
|
+
* `config.sh` derives, and the anchor's repository name. These land in the one
|
|
193
|
+
* environment provisioning passes to every child, the seed and gov installs
|
|
194
|
+
* included.
|
|
195
|
+
*/
|
|
196
|
+
export function scenarioExports(
|
|
197
|
+
definition: ScenarioDefinition,
|
|
198
|
+
root: string,
|
|
199
|
+
env: NodeJS.ProcessEnv,
|
|
200
|
+
): Record<string, string> {
|
|
201
|
+
const exports: Record<string, string> = {
|
|
202
|
+
GITHUB_ORG: deriveGithubOrg(root, env),
|
|
203
|
+
...definition.config,
|
|
204
|
+
}
|
|
205
|
+
if (definition.anchor === true) exports.ANCHOR_REPO = SANDBOX_ANCHOR_REPO
|
|
206
|
+
|
|
207
|
+
return exports
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Ends an arm early. `exited` is a `stage_setup` that failed or called `exit`,
|
|
212
|
+
* and `replaced` is one that handed the run to a verb through `exec`.
|
|
213
|
+
*/
|
|
214
|
+
export class ScenarioStop extends Error {
|
|
215
|
+
constructor(
|
|
216
|
+
readonly status: number,
|
|
217
|
+
readonly ending: 'exited' | 'replaced',
|
|
218
|
+
) {
|
|
219
|
+
super(`scenario stopped with ${status}`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface RunOptions {
|
|
224
|
+
readonly stdout?: 'inherit' | 'ignore'
|
|
225
|
+
readonly stderr?: 'inherit' | 'ignore'
|
|
226
|
+
/** Returns a failing status rather than stopping the arm, the `|| true` of the bash form. */
|
|
227
|
+
readonly allowFailure?: boolean
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface StageContext {
|
|
231
|
+
/** The sandbox tree, which every command the context runs takes as its `cwd`. */
|
|
232
|
+
readonly dir: string
|
|
233
|
+
readonly root: string
|
|
234
|
+
/** Passed to every command, and read back by provisioning once the arm returns. */
|
|
235
|
+
readonly env: NodeJS.ProcessEnv
|
|
236
|
+
readonly arm: string | undefined
|
|
237
|
+
readonly log: {
|
|
238
|
+
info(message: string): void
|
|
239
|
+
step(message: string): void
|
|
240
|
+
warn(message: string): void
|
|
241
|
+
/** A bare frame line, the `echo -e "${GREY}│${NC}"` of the bash form. */
|
|
242
|
+
bar(): void
|
|
243
|
+
}
|
|
244
|
+
fixtures(category: string, command: string, arm: string, stage: string): void
|
|
245
|
+
git(...args: string[]): void
|
|
246
|
+
/** `git commit -m <subject> --no-verify -q`, over whatever is staged. */
|
|
247
|
+
commit(subject: string): void
|
|
248
|
+
run(command: string, args: readonly string[], options?: RunOptions): number
|
|
249
|
+
/** Runs a command and returns its stdout, stopping the arm on a failure. */
|
|
250
|
+
read(command: string, args: readonly string[], options?: RunOptions): string
|
|
251
|
+
write(path: string, content: string): void
|
|
252
|
+
append(path: string, content: string): void
|
|
253
|
+
mkdir(path: string): void
|
|
254
|
+
readFile(path: string): string
|
|
255
|
+
/** Sets the author `configure_sandbox_git_identity` did. */
|
|
256
|
+
identity(): void
|
|
257
|
+
/** Probes the anchor, sets the author, and points `origin` at it. */
|
|
258
|
+
anchorRemote(): void
|
|
259
|
+
fail(message: string): never
|
|
260
|
+
/** Hands the run to a verb whose own frame closes it. */
|
|
261
|
+
exec(command: string, args: readonly string[]): never
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Every file under a folder, sorted, the order `find | sort` gave the bash form. */
|
|
265
|
+
export function listFixtureFiles(dir: string): string[] {
|
|
266
|
+
if (!existsSync(dir)) return []
|
|
267
|
+
|
|
268
|
+
return (readdirSync(dir, { recursive: true, encoding: 'utf8' }) as string[])
|
|
269
|
+
.map((path) => join(dir, path))
|
|
270
|
+
.filter((path) => statSync(path).isFile())
|
|
271
|
+
.sort()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface ContextOptions {
|
|
275
|
+
readonly root: string
|
|
276
|
+
readonly dir: string
|
|
277
|
+
readonly env: NodeJS.ProcessEnv
|
|
278
|
+
readonly arm: string | undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function createStageContext(options: ContextOptions): StageContext {
|
|
282
|
+
const { root, dir, env } = options
|
|
283
|
+
|
|
284
|
+
const fail = (message: string): never => {
|
|
285
|
+
logError(message)
|
|
286
|
+
throw new ScenarioStop(1, 'exited')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const spawn = (
|
|
290
|
+
command: string,
|
|
291
|
+
args: readonly string[],
|
|
292
|
+
stdout: 'inherit' | 'ignore' | 'pipe',
|
|
293
|
+
stderr: 'inherit' | 'ignore' | 'pipe',
|
|
294
|
+
) => {
|
|
295
|
+
const stdio: StdioOptions = ['ignore', stdout, stderr]
|
|
296
|
+
|
|
297
|
+
return spawnSync(command, [...args], {
|
|
298
|
+
cwd: dir,
|
|
299
|
+
env,
|
|
300
|
+
encoding: 'utf8',
|
|
301
|
+
stdio,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const run = (
|
|
306
|
+
command: string,
|
|
307
|
+
args: readonly string[],
|
|
308
|
+
runOptions: RunOptions = {},
|
|
309
|
+
): number => {
|
|
310
|
+
const result = spawn(
|
|
311
|
+
command,
|
|
312
|
+
args,
|
|
313
|
+
runOptions.stdout ?? 'inherit',
|
|
314
|
+
runOptions.stderr ?? 'inherit',
|
|
315
|
+
)
|
|
316
|
+
const status = result.status ?? 1
|
|
317
|
+
if (status !== 0 && runOptions.allowFailure !== true)
|
|
318
|
+
throw new ScenarioStop(status, 'exited')
|
|
319
|
+
|
|
320
|
+
return status
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const read = (
|
|
324
|
+
command: string,
|
|
325
|
+
args: readonly string[],
|
|
326
|
+
runOptions: RunOptions = {},
|
|
327
|
+
): string => {
|
|
328
|
+
const result = spawn(command, args, 'pipe', runOptions.stderr ?? 'inherit')
|
|
329
|
+
const status = result.status ?? 1
|
|
330
|
+
if (status !== 0 && runOptions.allowFailure !== true)
|
|
331
|
+
throw new ScenarioStop(status, 'exited')
|
|
332
|
+
|
|
333
|
+
return status === 0 ? result.stdout : ''
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const git = (...args: string[]): void => {
|
|
337
|
+
run('git', args)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const inTree = (path: string): string => join(dir, path)
|
|
341
|
+
|
|
342
|
+
const identity = (): void => {
|
|
343
|
+
const globalValue = (key: string): string =>
|
|
344
|
+
read('git', ['config', '--global', key], {
|
|
345
|
+
stderr: 'ignore',
|
|
346
|
+
allowFailure: true,
|
|
347
|
+
}).trim()
|
|
348
|
+
env.SANDBOX_GIT_NAME =
|
|
349
|
+
env.SANDBOX_GIT_NAME || globalValue('user.name') || 'canon-sandbox'
|
|
350
|
+
env.SANDBOX_GIT_EMAIL =
|
|
351
|
+
env.SANDBOX_GIT_EMAIL ||
|
|
352
|
+
globalValue('user.email') ||
|
|
353
|
+
'sandbox@example.com'
|
|
354
|
+
git('config', 'user.name', env.SANDBOX_GIT_NAME)
|
|
355
|
+
git('config', 'user.email', env.SANDBOX_GIT_EMAIL)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* `gh` answers an absent repository and an unreachable host with one status,
|
|
360
|
+
* so the 404 alone separates them. Creating is the opt-in, for the reason
|
|
361
|
+
* `ensure_sandbox_anchor_repo` in `scripts/lib/sandbox-git.sh` records.
|
|
362
|
+
*/
|
|
363
|
+
const ensureAnchorRepo = (org: string, repo: string): void => {
|
|
364
|
+
const probe = spawn(
|
|
365
|
+
'gh',
|
|
366
|
+
['api', `repos/${org}/${repo}`, '--silent'],
|
|
367
|
+
'pipe',
|
|
368
|
+
'pipe',
|
|
369
|
+
)
|
|
370
|
+
if (probe.status === 0) return
|
|
371
|
+
|
|
372
|
+
const output = `${probe.stdout ?? ''}${probe.stderr ?? ''}`.trim()
|
|
373
|
+
if (!output.includes('HTTP 404'))
|
|
374
|
+
fail(`Cannot reach ${org}/${repo}: ${output}`)
|
|
375
|
+
if (!['1', 'true'].includes(env.SANDBOX_ANCHOR_CREATE ?? ''))
|
|
376
|
+
fail(
|
|
377
|
+
`${org}/${repo} does not exist. Check GITHUB_ORG and whether a rename is pending, then create it with 'gh repo create ${org}/${repo} --private' or re-run with SANDBOX_ANCHOR_CREATE=true.`,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
logWarn(
|
|
381
|
+
`${org}/${repo} does not exist and SANDBOX_ANCHOR_CREATE is set. Creating it as private.`,
|
|
382
|
+
)
|
|
383
|
+
const created = spawn(
|
|
384
|
+
'gh',
|
|
385
|
+
['repo', 'create', `${org}/${repo}`, '--private'],
|
|
386
|
+
'pipe',
|
|
387
|
+
'pipe',
|
|
388
|
+
)
|
|
389
|
+
if (created.status !== 0)
|
|
390
|
+
fail(
|
|
391
|
+
`Could not create ${org}/${repo}: ${`${created.stdout ?? ''}${created.stderr ?? ''}`.trim()}`,
|
|
392
|
+
)
|
|
393
|
+
logInfo(`Created ${org}/${repo} as a private repository.`)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const anchorRemote = (): void => {
|
|
397
|
+
const org = env.GITHUB_ORG ?? ''
|
|
398
|
+
const repo = env.ANCHOR_REPO ?? ''
|
|
399
|
+
if (org === '')
|
|
400
|
+
fail(
|
|
401
|
+
'GITHUB_ORG is empty. Export GITHUB_ORG=<org>, or run from a clone whose remote.origin.url points at GitHub.',
|
|
402
|
+
)
|
|
403
|
+
if (repo === '')
|
|
404
|
+
fail(
|
|
405
|
+
'The anchor remote needs a repository name. Set anchor: true on the scenario.',
|
|
406
|
+
)
|
|
407
|
+
ensureAnchorRepo(org, repo)
|
|
408
|
+
identity()
|
|
409
|
+
run('git', ['remote', 'remove', 'origin'], {
|
|
410
|
+
stderr: 'ignore',
|
|
411
|
+
allowFailure: true,
|
|
412
|
+
})
|
|
413
|
+
git('remote', 'add', 'origin', `https://github.com/${org}/${repo}.git`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Twin of `stage_fixtures` in `scripts/lib/sandbox-fixtures.sh`. */
|
|
417
|
+
const fixtures = (
|
|
418
|
+
category: string,
|
|
419
|
+
command: string,
|
|
420
|
+
arm: string,
|
|
421
|
+
stage: string,
|
|
422
|
+
): void => {
|
|
423
|
+
const stageDir = join(
|
|
424
|
+
root,
|
|
425
|
+
'sandbox',
|
|
426
|
+
FIXTURES_DIR,
|
|
427
|
+
category,
|
|
428
|
+
command,
|
|
429
|
+
arm,
|
|
430
|
+
stage,
|
|
431
|
+
)
|
|
432
|
+
if (!existsSync(stageDir))
|
|
433
|
+
fail(`Fixture stage not found: ${category}/${command}/${arm}/${stage}`)
|
|
434
|
+
|
|
435
|
+
const createFiles = listFixtureFiles(join(stageDir, 'create'))
|
|
436
|
+
const appendFiles = listFixtureFiles(join(stageDir, 'append'))
|
|
437
|
+
for (const files of [createFiles, appendFiles]) {
|
|
438
|
+
const stray = files.filter((path) => !path.endsWith(FIXTURE_SUFFIX))
|
|
439
|
+
if (stray.length > 0)
|
|
440
|
+
fail(
|
|
441
|
+
`Fixture missing the ${FIXTURE_SUFFIX} suffix: ${stray.join(' ')} `,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
if (createFiles.length + appendFiles.length === 0)
|
|
445
|
+
fail(
|
|
446
|
+
`Fixture stage provisions nothing: ${category}/${command}/${arm}/${stage}`,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
const target = (base: string, file: string): string =>
|
|
450
|
+
relative(base, file).slice(0, -FIXTURE_SUFFIX.length)
|
|
451
|
+
|
|
452
|
+
for (const file of createFiles) {
|
|
453
|
+
const path = inTree(target(join(stageDir, 'create'), file))
|
|
454
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
455
|
+
copyFileSync(file, path)
|
|
456
|
+
}
|
|
457
|
+
// An append extends a file the anchor or the seeds provide, so a missing
|
|
458
|
+
// target means that upstream shape changed rather than a file to create.
|
|
459
|
+
for (const file of appendFiles) {
|
|
460
|
+
const rel = target(join(stageDir, 'append'), file)
|
|
461
|
+
if (!existsSync(inTree(rel))) fail(`Append fixture has no target: ${rel}`)
|
|
462
|
+
appendFileSync(inTree(rel), readFileSync(file))
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
dir,
|
|
468
|
+
root,
|
|
469
|
+
env,
|
|
470
|
+
arm: options.arm,
|
|
471
|
+
log: {
|
|
472
|
+
info: logInfo,
|
|
473
|
+
step: logStep,
|
|
474
|
+
warn: logWarn,
|
|
475
|
+
bar: () => {
|
|
476
|
+
const { GREY, NC } = palette(process.stderr)
|
|
477
|
+
process.stderr.write(`${GREY}│${NC}\n`)
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
fixtures,
|
|
481
|
+
git,
|
|
482
|
+
commit: (subject) => git('commit', '-m', subject, '--no-verify', '-q'),
|
|
483
|
+
run,
|
|
484
|
+
read,
|
|
485
|
+
write: (path, content) => {
|
|
486
|
+
mkdirSync(dirname(inTree(path)), { recursive: true })
|
|
487
|
+
writeFileSync(inTree(path), content)
|
|
488
|
+
},
|
|
489
|
+
append: (path, content) => appendFileSync(inTree(path), content),
|
|
490
|
+
mkdir: (path) => mkdirSync(inTree(path), { recursive: true }),
|
|
491
|
+
readFile: (path) => readFileSync(inTree(path), 'utf8'),
|
|
492
|
+
identity,
|
|
493
|
+
anchorRemote,
|
|
494
|
+
fail,
|
|
495
|
+
exec: (command, args) => {
|
|
496
|
+
const result = spawnSync(command, [...args], {
|
|
497
|
+
cwd: dir,
|
|
498
|
+
env,
|
|
499
|
+
stdio: 'inherit',
|
|
500
|
+
})
|
|
501
|
+
throw new ScenarioStop(result.status ?? 1, 'replaced')
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Picks the arm the way `select_or_route_scenario` did: a routed name first,
|
|
508
|
+
* echoed on the frame, then the first arm for a headless caller, then a prompt.
|
|
509
|
+
*/
|
|
510
|
+
async function routeArm(
|
|
511
|
+
names: readonly string[],
|
|
512
|
+
env: NodeJS.ProcessEnv,
|
|
513
|
+
): Promise<string> {
|
|
514
|
+
const echo = (name: string): string => {
|
|
515
|
+
const { GREY, NC, WHITE } = palette(process.stderr)
|
|
516
|
+
process.stderr.write(
|
|
517
|
+
`${GREY}│${NC}\n${GREY}◇${NC} ${ROUTE_PROMPT} ${WHITE}${name}${NC}\n`,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
return name
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const routed = env.SANDBOX_SCENARIO ?? ''
|
|
524
|
+
if (routed !== '') return echo(routed)
|
|
525
|
+
|
|
526
|
+
// Read off the scenario's environment rather than this process's, as
|
|
527
|
+
// `is_non_interactive` read the shell it ran in.
|
|
528
|
+
const isHeadless =
|
|
529
|
+
env.CANON_NON_INTERACTIVE === '1' ||
|
|
530
|
+
// canon-keep-retired
|
|
531
|
+
env.AITK_NON_INTERACTIVE === '1'
|
|
532
|
+
if (isHeadless) return echo(names[0] ?? DEFAULT_ARM_NAME)
|
|
533
|
+
|
|
534
|
+
if (!process.stdin.isTTY) {
|
|
535
|
+
logError(
|
|
536
|
+
`${ROUTE_PROMPT} requires a TTY. Pass an argument or set CANON_NON_INTERACTIVE=1.`,
|
|
537
|
+
)
|
|
538
|
+
throw new ScenarioStop(1, 'exited')
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return select({
|
|
542
|
+
message: ROUTE_PROMPT,
|
|
543
|
+
options: names.map((name) => ({ value: name, label: name })),
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export interface StageOutcome {
|
|
548
|
+
readonly status: number
|
|
549
|
+
readonly ending: 'returned' | 'exited' | 'replaced'
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Routes and runs one arm in process. Any throw ends the arm the way a failing
|
|
554
|
+
* command ended `stage_setup` under `set -e`, so provisioning stops before the
|
|
555
|
+
* baseline refs are written.
|
|
556
|
+
*/
|
|
557
|
+
export async function stageScenarioInProcess(
|
|
558
|
+
definition: ScenarioDefinition,
|
|
559
|
+
options: ContextOptions,
|
|
560
|
+
): Promise<StageOutcome> {
|
|
561
|
+
try {
|
|
562
|
+
const names = armNames(definition)
|
|
563
|
+
const name =
|
|
564
|
+
names.length === 0 ? DEFAULT_ARM_NAME : await routeArm(names, options.env)
|
|
565
|
+
const setup = Object.hasOwn(definition.arms, name)
|
|
566
|
+
? definition.arms[name]
|
|
567
|
+
: undefined
|
|
568
|
+
if (setup === undefined) {
|
|
569
|
+
logError(`Unknown scenario: ${name}`)
|
|
570
|
+
return { status: 1, ending: 'exited' }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
await setup(
|
|
574
|
+
createStageContext({
|
|
575
|
+
...options,
|
|
576
|
+
arm: names.length === 0 ? undefined : name,
|
|
577
|
+
}),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
return { status: 0, ending: 'returned' }
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (error instanceof ScenarioStop)
|
|
583
|
+
return { status: error.status, ending: error.ending }
|
|
584
|
+
logError(error instanceof Error ? error.message : String(error))
|
|
585
|
+
|
|
586
|
+
return { status: 1, ending: 'exited' }
|
|
587
|
+
}
|
|
588
|
+
}
|
package/src/sessions/live.ts
CHANGED
|
@@ -61,9 +61,12 @@ export const SYSTEM_PROBES: LivenessProbes = { procStartOf, responds }
|
|
|
61
61
|
* whose pid was reused and the record is discarded. Where it does not, the
|
|
62
62
|
* probe alone stands, and every surviving row is marked so a caller never reads
|
|
63
63
|
* a recycled pid as a confirmed identity.
|
|
64
|
+
*
|
|
65
|
+
* It reads two fields and asks for no more, so a record that is not a session,
|
|
66
|
+
* such as the gate's machine lock, carries the same proof.
|
|
64
67
|
*/
|
|
65
68
|
export function liveness(
|
|
66
|
-
record: SessionRecord,
|
|
69
|
+
record: Pick<SessionRecord, 'pid' | 'procStart'>,
|
|
67
70
|
probes: LivenessProbes = SYSTEM_PROBES,
|
|
68
71
|
): Liveness {
|
|
69
72
|
const started = record.procStart
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { basename, dirname, isAbsolute, join, relative } from 'node:path'
|
|
12
12
|
import { $ } from 'bun'
|
|
13
13
|
import { gitEnv } from '@/git/env'
|
|
14
|
-
import { recordDir, recordRoot, SCRATCH } from '@/record
|
|
14
|
+
import { recordDir, recordRoot, SCRATCH } from '@/roots/record'
|
|
15
15
|
import { remoteIdentity } from '@/records/backup'
|
|
16
16
|
import {
|
|
17
17
|
encodeProjectPath,
|
package/src/slides/decks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, relative, resolve } from 'node:path'
|
|
3
|
-
import { creationRel, recordDir } from '@/record
|
|
3
|
+
import { creationRel, recordDir } from '@/roots/record'
|
|
4
4
|
import { readDeck } from '@/slides/convert/deck'
|
|
5
5
|
|
|
6
6
|
/**
|
package/src/standards/read.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
3
|
import { stripFrontmatter } from '@/markdown/frontmatter'
|
|
4
|
-
import { PROJECT_ROOT } from '@/project
|
|
4
|
+
import { PROJECT_ROOT } from '@/roots/project'
|
|
5
5
|
|
|
6
6
|
export const INDEX_FILE = 'index.md'
|
|
7
7
|
|
package/src/sync/engine.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { rm } from 'node:fs/promises'
|
|
3
3
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
4
|
import { copyPreservingMode } from '@/sync/copy'
|
|
5
|
-
import { checkoutMismatchWarning } from '@/project
|
|
5
|
+
import { checkoutMismatchWarning } from '@/roots/project'
|
|
6
6
|
import { findInstalledOrigin, readHistoryIndex } from '@/sync/history'
|
|
7
7
|
import {
|
|
8
8
|
type DomainHashes,
|
package/src/sync/layout.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative } from 'node:path'
|
|
3
3
|
import { SUBDIRS } from '@/claude/seeds'
|
|
4
|
-
import { creationRel, isRecordEntry } from '@/record
|
|
5
|
-
import { SURFACE_ENTRIES, spell, surfaceDir } from '@/surface
|
|
4
|
+
import { creationRel, isRecordEntry } from '@/roots/record'
|
|
5
|
+
import { SURFACE_ENTRIES, spell, surfaceDir } from '@/roots/surface'
|
|
6
6
|
import type { StampDomain } from '@/sync/stamp'
|
|
7
7
|
|
|
8
8
|
const CLAUDE_DIR = '.claude'
|
package/src/sync/stamp.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { dirname, join, sep } from 'node:path'
|
|
5
5
|
import { execa } from 'execa'
|
|
6
|
-
import { resolveExisting } from '@/legacy
|
|
6
|
+
import { resolveExisting } from '@/roots/legacy'
|
|
7
7
|
import { recordTarget } from '@/targets/registry'
|
|
8
8
|
import { compareVersions, parseVersion } from '@/version/compare'
|
|
9
9
|
import { readInstalled } from '@/version/installed'
|
package/src/targets/validate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { statSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
|
-
import { checkoutMismatchWarning } from '@/project
|
|
3
|
+
import { checkoutMismatchWarning } from '@/roots/project'
|
|
4
4
|
import { logError, logWarn, outro } from '@/ui'
|
|
5
5
|
|
|
6
6
|
export function isDirectory(path: string): boolean {
|
package/src/tasks/answers.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
4
|
import { isUnder } from '@/tasks/paths'
|
|
5
|
-
import { recordDir, recordDirs } from '@/record
|
|
5
|
+
import { recordDir, recordDirs } from '@/roots/record'
|
|
6
6
|
import {
|
|
7
7
|
hasStagedBatches,
|
|
8
8
|
normalizeOperatorCall,
|
package/src/tasks/archive.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
4
|
import { regenOne } from '@/indexes/regen'
|
|
5
5
|
import { isUnder } from '@/tasks/paths'
|
|
6
|
-
import { recordDir, recordDirs } from '@/record
|
|
6
|
+
import { recordDir, recordDirs } from '@/roots/record'
|
|
7
7
|
|
|
8
8
|
const TASKS = 'tasks'
|
|
9
9
|
const PLANS = 'plans'
|