@erclx/aitk 3.46.0 → 3.47.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/claude-orchestrate/REQUIREMENT.md +3 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +18 -2
- package/claude/skills/claude-worker/SKILL.md +41 -4
- package/docs/agents/commands.md +56 -56
- package/docs/agents/index.md +2 -2
- package/docs/agents/sessions.md +27 -2
- package/docs/agents/superseded.md +35 -7
- package/package.json +1 -1
- package/src/commands/gov.ts +64 -9
- package/src/commands/sessions.ts +83 -6
- package/src/gov/superseded.ts +223 -15
- package/src/process/harness.ts +167 -0
- package/src/sessions/resolve.ts +103 -0
package/src/commands/sessions.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { resolve } from 'node:path'
|
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { checkClaim, type ClaimReport } from '@/sessions/claim'
|
|
4
4
|
import {
|
|
5
|
+
callerIdentity,
|
|
5
6
|
repositoryOf,
|
|
6
7
|
type ResolvedSession,
|
|
7
8
|
resolveSessions,
|
|
9
|
+
type SelfReport,
|
|
8
10
|
type SessionReport,
|
|
11
|
+
selfOf,
|
|
9
12
|
} from '@/sessions/resolve'
|
|
10
13
|
import {
|
|
11
14
|
intro,
|
|
@@ -21,6 +24,7 @@ interface ListCommandOptions {
|
|
|
21
24
|
readonly json?: boolean
|
|
22
25
|
readonly branch?: string
|
|
23
26
|
readonly repository?: string
|
|
27
|
+
readonly self?: boolean
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
const REASONS: Record<string, string> = {
|
|
@@ -52,6 +56,10 @@ export function register(program: Command): void {
|
|
|
52
56
|
'--repository <path>',
|
|
53
57
|
'Answer about this project rather than the working one',
|
|
54
58
|
)
|
|
59
|
+
.option(
|
|
60
|
+
'--self',
|
|
61
|
+
"Report the caller's own row, and refuse where the roster holds none",
|
|
62
|
+
)
|
|
55
63
|
.addHelpText(
|
|
56
64
|
'after',
|
|
57
65
|
[
|
|
@@ -93,6 +101,19 @@ export function register(program: Command): void {
|
|
|
93
101
|
'The match can return more than one session. Read the count rather than',
|
|
94
102
|
'the first row, since two sessions can hold one branch.',
|
|
95
103
|
'',
|
|
104
|
+
"--self narrows the report to the caller's own row, which is what a",
|
|
105
|
+
'dispatcher reads to learn the sessionId it carries into a launch. It',
|
|
106
|
+
'joins on CLAUDE_CODE_SESSION_ID first, falls back to CLAUDE_PID, and',
|
|
107
|
+
'falls back again to the pid the messaging socket path spells. It never',
|
|
108
|
+
'reads CLAUDE_CODE_HOST_SESSION_ID, which holds a value from another',
|
|
109
|
+
'namespace that matches no row.',
|
|
110
|
+
'',
|
|
111
|
+
'It refuses with reason "no-self-identity" when the environment states',
|
|
112
|
+
'none of the three, and "no-self-row" when it states one and no live',
|
|
113
|
+
'row carries it. The second is the ordinary answer for a session',
|
|
114
|
+
'driving from Remote Control, which is addressable on the message',
|
|
115
|
+
'channel and holds no local process record for the roster to report.',
|
|
116
|
+
'',
|
|
96
117
|
'Each session writes its own working directory beside its own name, so a',
|
|
97
118
|
'name from a session listing joins to a branch by an exact match rather',
|
|
98
119
|
'than by ordering the roster on start time.',
|
|
@@ -108,6 +129,7 @@ export function register(program: Command): void {
|
|
|
108
129
|
' aitk sessions list --json',
|
|
109
130
|
' aitk sessions list --branch feat/parser --json',
|
|
110
131
|
' aitk sessions list --branch chore/agents --repository ../caret --json',
|
|
132
|
+
' aitk sessions list --self --json',
|
|
111
133
|
'',
|
|
112
134
|
].join('\n'),
|
|
113
135
|
)
|
|
@@ -136,6 +158,34 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
136
158
|
return 1
|
|
137
159
|
}
|
|
138
160
|
|
|
161
|
+
// The roster read returns every row and marks none of them as the caller, so
|
|
162
|
+
// the join runs here, ahead of any scope. Resolving it after the branch
|
|
163
|
+
// filter would answer "no row" for a caller whose row was merely filtered
|
|
164
|
+
// out, which is a different failure wearing the same reason.
|
|
165
|
+
const own = opts.self ? selfOf(report.sessions, callerIdentity()) : null
|
|
166
|
+
|
|
167
|
+
if (own?.kind === 'unresolved') {
|
|
168
|
+
intro('aitk sessions list')
|
|
169
|
+
logStep('Refused')
|
|
170
|
+
logWarn(selfRefusal(own))
|
|
171
|
+
outro()
|
|
172
|
+
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
process.stdout.write(
|
|
175
|
+
`${JSON.stringify({
|
|
176
|
+
dir: report.dir,
|
|
177
|
+
reason:
|
|
178
|
+
own.reason === 'no-identity' ? 'no-self-identity' : 'no-self-row',
|
|
179
|
+
sessions: [],
|
|
180
|
+
})}\n`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pool = own === null ? report.sessions : [own.session]
|
|
188
|
+
|
|
139
189
|
// A branch name identifies a branch inside one repository and nothing across
|
|
140
190
|
// a machine, so an unscoped match reaches a session working in a different
|
|
141
191
|
// project. `main` is the name that collides on every machine running two.
|
|
@@ -164,11 +214,11 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
164
214
|
}
|
|
165
215
|
|
|
166
216
|
const shown = opts.branch
|
|
167
|
-
?
|
|
217
|
+
? pool.filter(
|
|
168
218
|
(session) =>
|
|
169
219
|
session.branch === opts.branch && session.repository === repository,
|
|
170
220
|
)
|
|
171
|
-
:
|
|
221
|
+
: pool
|
|
172
222
|
|
|
173
223
|
const claim = opts.branch
|
|
174
224
|
? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
|
|
@@ -176,7 +226,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
176
226
|
|
|
177
227
|
intro('aitk sessions list')
|
|
178
228
|
reportConfidence(report)
|
|
179
|
-
reportSessions(shown, opts.branch, repository)
|
|
229
|
+
reportSessions(shown, opts.branch, repository, own !== null)
|
|
180
230
|
if (claim) reportClaim(claim)
|
|
181
231
|
outro()
|
|
182
232
|
|
|
@@ -200,6 +250,22 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
200
250
|
return 0
|
|
201
251
|
}
|
|
202
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Separates a client that states no identity from a roster holding no row for
|
|
255
|
+
* one it does state, since the two send a reader to different places.
|
|
256
|
+
*/
|
|
257
|
+
function selfRefusal(own: Extract<SelfReport, { kind: 'unresolved' }>): string {
|
|
258
|
+
if (own.reason === 'no-identity') {
|
|
259
|
+
return 'Nothing in the environment identifies this session, so --self has nothing to match against. A client setting none of CLAUDE_CODE_SESSION_ID, CLAUDE_PID, or CLAUDE_CODE_MESSAGING_SOCKET cannot be located on the roster at all.'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const held =
|
|
263
|
+
own.identity.sessionId ??
|
|
264
|
+
(own.identity.pid === null ? 'nothing' : `pid ${own.identity.pid}`)
|
|
265
|
+
|
|
266
|
+
return `The environment identifies this session as ${held}, and no live row carries it. The roster holds local process records alone, so a session driving through Remote Control never appears here, and a record whose session has ended is dropped ahead of the match.`
|
|
267
|
+
}
|
|
268
|
+
|
|
203
269
|
/**
|
|
204
270
|
* States how liveness was decided on every run, including the run that decided
|
|
205
271
|
* it the strong way.
|
|
@@ -239,6 +305,7 @@ function reportSessions(
|
|
|
239
305
|
sessions: readonly ResolvedSession[],
|
|
240
306
|
branch: string | undefined,
|
|
241
307
|
repository: string | null,
|
|
308
|
+
scoped: boolean,
|
|
242
309
|
): void {
|
|
243
310
|
logStep('Sessions')
|
|
244
311
|
|
|
@@ -248,11 +315,21 @@ function reportSessions(
|
|
|
248
315
|
)
|
|
249
316
|
}
|
|
250
317
|
|
|
318
|
+
// An empty result under --self says nothing about the roster, since the pool
|
|
319
|
+
// was narrowed to one row before the branch filter ran. Reporting the wider
|
|
320
|
+
// answer there would claim a reading this run never took.
|
|
251
321
|
if (sessions.length === 0) {
|
|
322
|
+
if (branch) {
|
|
323
|
+
logInfo(
|
|
324
|
+
scoped
|
|
325
|
+
? `This session does not hold ${branch}.`
|
|
326
|
+
: `No live session in this repository holds ${branch}.`,
|
|
327
|
+
)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
252
331
|
logInfo(
|
|
253
|
-
|
|
254
|
-
? `No live session in this repository holds ${branch}.`
|
|
255
|
-
: 'No live session. Every record in the registry belongs to a session that has ended.',
|
|
332
|
+
'No live session. Every record in the registry belongs to a session that has ended.',
|
|
256
333
|
)
|
|
257
334
|
return
|
|
258
335
|
}
|
package/src/gov/superseded.ts
CHANGED
|
@@ -15,11 +15,33 @@ import { listRepositoryFiles } from '@/git-files'
|
|
|
15
15
|
*/
|
|
16
16
|
export const SUPERSEDED_MARKER = 'aitk-allow-superseded'
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What matched at one column.
|
|
20
|
+
*
|
|
21
|
+
* `literal` is the superseded value itself. The other three are the stem
|
|
22
|
+
* followed by a glob, by an angle-bracket placeholder, or by nothing that
|
|
23
|
+
* continues a name, which are the three forms this corpus writes a family in.
|
|
24
|
+
* A literal comparison reaches none of them, which is why a rename running the
|
|
25
|
+
* verb once per name reported clean over seven stale citations.
|
|
26
|
+
*/
|
|
27
|
+
export type SupersededMatch = 'literal' | 'glob' | 'placeholder' | 'prefix'
|
|
28
|
+
|
|
18
29
|
export interface SupersededHit {
|
|
19
30
|
readonly file: string
|
|
20
31
|
/** One-based, matching the `file:line` form a reader clicks. */
|
|
21
32
|
readonly line: number
|
|
22
33
|
readonly column: number
|
|
34
|
+
readonly match: SupersededMatch
|
|
35
|
+
/**
|
|
36
|
+
* The nearest heading above the hit in a markdown file, absent elsewhere and
|
|
37
|
+
* above the first heading.
|
|
38
|
+
*
|
|
39
|
+
* A line reads differently under the section holding it. `Use the aitk-*
|
|
40
|
+
* prefix on an internal skill` is a prohibition under `## Must not` and an
|
|
41
|
+
* instruction anywhere else, and a reviewer reading the line alone made
|
|
42
|
+
* exactly that misreading against this tree.
|
|
43
|
+
*/
|
|
44
|
+
readonly heading: string | undefined
|
|
23
45
|
/**
|
|
24
46
|
* Whether the replacement sits on the same line, outside the superseded
|
|
25
47
|
* occurrences themselves.
|
|
@@ -45,11 +67,22 @@ export interface SupersededOptions {
|
|
|
45
67
|
readonly replacement: string
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The segment pair a templated citation is matched on, reported so a run states
|
|
72
|
+
* the net it cast rather than only what the net caught.
|
|
73
|
+
*/
|
|
74
|
+
export interface SupersededStems {
|
|
75
|
+
readonly superseded: string
|
|
76
|
+
readonly replacement: string
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
export type SupersededReport =
|
|
49
80
|
| {
|
|
50
81
|
readonly kind: 'measured'
|
|
51
82
|
readonly superseded: string
|
|
52
83
|
readonly replacement: string
|
|
84
|
+
/** Absent when the two values yield no bounded stem to match on. */
|
|
85
|
+
readonly stems: SupersededStems | undefined
|
|
53
86
|
/** Everything git listed, so the report states its own bound. */
|
|
54
87
|
readonly listed: number
|
|
55
88
|
/** Files opened, which is what the verdict actually covers. */
|
|
@@ -68,22 +101,181 @@ export type SupersededReport =
|
|
|
68
101
|
*/
|
|
69
102
|
const PREVIEW_LIMIT = 200
|
|
70
103
|
|
|
104
|
+
/** The separator a family name is built from across this corpus. */
|
|
105
|
+
const SEGMENT_SEPARATOR = '-'
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A character that continues a name, so `aitk-` inside `aitk-cli` is read as
|
|
109
|
+
* one name rather than as the family prefix written bare.
|
|
110
|
+
*/
|
|
111
|
+
const NAME_CHARACTER = /[A-Za-z0-9]/
|
|
112
|
+
|
|
71
113
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
114
|
+
* A character that puts the stem mid-name when it sits directly before it, so
|
|
115
|
+
* a stem is only read where a name starts.
|
|
116
|
+
*
|
|
117
|
+
* The separator is here and not in `NAME_CHARACTER` because it decides one side
|
|
118
|
+
* only. `aitk-check-toolkit-` is a temp-directory prefix and matched the stem
|
|
119
|
+
* `toolkit` on four fixtures before this, where `claude/skills/toolkit-*` is a
|
|
120
|
+
* path and has to keep matching.
|
|
121
|
+
*/
|
|
122
|
+
const SEGMENT_CONTINUATION = /[A-Za-z0-9_-]/
|
|
123
|
+
|
|
124
|
+
const HEADING = /^#{1,6}\s+\S/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A fence opening or closing a code block, tracked so a shell comment inside
|
|
128
|
+
* one is not read as the section a hit below it sits under. A `# Install` line
|
|
129
|
+
* in a bash block is the ordinary shape of that.
|
|
130
|
+
*/
|
|
131
|
+
const FENCE = /^\s*(?:```|~~~)/
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The segment the two values differ on, carried with everything they share
|
|
135
|
+
* ahead of it.
|
|
136
|
+
*
|
|
137
|
+
* The shared prefix alone is what this exists against. `aitk-cli` and
|
|
138
|
+
* `aitk-feedback-file` share `aitk`, so a stem cut there matches every sibling
|
|
139
|
+
* and reports the whole family on a rename of one folder. Including the
|
|
140
|
+
* differing segment bounds the net to what actually changed, which leaves
|
|
141
|
+
* `aitk-cli` to `aitk-shell` matching neither sibling and `toolkit-operator` to
|
|
142
|
+
* `aitk-operator` matching the family prefix that did move.
|
|
143
|
+
*
|
|
144
|
+
* An empty replacement yields nothing. Retiring a value outright leaves no
|
|
145
|
+
* second value to diverge from, so every stem would run to the first segment
|
|
146
|
+
* and match the family the retirement never touched.
|
|
147
|
+
*/
|
|
148
|
+
export function deriveStems(
|
|
149
|
+
options: SupersededOptions,
|
|
150
|
+
): SupersededStems | undefined {
|
|
151
|
+
if (options.replacement === '') return undefined
|
|
152
|
+
|
|
153
|
+
const supersededSegments = options.superseded.split(SEGMENT_SEPARATOR)
|
|
154
|
+
const replacementSegments = options.replacement.split(SEGMENT_SEPARATOR)
|
|
155
|
+
|
|
156
|
+
let index = 0
|
|
157
|
+
while (
|
|
158
|
+
index < supersededSegments.length &&
|
|
159
|
+
index < replacementSegments.length &&
|
|
160
|
+
supersededSegments[index] === replacementSegments[index]
|
|
161
|
+
) {
|
|
162
|
+
index += 1
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const superseded = trimSeparators(
|
|
166
|
+
supersededSegments.slice(0, index + 1).join(SEGMENT_SEPARATOR),
|
|
167
|
+
)
|
|
168
|
+
const replacement = trimSeparators(
|
|
169
|
+
replacementSegments.slice(0, index + 1).join(SEGMENT_SEPARATOR),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if (superseded === '' || replacement === '') return undefined
|
|
173
|
+
if (superseded === replacement) return undefined
|
|
174
|
+
|
|
175
|
+
return { superseded, replacement }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function trimSeparators(value: string): string {
|
|
179
|
+
let end = value.length
|
|
180
|
+
while (end > 0 && value[end - 1] === SEGMENT_SEPARATOR) end -= 1
|
|
181
|
+
return value.slice(0, end)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Which templated form sits at `column`, or nothing when the stem there
|
|
186
|
+
* continues into an ordinary name.
|
|
187
|
+
*
|
|
188
|
+
* The character after `<stem>-` decides all three, so one scan reads every
|
|
189
|
+
* form. A name character means a sibling spelled out, which the literal
|
|
190
|
+
* comparison already answers for or correctly ignores.
|
|
191
|
+
*/
|
|
192
|
+
function classifyStem(
|
|
193
|
+
line: string,
|
|
194
|
+
column: number,
|
|
195
|
+
stem: string,
|
|
196
|
+
): SupersededMatch | undefined {
|
|
197
|
+
const before = column === 0 ? '' : line[column - 1]
|
|
198
|
+
if (before !== '' && SEGMENT_CONTINUATION.test(before)) return undefined
|
|
199
|
+
|
|
200
|
+
const after = line[column + stem.length + 1] ?? ''
|
|
201
|
+
if (after === '*') return 'glob'
|
|
202
|
+
if (after === '<') return 'placeholder'
|
|
203
|
+
if (after !== '' && NAME_CHARACTER.test(after)) return undefined
|
|
204
|
+
return 'prefix'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether the value replacing whatever matched appears on the line somewhere
|
|
209
|
+
* other than inside the matched occurrences, which is what makes the flag mean
|
|
210
|
+
* anything when one value contains the other.
|
|
75
211
|
*
|
|
76
212
|
* An empty replacement carries nothing, so it answers false rather than the
|
|
77
213
|
* true every line returns from a containment test against the empty string.
|
|
78
214
|
* Retiring a value outright is what passes one, and reporting every finding as
|
|
79
215
|
* carrying its replacement there says the opposite of what happened.
|
|
216
|
+
*
|
|
217
|
+
* A templated hit reads the stem pair instead, since the line repairing
|
|
218
|
+
* `toolkit-*` carries `aitk-*` and never the full name either value spells.
|
|
80
219
|
*/
|
|
81
|
-
function carriesReplacement(
|
|
82
|
-
|
|
83
|
-
|
|
220
|
+
function carriesReplacement(
|
|
221
|
+
line: string,
|
|
222
|
+
options: SupersededOptions,
|
|
223
|
+
stems: SupersededStems | undefined,
|
|
224
|
+
match: SupersededMatch,
|
|
225
|
+
): boolean {
|
|
226
|
+
if (match === 'literal') {
|
|
227
|
+
if (options.replacement === '') return false
|
|
228
|
+
return line.split(options.superseded).join('').includes(options.replacement)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (stems === undefined) return false
|
|
232
|
+
const matched = `${stems.superseded}${SEGMENT_SEPARATOR}`
|
|
233
|
+
return line
|
|
234
|
+
.split(matched)
|
|
235
|
+
.join('')
|
|
236
|
+
.includes(`${stems.replacement}${SEGMENT_SEPARATOR}`)
|
|
84
237
|
}
|
|
85
238
|
|
|
86
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* Every column in one line carrying the superseded value or its family stem,
|
|
241
|
+
* ordered left to right.
|
|
242
|
+
*
|
|
243
|
+
* A stem occurrence sharing a column with a literal one is dropped. The stem is
|
|
244
|
+
* a prefix of the value it derives from whenever only the last segment moved,
|
|
245
|
+
* so the same text would otherwise report twice under two kinds.
|
|
246
|
+
*/
|
|
247
|
+
function matchLine(
|
|
248
|
+
line: string,
|
|
249
|
+
options: SupersededOptions,
|
|
250
|
+
stems: SupersededStems | undefined,
|
|
251
|
+
): { column: number; match: SupersededMatch }[] {
|
|
252
|
+
const matches: { column: number; match: SupersededMatch }[] = []
|
|
253
|
+
|
|
254
|
+
let column = line.indexOf(options.superseded)
|
|
255
|
+
while (column !== -1) {
|
|
256
|
+
matches.push({ column, match: 'literal' })
|
|
257
|
+
column = line.indexOf(options.superseded, column + 1)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (stems !== undefined) {
|
|
261
|
+
const pattern = `${stems.superseded}${SEGMENT_SEPARATOR}`
|
|
262
|
+
let at = line.indexOf(pattern)
|
|
263
|
+
while (at !== -1) {
|
|
264
|
+
const form = classifyStem(line, at, stems.superseded)
|
|
265
|
+
if (form !== undefined && !matches.some((hit) => hit.column === at)) {
|
|
266
|
+
matches.push({ column: at, match: form })
|
|
267
|
+
}
|
|
268
|
+
at = line.indexOf(pattern, at + 1)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return matches.sort((first, second) => first.column - second.column)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Every occurrence of `superseded` or of its family stem in one file's text,
|
|
277
|
+
* exemptions separated.
|
|
278
|
+
*/
|
|
87
279
|
export function sweepText(
|
|
88
280
|
file: string,
|
|
89
281
|
text: string,
|
|
@@ -92,10 +284,19 @@ export function sweepText(
|
|
|
92
284
|
const lines = text.split('\n')
|
|
93
285
|
const findings: SupersededHit[] = []
|
|
94
286
|
const exempt: SupersededHit[] = []
|
|
287
|
+
const stems = deriveStems(options)
|
|
288
|
+
const sectioned = file.endsWith('.md')
|
|
289
|
+
let heading: string | undefined
|
|
290
|
+
let fenced = false
|
|
95
291
|
|
|
96
292
|
for (const [index, line] of lines.entries()) {
|
|
97
|
-
|
|
98
|
-
|
|
293
|
+
if (sectioned) {
|
|
294
|
+
if (FENCE.test(line)) fenced = !fenced
|
|
295
|
+
else if (!fenced && HEADING.test(line)) heading = line.trim()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const matches = matchLine(line, options, stems)
|
|
299
|
+
if (matches.length === 0) continue
|
|
99
300
|
|
|
100
301
|
const muted = isMarked(lines, index, SUPERSEDED_MARKER)
|
|
101
302
|
const bucket = muted ? exempt : findings
|
|
@@ -104,17 +305,17 @@ export function sweepText(
|
|
|
104
305
|
trimmed.length > PREVIEW_LIMIT
|
|
105
306
|
? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
|
|
106
307
|
: trimmed
|
|
107
|
-
const alsoReplacement = carriesReplacement(line, options)
|
|
108
308
|
|
|
109
|
-
|
|
309
|
+
for (const { column, match } of matches) {
|
|
110
310
|
bucket.push({
|
|
111
311
|
file,
|
|
112
312
|
line: index + 1,
|
|
113
313
|
column: column + 1,
|
|
114
|
-
|
|
314
|
+
match,
|
|
315
|
+
heading,
|
|
316
|
+
carriesReplacement: carriesReplacement(line, options, stems, match),
|
|
115
317
|
preview,
|
|
116
318
|
})
|
|
117
|
-
column = line.indexOf(options.superseded, column + 1)
|
|
118
319
|
}
|
|
119
320
|
}
|
|
120
321
|
|
|
@@ -135,9 +336,15 @@ export function sweepText(
|
|
|
135
336
|
* what was listed, opened, and skipped are what keep it from reading as a
|
|
136
337
|
* verdict over the whole tree.
|
|
137
338
|
*
|
|
339
|
+
* Beside the literal comparison it matches the family stem the two values
|
|
340
|
+
* differ on, so a citation writing the family as a pattern enters the report.
|
|
341
|
+
* The trade is false positives, bounded at one across a sixteen-folder rename,
|
|
342
|
+
* which is why this reports rather than gates.
|
|
343
|
+
*
|
|
138
344
|
* What it cannot see is a prose reference that went stale without carrying the
|
|
139
|
-
* value, such as a declaration citing the wrong standard for the transform
|
|
140
|
-
*
|
|
345
|
+
* value, such as a declaration citing the wrong standard for the transform, and
|
|
346
|
+
* a family written in a form neither the value nor the three stem shapes reach.
|
|
347
|
+
* A value sweep closes most of this class and no part of either one.
|
|
141
348
|
*/
|
|
142
349
|
export async function readSuperseded(
|
|
143
350
|
root: string,
|
|
@@ -198,6 +405,7 @@ export async function readSuperseded(
|
|
|
198
405
|
kind: 'measured',
|
|
199
406
|
superseded: options.superseded,
|
|
200
407
|
replacement: options.replacement,
|
|
408
|
+
stems: deriveStems(options),
|
|
201
409
|
listed: listed.length,
|
|
202
410
|
files,
|
|
203
411
|
skipped,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
5
|
+
import { stateDir } from '@/targets/registry'
|
|
6
|
+
|
|
7
|
+
const CLI = join(PROJECT_ROOT, 'src/cli.ts')
|
|
8
|
+
|
|
9
|
+
/** No case has ever needed longer, and a blocked verb should fail fast. */
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 10_000
|
|
11
|
+
|
|
12
|
+
export interface ProcessRun {
|
|
13
|
+
readonly status: number | null
|
|
14
|
+
readonly stdout: string
|
|
15
|
+
readonly stderr: string
|
|
16
|
+
readonly json: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunCliOptions {
|
|
20
|
+
readonly cwd: string
|
|
21
|
+
readonly env?: NodeJS.ProcessEnv
|
|
22
|
+
readonly timeoutMs?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Thrown when a case reaches past its declared temporary directory into this
|
|
27
|
+
* machine's real toolkit state. `stateDir()` in `src/targets/registry.ts`
|
|
28
|
+
* holds both the target registry `gov install` and `gov sync` record into and
|
|
29
|
+
* the sandbox tree `aitk sandbox` provisions into, so a case that inherits the
|
|
30
|
+
* real `HOME` unmodified writes into whichever of the two a verb touches, and
|
|
31
|
+
* nothing but this check would ever say so.
|
|
32
|
+
*/
|
|
33
|
+
export class ContainmentViolation extends Error {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compares two snapshots of this machine's real toolkit state directory and
|
|
37
|
+
* reports whether a spawn changed it. A pure comparison over the two reads
|
|
38
|
+
* rather than the read itself, so the detection logic is testable without
|
|
39
|
+
* touching the filesystem or spawning anything.
|
|
40
|
+
*/
|
|
41
|
+
export function detectStateLeak(
|
|
42
|
+
before: string | undefined,
|
|
43
|
+
after: string | undefined,
|
|
44
|
+
): boolean {
|
|
45
|
+
return before !== after
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A sorted `path:size` listing of every file under this machine's real
|
|
50
|
+
* `stateDir()`, walked recursively rather than read one level deep, so a
|
|
51
|
+
* write nested inside an existing folder, such as a file the sandbox tree
|
|
52
|
+
* already holds, shows up the same as a new top-level entry. Reading a single
|
|
53
|
+
* known file, such as the target registry alone, would miss every sibling
|
|
54
|
+
* `stateDir()` grows, which is what left the sandbox tree unwatched.
|
|
55
|
+
*/
|
|
56
|
+
export function snapshotStateDir(): string {
|
|
57
|
+
const root = stateDir()
|
|
58
|
+
const rows: string[] = []
|
|
59
|
+
|
|
60
|
+
function walk(dir: string): void {
|
|
61
|
+
let names: string[]
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(dir)
|
|
64
|
+
} catch {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const name of names.sort()) {
|
|
69
|
+
const full = join(dir, name)
|
|
70
|
+
let info: ReturnType<typeof statSync>
|
|
71
|
+
try {
|
|
72
|
+
info = statSync(full)
|
|
73
|
+
} catch {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (info.isDirectory()) walk(full)
|
|
77
|
+
else rows.push(`${full}:${info.size}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
walk(root)
|
|
82
|
+
return rows.join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Spawns the real entry point rather than calling a command's action function
|
|
87
|
+
* in-process, so a case answers whether a verb is registered, whether it
|
|
88
|
+
* exits the way its own contract states, and whether its `--json` record
|
|
89
|
+
* parses off stdout alone, none of which an in-process call can misreport.
|
|
90
|
+
*
|
|
91
|
+
* A git hook exports `GIT_DIR`, which would resolve a fixture's git-aware
|
|
92
|
+
* reads against this checkout instead of the temporary directory a case
|
|
93
|
+
* builds, so every spawn drops the `GIT_` prefix before adding the headless
|
|
94
|
+
* flag every case needs to avoid a picker blocking on stdin.
|
|
95
|
+
*
|
|
96
|
+
* `AITK_STATE_DIR` and `AITK_SANDBOX_DIR` get the same treatment as `GIT_DIR`,
|
|
97
|
+
* each pointed at a folder under the case's own `cwd` rather than dropped,
|
|
98
|
+
* since dropping either alone would still resolve through the inherited
|
|
99
|
+
* `HOME` to this machine's real `~/.local/state/aitk`. `stateDir()` and
|
|
100
|
+
* `sandboxTree()` resolve the same three ways and share that parent, so both
|
|
101
|
+
* overrides move together. A case explicitly passing its own value through
|
|
102
|
+
* `options.env` still wins, matching `AITK_NON_INTERACTIVE` below.
|
|
103
|
+
*
|
|
104
|
+
* The `stateDir()` snapshot before and after the spawn is what actually
|
|
105
|
+
* catches an escape past that redirection, since a default can be wrong in a
|
|
106
|
+
* way a case never asserts on its own, and it is what `AITK_SANDBOX_DIR`
|
|
107
|
+
* rides for free: the sandbox tree already sits under `stateDir()`, so
|
|
108
|
+
* walking the whole directory catches a leak there with no override of its
|
|
109
|
+
* own to add. `ContainmentViolation` fails loud rather than leaving a dead
|
|
110
|
+
* row for a reviewer to find on a real machine.
|
|
111
|
+
*/
|
|
112
|
+
export function runCli(
|
|
113
|
+
args: readonly string[],
|
|
114
|
+
options: RunCliOptions,
|
|
115
|
+
): ProcessRun {
|
|
116
|
+
const inherited = Object.fromEntries(
|
|
117
|
+
Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const before = snapshotStateDir()
|
|
121
|
+
|
|
122
|
+
const result = spawnSync('bun', [CLI, ...args], {
|
|
123
|
+
cwd: options.cwd,
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
126
|
+
env: {
|
|
127
|
+
...inherited,
|
|
128
|
+
AITK_NON_INTERACTIVE: '1',
|
|
129
|
+
AITK_STATE_DIR: join(options.cwd, '.aitk-state'),
|
|
130
|
+
AITK_SANDBOX_DIR: join(options.cwd, '.aitk-state', 'sandbox'),
|
|
131
|
+
...options.env,
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const after = snapshotStateDir()
|
|
136
|
+
if (detectStateLeak(before, after)) {
|
|
137
|
+
throw new ContainmentViolation(
|
|
138
|
+
`A case wrote into this machine's real toolkit state at ${stateDir()}. ` +
|
|
139
|
+
'Every process-tier case must stay inside the directory it declared.',
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
status: result.status,
|
|
145
|
+
stdout: result.stdout,
|
|
146
|
+
stderr: result.stderr,
|
|
147
|
+
json: parseJson(result.stdout),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Data goes to stdout and framing to stderr, so a harness reading `--json`
|
|
153
|
+
* off the merged output would assert against a record no verb ever wrote. A
|
|
154
|
+
* command that emits no JSON, or fails before it gets there, leaves the
|
|
155
|
+
* field `undefined` rather than throwing, so a case asserting the exit code
|
|
156
|
+
* of a refusal is not also forced to guard a parse.
|
|
157
|
+
*/
|
|
158
|
+
function parseJson(stdout: string): unknown {
|
|
159
|
+
const trimmed = stdout.trim()
|
|
160
|
+
if (trimmed === '') return undefined
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(trimmed)
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
}
|