@erclx/canon 5.14.0 → 5.14.1
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/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/close-out.md +1 -1
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
- package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
- package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
- package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
- package/docs/agents/pr-reads.md +1 -1
- package/docs/agents/sandbox.md +3 -3
- package/docs/agents/state-scoped-risk.md +2 -2
- package/package.json +4 -5
- package/scripts/core/check-color-source.sh +11 -1
- package/scripts/core/install-check.sh +13 -4
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-fixtures.sh +2 -2
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/manage-sandbox.sh +2 -2
- package/src/commands/sandbox.ts +2 -2
- package/src/context/citations.ts +1 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +1 -1
- package/src/sandbox/expect.ts +0 -1
- package/src/shipped/references.ts +8 -10
- package/src/targets/pulls.ts +2 -2
- package/tsconfig.json +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
- package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Reports worker and pull request transitions as they happen. Reads only.
|
|
2
|
+
//
|
|
3
|
+
// The poll answers "what moved since the last run" on a schedule the session
|
|
4
|
+
// holds. This answers "what just changed" on a loop of its own, and it exists
|
|
5
|
+
// because the two readings a dispatch needs are not one reading: a worker that
|
|
6
|
+
// finishes goes idle and a worker that crashes vanishes, so a watch matching
|
|
7
|
+
// only the pull request list stays silent through the second. A third case is
|
|
8
|
+
// neither: a worker that stops on a question or a prompt neither finishes nor
|
|
9
|
+
// crashes, and sits reading "waiting" until something reports it, which is the
|
|
10
|
+
// WORKER-STOPPED line below, or WORKER-UNMEASURABLE for a "waiting" row too old
|
|
11
|
+
// to carry a stamp this reads.
|
|
12
|
+
//
|
|
13
|
+
// Run as `bun watch.ts`. A failed read never ends the loop, since it runs for
|
|
14
|
+
// hours and one transient `gh` failure aborting it is a silent stop. Every read
|
|
15
|
+
// states its own failure instead.
|
|
16
|
+
import { baseBranch, baseRef, gitPath, mainRoot, run } from './repo'
|
|
17
|
+
|
|
18
|
+
// Seconds between passes. A pass costs two cheap reads and the transitions it
|
|
19
|
+
// watches take minutes, so the number trades staleness against little. Raise it
|
|
20
|
+
// in a project whose workers run long.
|
|
21
|
+
const INTERVAL_S = 60
|
|
22
|
+
|
|
23
|
+
// Seconds a worker can sit in "waiting" before this reports it as stopped
|
|
24
|
+
// rather than folding it into the ordinary status-change lines. "busy" and
|
|
25
|
+
// "idle" resolve on their own; "waiting" does not, so a dwell that keeps
|
|
26
|
+
// growing there is a session blocked on something outside itself. The number
|
|
27
|
+
// sits well above INTERVAL_S, so a handful of passes confirm the row before it
|
|
28
|
+
// reports, and well inside the ten-to-thirty-minute span a dispatched build
|
|
29
|
+
// ordinarily runs. A permission prompt clears the moment a person approves it
|
|
30
|
+
// and a question can sit legitimately, and one number serves both.
|
|
31
|
+
export const STALL_THRESHOLD_S = 300
|
|
32
|
+
|
|
33
|
+
export type Read<T> = { isOk: false } | { isOk: true; value: T }
|
|
34
|
+
|
|
35
|
+
export interface Worker {
|
|
36
|
+
branch: string
|
|
37
|
+
dwellMs: number
|
|
38
|
+
name: string
|
|
39
|
+
status: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WatchState {
|
|
43
|
+
/** Names currently reported as stalled, so a stall prints once. */
|
|
44
|
+
stalled: ReadonlySet<string>
|
|
45
|
+
/** Null until a read succeeds, so each source keeps its own baseline flag. */
|
|
46
|
+
pulls: null | readonly string[]
|
|
47
|
+
workers: null | readonly Worker[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const initialState = (): WatchState => ({
|
|
51
|
+
pulls: null,
|
|
52
|
+
stalled: new Set(),
|
|
53
|
+
workers: null,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// `sort` orders by byte, so a title with a non-ASCII first character prints in
|
|
57
|
+
// the order it always did rather than in UTF-16 code unit order.
|
|
58
|
+
const byBytes = (a: string, b: string): number =>
|
|
59
|
+
Buffer.compare(Buffer.from(a), Buffer.from(b))
|
|
60
|
+
|
|
61
|
+
const rowOf = (worker: Worker): string =>
|
|
62
|
+
[worker.name, worker.branch, worker.status, String(worker.dwellMs)].join('\t')
|
|
63
|
+
|
|
64
|
+
// Identity leaves the dwell out. It grows every second, so keying on it
|
|
65
|
+
// reported every worker as new on every pass.
|
|
66
|
+
const identityOf = (worker: Worker): string =>
|
|
67
|
+
JSON.stringify([worker.name, worker.branch, worker.status])
|
|
68
|
+
|
|
69
|
+
const unseen = <T>(
|
|
70
|
+
current: readonly T[],
|
|
71
|
+
previous: readonly T[],
|
|
72
|
+
key: (item: T) => string,
|
|
73
|
+
): T[] => {
|
|
74
|
+
const known = new Set(previous.map(key))
|
|
75
|
+
|
|
76
|
+
return current.filter((item) => !known.has(key(item)))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const stallLines = (
|
|
80
|
+
workers: readonly Worker[],
|
|
81
|
+
previous: ReadonlySet<string>,
|
|
82
|
+
thresholdS: number,
|
|
83
|
+
): { lines: string[]; stalled: Set<string> } => {
|
|
84
|
+
const stalled = new Set(previous)
|
|
85
|
+
const lines: string[] = []
|
|
86
|
+
|
|
87
|
+
for (const worker of workers) {
|
|
88
|
+
if (worker.name === '') continue
|
|
89
|
+
|
|
90
|
+
if (worker.status !== 'waiting') {
|
|
91
|
+
stalled.delete(worker.name)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (worker.dwellMs === -1) {
|
|
96
|
+
if (!stalled.has(worker.name)) {
|
|
97
|
+
lines.push(`WORKER-UNMEASURABLE ${worker.name} ${worker.branch}`)
|
|
98
|
+
stalled.add(worker.name)
|
|
99
|
+
}
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dwellS = Math.trunc(worker.dwellMs / 1000)
|
|
104
|
+
if (dwellS < thresholdS) {
|
|
105
|
+
stalled.delete(worker.name)
|
|
106
|
+
} else if (!stalled.has(worker.name)) {
|
|
107
|
+
lines.push(`WORKER-STOPPED ${worker.name} ${worker.branch} ${dwellS}s`)
|
|
108
|
+
stalled.add(worker.name)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { lines, stalled }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One pass over two reads. A failed read is reported and the previous baseline
|
|
117
|
+
* kept, since reading an empty result as the current state would report every
|
|
118
|
+
* worker gone and every pull request new on the pass after.
|
|
119
|
+
*/
|
|
120
|
+
export const pass = (
|
|
121
|
+
state: WatchState,
|
|
122
|
+
pulls: Read<string[]>,
|
|
123
|
+
workers: Read<Worker[]>,
|
|
124
|
+
thresholdS = STALL_THRESHOLD_S,
|
|
125
|
+
): { lines: string[]; state: WatchState } => {
|
|
126
|
+
const lines: string[] = []
|
|
127
|
+
|
|
128
|
+
if (!pulls.isOk) {
|
|
129
|
+
lines.push(
|
|
130
|
+
'watch: the open pull request list failed to load, so none is classified this pass',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
if (!workers.isOk) {
|
|
134
|
+
lines.push(
|
|
135
|
+
'watch: the session roster failed to load, so no worker is classified this pass',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (pulls.isOk && state.pulls !== null) {
|
|
140
|
+
lines.push(
|
|
141
|
+
...unseen(pulls.value, state.pulls, (line) => line).sort(byBytes),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let stalled = state.stalled
|
|
146
|
+
if (workers.isOk) {
|
|
147
|
+
const sorted = [...workers.value].sort((a, b) =>
|
|
148
|
+
byBytes(rowOf(a), rowOf(b)),
|
|
149
|
+
)
|
|
150
|
+
if (state.workers !== null) {
|
|
151
|
+
const changed = unseen(sorted, state.workers, identityOf)
|
|
152
|
+
lines.push(...changed.map((worker) => `WORKER ${rowOf(worker)}`))
|
|
153
|
+
|
|
154
|
+
const names = new Set(sorted.map((worker) => worker.name))
|
|
155
|
+
const gone = [...new Set(state.workers.map((worker) => worker.name))]
|
|
156
|
+
.filter((name) => !names.has(name))
|
|
157
|
+
.sort(byBytes)
|
|
158
|
+
lines.push(...gone.map((name) => `WORKER-GONE ${name}`))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const stalls = stallLines(sorted, stalled, thresholdS)
|
|
162
|
+
lines.push(...stalls.lines)
|
|
163
|
+
stalled = stalls.stalled
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
lines,
|
|
168
|
+
state: {
|
|
169
|
+
pulls: pulls.isOk ? pulls.value : state.pulls,
|
|
170
|
+
stalled,
|
|
171
|
+
workers: workers.isOk ? workers.value : state.workers,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const readPulls = (): Read<string[]> => {
|
|
177
|
+
const listed = run('gh', [
|
|
178
|
+
'pr',
|
|
179
|
+
'list',
|
|
180
|
+
'--state',
|
|
181
|
+
'open',
|
|
182
|
+
'--json',
|
|
183
|
+
'number,headRefName,title',
|
|
184
|
+
'--jq',
|
|
185
|
+
'.[] | "PR-OPEN #\\(.number) \\(.headRefName) \\(.title)"',
|
|
186
|
+
])
|
|
187
|
+
if (!listed.isOk) return { isOk: false }
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
isOk: true,
|
|
191
|
+
value: listed.stdout.split('\n').filter((l) => l !== ''),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const asText = (value: unknown, fallback: string): string =>
|
|
196
|
+
typeof value === 'string' ? value : fallback
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Every session in this repository holding a branch other than the base one is
|
|
200
|
+
* a worker, whoever launched it. A match on the `orchestrator-` name prefix
|
|
201
|
+
* reads a dispatched worker and misses every hand-launched one. The name stays whole, since one
|
|
202
|
+
* this client writes carries spaces.
|
|
203
|
+
*/
|
|
204
|
+
const readWorkers = (repository: string, base: string): Read<Worker[]> => {
|
|
205
|
+
const listed = run('canon', ['sessions', 'list', '--json'])
|
|
206
|
+
if (!listed.isOk) return { isOk: false }
|
|
207
|
+
|
|
208
|
+
const last = listed.stdout.trimEnd().split('\n').at(-1) ?? ''
|
|
209
|
+
let parsed: unknown
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(last)
|
|
212
|
+
} catch {
|
|
213
|
+
return { isOk: false }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const sessions =
|
|
217
|
+
typeof parsed === 'object' &&
|
|
218
|
+
parsed !== null &&
|
|
219
|
+
Array.isArray((parsed as { sessions?: unknown }).sessions)
|
|
220
|
+
? (parsed as { sessions: unknown[] }).sessions
|
|
221
|
+
: []
|
|
222
|
+
|
|
223
|
+
const workers: Worker[] = []
|
|
224
|
+
for (const session of sessions) {
|
|
225
|
+
if (typeof session !== 'object' || session === null) continue
|
|
226
|
+
const row = session as Record<string, unknown>
|
|
227
|
+
if (row.repository !== repository) continue
|
|
228
|
+
if (
|
|
229
|
+
row.branch === null ||
|
|
230
|
+
row.branch === undefined ||
|
|
231
|
+
row.branch === base
|
|
232
|
+
) {
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
workers.push({
|
|
237
|
+
branch: asText(row.branch, ''),
|
|
238
|
+
dwellMs: typeof row.statusDwellMs === 'number' ? row.statusDwellMs : -1,
|
|
239
|
+
name: asText(row.name, ''),
|
|
240
|
+
status: asText(row.status, ''),
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { isOk: true, value: workers }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const main = async (): Promise<number> => {
|
|
248
|
+
const root = mainRoot()
|
|
249
|
+
if (root === null) {
|
|
250
|
+
console.error('watch: not a git repository, so nothing is watched')
|
|
251
|
+
|
|
252
|
+
return 1
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const repository = gitPath(root)
|
|
256
|
+
const base = baseBranch(baseRef())
|
|
257
|
+
let state = initialState()
|
|
258
|
+
|
|
259
|
+
for (;;) {
|
|
260
|
+
const result = pass(state, readPulls(), readWorkers(repository, base))
|
|
261
|
+
for (const line of result.lines) console.log(line)
|
|
262
|
+
state = result.state
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, INTERVAL_S * 1000))
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (import.meta.main) process.exitCode = await main()
|
package/docs/agents/pr-reads.md
CHANGED
|
@@ -52,7 +52,7 @@ Both values are taken at the top of the pass rather than at the post, so the com
|
|
|
52
52
|
|
|
53
53
|
The last non-empty line is where it has to sit, and the reader looks nowhere else. Searching a body for the last match instead reads a marker the body was displaying rather than claiming, since a fenced block showing the format on its own line trims to exactly the pattern, and a pass carrying no marker of its own would then hand the next reader a commit taken from an illustration. That is the same defect through a second door, so position rather than shape is what separates a claim from a quotation.
|
|
54
54
|
|
|
55
|
-
`canon pr review-state` is the only parser of that format. Two parsers was the alternative, one in the shipped skill body and one in the orchestrator poll's
|
|
55
|
+
`canon pr review-state` is the only parser of that format. Two parsers was the alternative, one in the shipped skill body and one in the orchestrator poll's own filters, and those two ship on different cadences, so a format change to either would leave the other reading a reviewed commit as unreviewed.
|
|
56
56
|
|
|
57
57
|
`source` on the record says which reading answered, and a caller checks it before trusting the rest:
|
|
58
58
|
|
package/docs/agents/sandbox.md
CHANGED
|
@@ -9,7 +9,7 @@ description: Scenario routing, the expectation scoring surface, and the coverage
|
|
|
9
9
|
|
|
10
10
|
## Scenarios
|
|
11
11
|
|
|
12
|
-
Scenarios live under `
|
|
12
|
+
Scenarios live under `sandbox/`, one folder per category. `sandbox/fixtures/` is the exception, holding file content that scenarios stage rather than scenarios of its own, so both pickers filter it out. That tree sits outside `files` in `package.json`, so an installed `canon` carries the command, reports it as toolkit-only on one line, and exits 1 rather than failing on the missing directory. Route non-interactively with `SANDBOX_SCENARIO`:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
SANDBOX_SCENARIO=sync canon sandbox infra:tooling
|
|
@@ -67,9 +67,9 @@ The record carries every scenario with the arms that declare, plus `totalScenari
|
|
|
67
67
|
|
|
68
68
|
A skill pairs to a scenario by filename, `<category>-<command>` first and bare `<command>` second, so `claude/target-setup.sh` reaches the `target-setup` skill. `should-be-asserted` is the default rather than a queue to drain, and which of those skills earns an arm is a project decision the census does not make.
|
|
69
69
|
|
|
70
|
-
`exempt` means no arm should be written and holds only with a reason, declared in `
|
|
70
|
+
`exempt` means no arm should be written and holds only with a reason, declared in `sandbox/exempt.toml` and limited to a harness limit the checker cannot reach past or a skill that writes no artifact. An armed arm outranks an exemption. An exemption naming no shipped skill, or naming one an arm now asserts, exits 1 without `--strict`. Each armed arm reports as `<category>:<command>/<arm>`, so two same-named arms under different scenarios stay distinct.
|
|
71
71
|
|
|
72
|
-
`
|
|
72
|
+
`sandbox/run.sh` calls this after a headless run and merges the verdict into the envelope it prints. It also writes that merged record to `.canon/tmp/runs/sandbox/<target>-<arm>-<timestamp>.json` with a `writes` array appended, and logs the path on stderr. Both fields are what a later re-score needs, since `--envelope` and `--writes` read files the run deletes on exit.
|
|
73
73
|
|
|
74
74
|
Two more fields ride alongside the verdict rather than inside it. `escapes` lists what the run wrote under a watched toolkit root, which the verdict cannot assert over because those files sit outside the sandbox tree. `sessions` reports the nested-dispatch bound, carrying `watched` for whether the client's session registry was there to read, `new` for the records that appeared while the run was in flight, `concurrent` for the records present both before and after, and `reap` for what the run found in the session's process group afterwards. Neither field fails a run on its own.
|
|
75
75
|
|
|
@@ -22,7 +22,7 @@ canon secrets scan --json
|
|
|
22
22
|
canon secrets scan ../my-app
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
The corpus is the package's own `files` field rather than a list the check keeps. That field is the single statement of which trees leave this repository, so a second list beside it would answer the same question and drift. It also carries the negations the publish already makes, which is what puts the
|
|
25
|
+
The corpus is the package's own `files` field rather than a list the check keeps. That field is the single statement of which trees leave this repository, so a second list beside it would answer the same question and drift. It also carries the negations the publish already makes, which is what puts the eval tree and every test file out of scope by the rule that keeps them out of the tarball rather than by an exclusion this check invented. The sandbox tree sits under no `files` entry at all, so the same field leaves it out without a negation.
|
|
26
26
|
|
|
27
27
|
The plugin reaches a target by a different route, loading live from `claude/` rather than from a tarball. That folder is a `files` entry too, and its `standards` symlink resolves into a tree the field lists in its own right, so both routes land inside the same corpus.
|
|
28
28
|
|
|
@@ -30,7 +30,7 @@ Three root files are read whether or not the field names them, being `package.js
|
|
|
30
30
|
|
|
31
31
|
### What the corpus leaves out
|
|
32
32
|
|
|
33
|
-
The corpus answers what the package publishes, which is narrower than what the repository holds. Measured against this repository on 2026-08-21, the scan read 544 files and left 593 of the 1139 git lists unread. Those include everything under `.claude/`, `wiki/`, and `internal/`, the workflow definitions under `.github/`, and the trees the publish negations remove, being `scripts/
|
|
33
|
+
The corpus answers what the package publishes, which is narrower than what the repository holds. Measured against this repository on 2026-08-21, the scan read 544 files and left 593 of the 1139 git lists unread. Those include everything under `.claude/`, `wiki/`, and `internal/`, the workflow definitions under `.github/`, and the trees the publish negations remove, being `scripts/eval/` and every test file. The scenario tree was a third negation at that reading and now sits at `sandbox/` under no `files` entry, so it stays unread. `src/capture/` was a fourth negation when that reading was taken and is not one now. The folder holds 5 tracked files, 2 of them tests the publish still excludes, so the corpus is 3 wider than the numbers above describe and the unread count is 3 smaller.
|
|
34
34
|
|
|
35
35
|
A public repository makes that gap readable by anyone, so a clean run means no credential in the published tree rather than none in the repository. The run states the number on every pass, including a clean one, so the bound travels with the verdict. Widening the corpus to every tracked file is a separate decision, since the row this implements puts the shipped tree first on the toolkit's rule that content leaving the repository gates harder than content that stays.
|
|
36
36
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.14.
|
|
4
|
+
"version": "5.14.1",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
"docs",
|
|
18
18
|
"tsconfig.json",
|
|
19
19
|
"assets/brand/mark.svg",
|
|
20
|
-
"!scripts/sandbox",
|
|
21
20
|
"!scripts/eval",
|
|
22
21
|
"!**/*.test.ts",
|
|
23
22
|
"!**/*.test.tsx"
|
|
@@ -27,9 +26,9 @@
|
|
|
27
26
|
},
|
|
28
27
|
"scripts": {
|
|
29
28
|
"check:spell": "cspell '**' '.*/**' '.*' --no-progress --color --show-context",
|
|
30
|
-
"format": "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --write --indent 2 scripts/ tooling/ claude/",
|
|
31
|
-
"check:format": "prettier --check --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --diff --indent 2 scripts/ tooling/ claude/",
|
|
32
|
-
"check:shell": "find scripts tooling claude .claude/hooks -name '*.sh' -exec shellcheck --severity=warning {} + && find .husky -maxdepth 1 -type f -exec shellcheck --severity=warning --shell=sh {} +",
|
|
29
|
+
"format": "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --write --indent 2 scripts/ sandbox/ tooling/ claude/",
|
|
30
|
+
"check:format": "prettier --check --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --diff --indent 2 scripts/ sandbox/ tooling/ claude/",
|
|
31
|
+
"check:shell": "find scripts sandbox tooling claude .claude/hooks -name '*.sh' -exec shellcheck --severity=warning {} + && find .husky -maxdepth 1 -type f -exec shellcheck --severity=warning --shell=sh {} +",
|
|
33
32
|
"check:types": "tsc --noEmit",
|
|
34
33
|
"check:install": "./scripts/core/install-check.sh",
|
|
35
34
|
"test": "bun --bun vitest run",
|
|
@@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
6
6
|
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
7
7
|
|
|
8
8
|
SCRIPTS_ROOT="$PROJECT_ROOT/scripts"
|
|
9
|
+
SANDBOX_ROOT="$PROJECT_ROOT/sandbox"
|
|
9
10
|
PALETTE_SOURCE="$SCRIPTS_ROOT/lib/ui.sh"
|
|
10
11
|
|
|
11
12
|
# `find` writes to stderr and returns non-zero for a missing root, inside a
|
|
@@ -16,6 +17,15 @@ if [ ! -f "$PALETTE_SOURCE" ]; then
|
|
|
16
17
|
exit 1
|
|
17
18
|
fi
|
|
18
19
|
|
|
20
|
+
# The same silence follows a root that moved, so a walked root that is gone
|
|
21
|
+
# fails here rather than shrinking the corpus with no message.
|
|
22
|
+
for root in "$SCRIPTS_ROOT" "$SANDBOX_ROOT"; do
|
|
23
|
+
if [ ! -d "$root" ]; then
|
|
24
|
+
echo "No shell root at ${root#"$PROJECT_ROOT/"}, color sourcing unverifiable." >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
done
|
|
28
|
+
|
|
19
29
|
# Any SGR sequence in either spelling. The cursor and erase sequences an
|
|
20
30
|
# interactive prompt writes end in a letter instead, and they run only where a
|
|
21
31
|
# terminal already exists, so they are not what this measures.
|
|
@@ -31,7 +41,7 @@ while IFS= read -r file; do
|
|
|
31
41
|
if grep -qE "$SGR" "$file"; then
|
|
32
42
|
offenders="$offenders ${file#"$PROJECT_ROOT/"}"$'\n'
|
|
33
43
|
fi
|
|
34
|
-
done < <(find "$SCRIPTS_ROOT" -type f -name '*.sh')
|
|
44
|
+
done < <(find "$SCRIPTS_ROOT" "$SANDBOX_ROOT" -type f -name '*.sh')
|
|
35
45
|
|
|
36
46
|
if [ -n "$offenders" ]; then
|
|
37
47
|
echo "A color escape is defined outside the shared library:" >&2
|
|
@@ -22,7 +22,7 @@ Usage: scripts/core/install-check.sh [--keep]
|
|
|
22
22
|
|
|
23
23
|
Verifies the published install path end to end:
|
|
24
24
|
1. Packs this repo into a tarball at .canon/tmp/install-check/pack
|
|
25
|
-
2. Extracts it and asserts
|
|
25
|
+
2. Extracts it and asserts sandbox is absent from the tree
|
|
26
26
|
3. Runs bun install --production in the extracted tree
|
|
27
27
|
4. Runs the CLI with --help from the extracted tree to confirm it resolves
|
|
28
28
|
5. Scaffolds a fresh project in .canon/tmp/install-check/target
|
|
@@ -51,6 +51,15 @@ mkdir -p "$PACK_DIR" "$EXTRACT_DIR"
|
|
|
51
51
|
|
|
52
52
|
open_timeline "Install verification"
|
|
53
53
|
|
|
54
|
+
# An absence check over a path the source tree no longer holds passes on
|
|
55
|
+
# nothing, so the excluded tree has to exist here before its absence below can
|
|
56
|
+
# mean the publish left it out.
|
|
57
|
+
log_step "Assert excluded path exists in source"
|
|
58
|
+
if [ ! -f "$PROJECT_ROOT/sandbox/run.sh" ]; then
|
|
59
|
+
log_error "sandbox/run.sh is missing from the source tree, so the absence check below would pass on nothing"
|
|
60
|
+
fi
|
|
61
|
+
log_info "Present: sandbox/run.sh"
|
|
62
|
+
|
|
54
63
|
log_step "Pack"
|
|
55
64
|
TARBALL_PATH="$(cd "$PROJECT_ROOT" && bun pm pack --quiet --ignore-scripts --destination "$PACK_DIR" | tail -n 1)"
|
|
56
65
|
log_info "Packed to $TARBALL_PATH"
|
|
@@ -60,10 +69,10 @@ tar -xzf "$TARBALL_PATH" -C "$EXTRACT_DIR" --strip-components=1
|
|
|
60
69
|
log_info "Extracted to $EXTRACT_DIR"
|
|
61
70
|
|
|
62
71
|
log_step "Assert excluded path is absent"
|
|
63
|
-
if [ -e "$EXTRACT_DIR/
|
|
64
|
-
log_error "
|
|
72
|
+
if [ -e "$EXTRACT_DIR/sandbox" ]; then
|
|
73
|
+
log_error "sandbox shipped in the tarball, and the files field in package.json leaves it out"
|
|
65
74
|
fi
|
|
66
|
-
log_info "Absent:
|
|
75
|
+
log_info "Absent: sandbox"
|
|
67
76
|
|
|
68
77
|
log_step "Install dependencies (production)"
|
|
69
78
|
(cd "$EXTRACT_DIR" && bun install --production --ignore-scripts --silent 2>&1 | pipe_output) || log_error "bun install --production failed"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
# them, so the escape watch reported clean throughout and was right to. The run
|
|
10
10
|
# spent real cost until a person found it, and `SIGTERM` alone did not end it.
|
|
11
11
|
#
|
|
12
|
-
# Three mechanisms sit here rather than in `
|
|
12
|
+
# Three mechanisms sit here rather than in `sandbox/run.sh`, which is the
|
|
13
13
|
# only caller. Each is reachable from a test that never launches a session:
|
|
14
14
|
# `src/sandbox-dispatch.test.ts` drives the shim against a stub binary and the
|
|
15
15
|
# reap against a `sleep`, where proving either through `run.sh` would mean
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
FIXTURE_SUFFIX=".fixture"
|
|
7
7
|
|
|
8
8
|
# The tree mirrors the scenario path, so `category` and `scenario` together match
|
|
9
|
-
# `
|
|
9
|
+
# `sandbox/<category>/<scenario>.sh`. Both segments are load-bearing:
|
|
10
10
|
# four scenario basenames repeat across categories (`claude`, `docs`, `review`,
|
|
11
11
|
# `sync`), and `docs` is also a category of its own.
|
|
12
12
|
fixture_stage_dir() {
|
|
@@ -14,7 +14,7 @@ fixture_stage_dir() {
|
|
|
14
14
|
local scenario="$2"
|
|
15
15
|
local arm="$3"
|
|
16
16
|
local stage="$4"
|
|
17
|
-
echo "$PROJECT_ROOT/
|
|
17
|
+
echo "$PROJECT_ROOT/sandbox/fixtures/$category/$scenario/$arm/$stage"
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
list_fixture_files() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
|
|
3
|
-
# The sandbox tree lives outside the toolkit worktree. `
|
|
3
|
+
# The sandbox tree lives outside the toolkit worktree. `sandbox/run.sh`
|
|
4
4
|
# sets cwd to it for `claude -p`, and every `CLAUDE.md` between that cwd and the
|
|
5
5
|
# filesystem root loads into the session. Under the repository the toolkit's own
|
|
6
6
|
# instructions join that chain beside the seeded copy the scenario installed,
|
|
@@ -40,7 +40,7 @@ show_help() {
|
|
|
40
40
|
exit 0
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
ANCHOR_FIXTURE_DIR="$PROJECT_ROOT/
|
|
43
|
+
ANCHOR_FIXTURE_DIR="$PROJECT_ROOT/sandbox/fixtures/anchor/create"
|
|
44
44
|
|
|
45
45
|
# Where an anchor scenario's tree comes from and where its remote points are two
|
|
46
46
|
# concerns, and only the remote needs a network. Taking the tree from a fixture
|
|
@@ -438,7 +438,7 @@ main() {
|
|
|
438
438
|
fi
|
|
439
439
|
|
|
440
440
|
SANDBOX="$(resolve_sandbox_dir)"
|
|
441
|
-
SANDBOX_DIR="$PROJECT_ROOT/
|
|
441
|
+
SANDBOX_DIR="$PROJECT_ROOT/sandbox"
|
|
442
442
|
|
|
443
443
|
local unsafe
|
|
444
444
|
if ! unsafe="$(assert_sandbox_dir_safe "$SANDBOX" "$PROJECT_ROOT")"; then
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -34,13 +34,13 @@ import {
|
|
|
34
34
|
select,
|
|
35
35
|
} from '@/ui'
|
|
36
36
|
|
|
37
|
-
const SANDBOX_DIR = join(PROJECT_ROOT, '
|
|
37
|
+
const SANDBOX_DIR = join(PROJECT_ROOT, 'sandbox')
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Reports that the scenario tree does not ship, and answers whether it reported,
|
|
41
41
|
* so a caller that sees `true` returns without touching the tree.
|
|
42
42
|
*
|
|
43
|
-
* `
|
|
43
|
+
* `sandbox` sits outside the published package, so an installed
|
|
44
44
|
* `canon` resolves `SANDBOX_DIR` to a directory that is not there. Both entry
|
|
45
45
|
* points that walk the tree ask here rather than carrying a check each, because
|
|
46
46
|
* absence is a property of the install rather than of a verb, and a second copy
|
package/src/context/citations.ts
CHANGED
|
@@ -60,7 +60,7 @@ function ignoredCitations(line: string): IgnoredCitations | undefined {
|
|
|
60
60
|
* eval harness names paths in its target project. Neither is a reference into
|
|
61
61
|
* this repository, so an unresolved path there is correct rather than stale.
|
|
62
62
|
*/
|
|
63
|
-
const FIXTURE_TREES: readonly string[] = ['
|
|
63
|
+
const FIXTURE_TREES: readonly string[] = ['sandbox/', 'scripts/eval/']
|
|
64
64
|
|
|
65
65
|
const FIXTURE_SEGMENTS: readonly string[] = ['fixtures', '__fixtures__']
|
|
66
66
|
|
package/src/sandbox/census.ts
CHANGED
|
@@ -134,7 +134,7 @@ export function parseExemptions(source: string): Map<string, string> {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
function readExemptions(root: string): Map<string, string> {
|
|
137
|
-
const path = join(root, '
|
|
137
|
+
const path = join(root, 'sandbox', EXEMPT_FILE)
|
|
138
138
|
if (!existsSync(path)) return new Map()
|
|
139
139
|
|
|
140
140
|
return parseExemptions(readFileSync(path, 'utf8'))
|
package/src/sandbox/coverage.ts
CHANGED
|
@@ -88,7 +88,7 @@ function armsFor(
|
|
|
88
88
|
* a suite that proves something from one that runs.
|
|
89
89
|
*/
|
|
90
90
|
export function collectCoverage(root: string): CoverageReport {
|
|
91
|
-
const sandboxDir = join(root, '
|
|
91
|
+
const sandboxDir = join(root, 'sandbox')
|
|
92
92
|
const fixturesDir = join(sandboxDir, FIXTURES_DIR)
|
|
93
93
|
|
|
94
94
|
const scenarios = listScenarios(sandboxDir).map((scenario) => ({
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -36,13 +36,10 @@ export const SHIPPED_CORPORA = [
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Trees inside a corpus above that no target receives, matching the negations
|
|
39
|
-
* the `files` field already makes. A number under
|
|
40
|
-
*
|
|
39
|
+
* the `files` field already makes. A number under one names something its only
|
|
40
|
+
* reader can already resolve.
|
|
41
41
|
*/
|
|
42
|
-
export const SHIPPED_EXCLUSIONS = [
|
|
43
|
-
/^scripts\/sandbox\//,
|
|
44
|
-
/^scripts\/eval\//,
|
|
45
|
-
] as const
|
|
42
|
+
export const SHIPPED_EXCLUSIONS = [/^scripts\/eval\//] as const
|
|
46
43
|
|
|
47
44
|
/** Whether a repository-relative path sits in the gated corpus. */
|
|
48
45
|
export function isShippedCorpus(path: string): boolean {
|
|
@@ -97,10 +94,11 @@ const PULL_REQUEST = /(?<![0-9A-Za-z_])#([0-9]+)(?![0-9A-Za-z_])/g
|
|
|
97
94
|
* The third is the one a later author meets. Admitting an all-digit sha admits
|
|
98
95
|
* every run of seven or more decimal digits with it, so a date written without
|
|
99
96
|
* separators, a large count, or a float artifact reads as a commit reference.
|
|
100
|
-
* The corpus carries no instance and that is the
|
|
101
|
-
* twelve such runs
|
|
102
|
-
* `scripts
|
|
103
|
-
*
|
|
97
|
+
* The corpus carries no instance and that is the corpus bounds rather than luck:
|
|
98
|
+
* twelve such runs were counted when the scenario tree still sat under
|
|
99
|
+
* `scripts/`, eleven of them under that tree, `scripts/eval/`, or a test file,
|
|
100
|
+
* all now outside the corpus, and the twelfth is the genuine sha in
|
|
101
|
+
* `orchestrator-poll.md`. Write a seven-digit measurement into a
|
|
104
102
|
* shipped page and the push fails on it, which the marker answers and no
|
|
105
103
|
* narrowing can, since requiring a letter loses the all-digit sha above.
|
|
106
104
|
*/
|
package/src/targets/pulls.ts
CHANGED
|
@@ -8,7 +8,7 @@ const GH_TIMEOUT_MS = 30_000
|
|
|
8
8
|
* The two headings a review pass posts under.
|
|
9
9
|
*
|
|
10
10
|
* Owned by `review-pr`, which states the full set once, and pinned here
|
|
11
|
-
* the way `role-orchestrator/scripts/poll.
|
|
11
|
+
* the way `role-orchestrator/scripts/poll.ts` pins them. All three surfaces
|
|
12
12
|
* ship separately, so a heading added in that skill goes stale here with
|
|
13
13
|
* nothing comparing the copies.
|
|
14
14
|
*/
|
|
@@ -139,7 +139,7 @@ export function rollup(checks: readonly RawCheck[]): ChecksState | null {
|
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
141
|
* Reads the heading of the newest pass carrying one, matching on the first line
|
|
142
|
-
* alone the way `poll.
|
|
142
|
+
* alone the way `poll.ts` does.
|
|
143
143
|
*
|
|
144
144
|
* The reviews arrive oldest first, so the last match is the current state of
|
|
145
145
|
* the thread. A pass carrying neither heading is somebody reviewing by hand and
|
package/tsconfig.json
CHANGED