@erclx/aitk 3.43.2 → 3.44.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.
Files changed (58) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/{toolkit-cli → aitk-cli}/REQUIREMENT.md +4 -4
  3. package/claude/skills/{toolkit-cli → aitk-cli}/SKILL.md +1 -1
  4. package/claude/skills/{toolkit-feedback → aitk-feedback-file}/REQUIREMENT.md +3 -3
  5. package/claude/skills/{toolkit-feedback → aitk-feedback-file}/SKILL.md +2 -2
  6. package/claude/skills/{toolkit-triage → aitk-feedback-triage}/REQUIREMENT.md +3 -3
  7. package/claude/skills/{toolkit-triage → aitk-feedback-triage}/SKILL.md +3 -3
  8. package/claude/skills/{toolkit-operator → aitk-operator}/REQUIREMENT.md +3 -3
  9. package/claude/skills/{toolkit-operator → aitk-operator}/SKILL.md +3 -3
  10. package/claude/skills/{claude-screencast → aitk-screencast}/REQUIREMENT.md +3 -3
  11. package/claude/skills/{claude-screencast → aitk-screencast}/SKILL.md +2 -2
  12. package/claude/skills/{claude-slides-draft → aitk-slides-draft}/REQUIREMENT.md +3 -3
  13. package/claude/skills/{claude-slides-draft → aitk-slides-draft}/SKILL.md +1 -1
  14. package/claude/skills/{cli-script → bash-cli-script}/REQUIREMENT.md +2 -2
  15. package/claude/skills/{cli-script → bash-cli-script}/SKILL.md +2 -2
  16. package/claude/skills/bash-script/REQUIREMENT.md +2 -2
  17. package/claude/skills/bash-script/SKILL.md +2 -2
  18. package/claude/skills/ci-workflow/REQUIREMENT.md +1 -1
  19. package/claude/skills/claude-memory-review/SKILL.md +2 -2
  20. package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
  21. package/claude/skills/claude-seed-sync/REQUIREMENT.md +1 -1
  22. package/claude/skills/claude-seed-sync/SKILL.md +1 -1
  23. package/claude/skills/git-issue/REQUIREMENT.md +2 -2
  24. package/claude/skills/git-issue/SKILL.md +1 -1
  25. package/claude/skills/{restate → restate-plainly}/REQUIREMENT.md +2 -2
  26. package/claude/skills/{restate → restate-plainly}/SKILL.md +2 -2
  27. package/claude/skills/setup-init/REQUIREMENT.md +1 -1
  28. package/claude/skills/setup-init/SKILL.md +1 -1
  29. package/claude/skills/write-human/REQUIREMENT.md +1 -1
  30. package/claude/skills/write-human/SKILL.md +1 -1
  31. package/docs/agents/demo.md +1 -1
  32. package/docs/agents/index.md +1 -0
  33. package/docs/agents/overview.md +2 -2
  34. package/docs/agents/scripting.md +1 -1
  35. package/docs/agents/sessions.md +11 -5
  36. package/docs/agents/targets.md +83 -0
  37. package/docs/ai-workflow.md +16 -16
  38. package/docs/target-projects.md +1 -1
  39. package/governance/rules/lang/120-bash.md +1 -1
  40. package/package.json +1 -1
  41. package/scripts/core/regen-tooling-paths.sh +1 -1
  42. package/scripts/core/verify.sh +1 -1
  43. package/src/claude/cases/authoring.ts +2 -2
  44. package/src/claude/cases/claude-workflow.ts +2 -2
  45. package/src/claude/cases/setup.ts +6 -6
  46. package/src/cli.ts +3 -0
  47. package/src/commands/demo.ts +1 -1
  48. package/src/commands/sessions.ts +24 -8
  49. package/src/commands/targets.ts +319 -0
  50. package/src/demo/beats.ts +1 -1
  51. package/src/sessions/claim.ts +7 -0
  52. package/src/sync/stamp.ts +9 -0
  53. package/src/targets/pulls.ts +250 -0
  54. package/src/targets/registry.ts +161 -0
  55. package/src/targets/resolve.ts +145 -0
  56. package/src/targets/sweep.ts +246 -0
  57. package/standards/issue.md +1 -1
  58. /package/claude/skills/{cli-script → bash-cli-script}/references/template.md +0 -0
package/src/sync/stamp.ts CHANGED
@@ -3,6 +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 { recordTarget } from '@/targets/registry'
6
7
 
7
8
  /**
8
9
  * Domains the stamp can record. Governance attributes file by file through the
@@ -209,6 +210,14 @@ async function putDomain(
209
210
  const path = stampPath(target)
210
211
  await mkdir(dirname(path), { recursive: true })
211
212
  await writeFile(path, `${JSON.stringify(stamp, null, 2)}\n`)
213
+
214
+ // Every install and sync that stamps a target passes through here, which is
215
+ // what makes this the one place the machine-level index can be kept without
216
+ // each command remembering to. Its outcome is dropped rather than reported:
217
+ // the stamp just written is the authoritative record of this install, the
218
+ // index is a cache over every such stamp, and a state folder nobody can
219
+ // write is not a reason to fail a sync that already landed its files.
220
+ recordTarget(target, now)
212
221
  }
213
222
 
214
223
  const commitCache = new Map<string, Promise<string | undefined>>()
@@ -0,0 +1,250 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git-env'
3
+ import { isDirectory } from '@/target'
4
+
5
+ const GH_TIMEOUT_MS = 30_000
6
+
7
+ /**
8
+ * The two headings a review pass posts under.
9
+ *
10
+ * Owned by `claude-pr-review`, which states the full set once, and pinned here
11
+ * the way `claude-orchestrate/scripts/poll.sh` pins them. All three surfaces
12
+ * ship separately, so a heading added in that skill goes stale here with
13
+ * nothing comparing the copies.
14
+ */
15
+ const REVIEW_OPEN = '## Review'
16
+ const REVIEW_CLOSED = '## Review closed'
17
+
18
+ /** Why a target produced no reading, so an unreachable one never reads as having no work. */
19
+ export type TargetRefusal = 'not-a-directory' | 'gh-unavailable' | 'list-failed'
20
+
21
+ export type ChecksState = 'passing' | 'failing' | 'pending'
22
+
23
+ /** Whether the newest review pass left work owed. */
24
+ export type ReviewState = 'open' | 'closed'
25
+
26
+ export interface PullState {
27
+ readonly number: number
28
+ readonly title: string
29
+ readonly url: string
30
+ readonly head: string
31
+ /** Null when GitHub reported no check at all, which is not the same answer as passing. */
32
+ readonly checks: ChecksState | null
33
+ /** Null when no pass carrying a review heading has landed on the thread. */
34
+ readonly review: ReviewState | null
35
+ /** False when the review read failed, leaving `review` covering nothing. */
36
+ readonly reviewReadable: boolean
37
+ }
38
+
39
+ export type TargetPulls =
40
+ | {
41
+ readonly kind: 'refused'
42
+ readonly path: string
43
+ readonly reason: TargetRefusal
44
+ }
45
+ | {
46
+ readonly kind: 'read'
47
+ readonly path: string
48
+ readonly pulls: readonly PullState[]
49
+ }
50
+
51
+ /** Runs one `gh` invocation in a target and hands back its stdout, or null when it failed. */
52
+ export type GhRunner = (
53
+ cwd: string,
54
+ args: readonly string[],
55
+ ) => Promise<string | null>
56
+
57
+ export interface PullsOptions {
58
+ readonly run?: GhRunner
59
+ }
60
+
61
+ /**
62
+ * `gh` resolves its repository through the same environment variables git does
63
+ * and they beat `cwd`, so a run from inside a hook would read whichever
64
+ * repository that hook's environment names rather than the target handed here.
65
+ */
66
+ const runGh: GhRunner = async (cwd, args) => {
67
+ if (Bun.which('gh') === null) return null
68
+
69
+ try {
70
+ const result = await execa('gh', [...args], {
71
+ cwd,
72
+ timeout: GH_TIMEOUT_MS,
73
+ env: gitEnv(),
74
+ extendEnv: false,
75
+ })
76
+ return result.stdout
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ interface RawPull {
83
+ readonly number?: number
84
+ readonly title?: string
85
+ readonly url?: string
86
+ readonly headRefOid?: string
87
+ readonly statusCheckRollup?: readonly RawCheck[]
88
+ }
89
+
90
+ interface RawCheck {
91
+ readonly status?: string
92
+ readonly conclusion?: string
93
+ readonly state?: string
94
+ }
95
+
96
+ interface RawReview {
97
+ readonly body?: string
98
+ readonly submittedAt?: string
99
+ }
100
+
101
+ const FAILED = new Set([
102
+ 'FAILURE',
103
+ 'TIMED_OUT',
104
+ 'CANCELLED',
105
+ 'ACTION_REQUIRED',
106
+ 'STARTUP_FAILURE',
107
+ 'ERROR',
108
+ ])
109
+
110
+ /**
111
+ * Collapses every check on a head into one word.
112
+ *
113
+ * A failure outranks a pending one, because a run still going cannot clear a
114
+ * job that already failed and reporting the head as pending would invite a
115
+ * wait for an answer that has arrived.
116
+ */
117
+ export function rollup(checks: readonly RawCheck[]): ChecksState | null {
118
+ if (checks.length === 0) return null
119
+
120
+ const verdicts = checks.map((check) => check.conclusion ?? check.state ?? '')
121
+
122
+ if (verdicts.some((verdict) => FAILED.has(verdict))) return 'failing'
123
+
124
+ const running = checks.some(
125
+ (check) =>
126
+ (check.status !== undefined && check.status !== 'COMPLETED') ||
127
+ check.state === 'PENDING' ||
128
+ (check.conclusion === undefined && check.state === undefined),
129
+ )
130
+
131
+ return running ? 'pending' : 'passing'
132
+ }
133
+
134
+ /**
135
+ * Reads the heading of the newest pass carrying one, matching on the first line
136
+ * alone the way `poll.sh` does.
137
+ *
138
+ * The reviews arrive oldest first, so the last match is the current state of
139
+ * the thread. A pass carrying neither heading is somebody reviewing by hand and
140
+ * says nothing about whether the loop owes work.
141
+ */
142
+ export function latestReview(
143
+ reviews: readonly RawReview[],
144
+ ): ReviewState | null {
145
+ let state: ReviewState | null = null
146
+
147
+ for (const review of reviews) {
148
+ const first = (review.body ?? '').split('\n')[0]?.replace(/\r$/, '')
149
+ if (first === REVIEW_OPEN) state = 'open'
150
+ else if (first === REVIEW_CLOSED) state = 'closed'
151
+ }
152
+
153
+ return state
154
+ }
155
+
156
+ function parse<T>(text: string | null): T | null {
157
+ if (text === null) return null
158
+ try {
159
+ return JSON.parse(text) as T
160
+ } catch {
161
+ return null
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Reports the open pull requests in one target with their checks and the
167
+ * heading their newest review pass carries.
168
+ *
169
+ * A list that failed and a target with no open pull request are separated
170
+ * rather than collapsed, since reading the first as the second reports a target
171
+ * as done when nothing was read at all. That is the failure mode the shell loop
172
+ * this replaces had no way to surface.
173
+ */
174
+ export async function readTargetPulls(
175
+ path: string,
176
+ opts: PullsOptions = {},
177
+ ): Promise<TargetPulls> {
178
+ const run = opts.run ?? runGh
179
+
180
+ if (!isDirectory(path))
181
+ return { kind: 'refused', path, reason: 'not-a-directory' }
182
+
183
+ if (opts.run === undefined && Bun.which('gh') === null) {
184
+ return { kind: 'refused', path, reason: 'gh-unavailable' }
185
+ }
186
+
187
+ const listed = parse<readonly RawPull[]>(
188
+ await run(path, [
189
+ 'pr',
190
+ 'list',
191
+ '--state',
192
+ 'open',
193
+ '--json',
194
+ 'number,title,url,headRefOid,statusCheckRollup',
195
+ ]),
196
+ )
197
+
198
+ if (listed === null) return { kind: 'refused', path, reason: 'list-failed' }
199
+
200
+ const pulls = await Promise.all(
201
+ listed
202
+ .filter(
203
+ (raw): raw is RawPull & { number: number } =>
204
+ typeof raw.number === 'number',
205
+ )
206
+ .map(async (raw) => {
207
+ // One query per pull request, so a review read that failed surfaces on
208
+ // the thread it failed for rather than emptying the whole target.
209
+ const reviews = parse<{ reviews?: readonly RawReview[] }>(
210
+ await run(path, [
211
+ 'pr',
212
+ 'view',
213
+ String(raw.number),
214
+ '--json',
215
+ 'reviews',
216
+ ]),
217
+ )
218
+
219
+ return {
220
+ number: raw.number,
221
+ title: raw.title ?? '',
222
+ url: raw.url ?? '',
223
+ head: raw.headRefOid ?? '',
224
+ checks: rollup(raw.statusCheckRollup ?? []),
225
+ review: reviews === null ? null : latestReview(reviews.reviews ?? []),
226
+ reviewReadable: reviews !== null,
227
+ }
228
+ }),
229
+ )
230
+
231
+ return { kind: 'read', path, pulls }
232
+ }
233
+
234
+ /**
235
+ * Reads every target, one at a time.
236
+ *
237
+ * The reads are serial rather than batched because each one spends a GitHub API
238
+ * quota shared across all of them, and a wave running over a dozen targets that
239
+ * fired them together would meet the secondary rate limit rather than an answer.
240
+ */
241
+ export async function readPullsAcross(
242
+ paths: readonly string[],
243
+ opts: PullsOptions = {},
244
+ ): Promise<readonly TargetPulls[]> {
245
+ const reports: TargetPulls[] = []
246
+
247
+ for (const path of paths) reports.push(await readTargetPulls(path, opts))
248
+
249
+ return reports
250
+ }
@@ -0,0 +1,161 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join, resolve } from 'node:path'
4
+
5
+ /**
6
+ * One project the toolkit has installed into, as the install recorded it.
7
+ *
8
+ * The path is the target root rather than its git directory, because the
9
+ * record is written by a sync that knows where it wrote and not by anything
10
+ * that resolved a repository. Whether two entries are one project is a
11
+ * question about their origins, which `src/targets/sweep.ts` answers.
12
+ */
13
+ export interface TargetRecord {
14
+ readonly path: string
15
+ /** ISO stamp of the most recent sync that recorded this target. */
16
+ readonly stampedAt: string
17
+ }
18
+
19
+ /**
20
+ * An absent file and an empty one are separate answers, the same split
21
+ * `src/sessions/registry.ts` draws.
22
+ *
23
+ * The first means no sync has ever recorded a target on this machine, so the
24
+ * population is unknown and the sweep is the only reading available. The
25
+ * second means the file was read and holds no usable row, which a caller
26
+ * should be able to tell apart from a lookup that never ran.
27
+ */
28
+ export type TargetRegistry =
29
+ | { readonly kind: 'absent'; readonly path: string }
30
+ | {
31
+ readonly kind: 'read'
32
+ readonly path: string
33
+ readonly targets: readonly TargetRecord[]
34
+ }
35
+
36
+ interface StoredRegistry {
37
+ readonly version: number
38
+ readonly targets: readonly TargetRecord[]
39
+ }
40
+
41
+ const VERSION = 1
42
+
43
+ /**
44
+ * Resolves the folder holding this machine's toolkit state.
45
+ *
46
+ * Twin of `sandboxTree` in `src/commands/sandbox.ts`, which resolves the same
47
+ * three sources in the same order. The override exists so a test never writes
48
+ * into the home directory of whoever runs it.
49
+ */
50
+ export function stateDir(): string {
51
+ const override = process.env.AITK_STATE_DIR
52
+ if (override !== undefined && override !== '') return override
53
+
54
+ const state = process.env.XDG_STATE_HOME
55
+ const base =
56
+ state !== undefined && state !== ''
57
+ ? state
58
+ : join(homedir(), '.local', 'state')
59
+
60
+ return join(base, 'aitk')
61
+ }
62
+
63
+ export function registryPath(): string {
64
+ return join(stateDir(), 'targets.json')
65
+ }
66
+
67
+ function isRecord(value: Partial<TargetRecord>): value is TargetRecord {
68
+ return (
69
+ typeof value.path === 'string' &&
70
+ value.path.length > 0 &&
71
+ typeof value.stampedAt === 'string' &&
72
+ value.stampedAt.length > 0
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Reads every recorded target, sorted by path.
78
+ *
79
+ * A row missing either field is dropped rather than reported. This module is
80
+ * the file's only writer, so a malformed row is a hand edit or a truncated
81
+ * write and neither is a finding the caller can act on. What a caller can act
82
+ * on is the file being absent, which is its own kind above.
83
+ */
84
+ export function readTargetRegistry(
85
+ path: string = registryPath(),
86
+ ): TargetRegistry {
87
+ let text: string
88
+ try {
89
+ text = readFileSync(path, 'utf8')
90
+ } catch {
91
+ return { kind: 'absent', path }
92
+ }
93
+
94
+ let parsed: unknown
95
+ try {
96
+ parsed = JSON.parse(text)
97
+ } catch {
98
+ return { kind: 'read', path, targets: [] }
99
+ }
100
+
101
+ if (typeof parsed !== 'object' || parsed === null) {
102
+ return { kind: 'read', path, targets: [] }
103
+ }
104
+
105
+ const stored = parsed as Partial<StoredRegistry>
106
+ const rows = Array.isArray(stored.targets) ? stored.targets : []
107
+ const targets = rows
108
+ .filter((row): row is TargetRecord =>
109
+ isRecord(row as Partial<TargetRecord>),
110
+ )
111
+ .sort((a, b) => a.path.localeCompare(b.path))
112
+
113
+ return { kind: 'read', path, targets }
114
+ }
115
+
116
+ /** Why a record attempt did not land, so a caller can say so rather than assume it did. */
117
+ export type RecordOutcome = 'recorded' | 'unwritten'
118
+
119
+ /**
120
+ * Records one target, keyed by its resolved path and replacing any row already
121
+ * held for it.
122
+ *
123
+ * The write is a temp file plus a rename, so a reader never meets a half
124
+ * written file. Two syncs finishing together still resolve last-writer-wins on
125
+ * the merged set, which can drop the row the loser added. That is left rather
126
+ * than locked: the authoritative record of an install is the stamp inside the
127
+ * target, this index is a cache over those, and the next sync of the dropped
128
+ * target restores its row.
129
+ *
130
+ * Nothing removes a row either, so a target that was deleted or that dropped
131
+ * the toolkit stays here and the count drifts upward. `aitk targets pulls`
132
+ * meets that on use, since it refuses a path it cannot open rather than
133
+ * reading it as a target with no work, but `aitk targets list` does not: it
134
+ * never opens a recorded path, and the count is its whole output.
135
+ */
136
+ export function recordTarget(
137
+ target: string,
138
+ now: Date,
139
+ path: string = registryPath(),
140
+ ): RecordOutcome {
141
+ const resolved = resolve(target)
142
+ const current = readTargetRegistry(path)
143
+ const existing = current.kind === 'read' ? current.targets : []
144
+
145
+ const targets = [
146
+ ...existing.filter((row) => row.path !== resolved),
147
+ { path: resolved, stampedAt: now.toISOString() },
148
+ ].sort((a, b) => a.path.localeCompare(b.path))
149
+
150
+ const payload: StoredRegistry = { version: VERSION, targets }
151
+ const temp = `${path}.${process.pid}.tmp`
152
+
153
+ try {
154
+ mkdirSync(dirname(path), { recursive: true })
155
+ writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`)
156
+ renameSync(temp, path)
157
+ return 'recorded'
158
+ } catch {
159
+ return 'unwritten'
160
+ }
161
+ }
@@ -0,0 +1,145 @@
1
+ import { resolve } from 'node:path'
2
+ import { isLegacyStamped } from '@/sync/stamp'
3
+ import {
4
+ readTargetRegistry,
5
+ type TargetRegistry,
6
+ registryPath,
7
+ } from '@/targets/registry'
8
+ import {
9
+ type SweepBound,
10
+ type SweepOptions,
11
+ sweepTargets,
12
+ } from '@/targets/sweep'
13
+
14
+ /**
15
+ * Where a target came from, carried on the row rather than inferred.
16
+ *
17
+ * A caller deciding whether an answer is trustworthy needs to know which rows
18
+ * the machine recorded for itself and which a walk guessed at, and the two
19
+ * carry different bounds.
20
+ */
21
+ export type TargetSource = 'given' | 'record' | 'sweep'
22
+
23
+ export interface KnownTarget {
24
+ /** Every checkout of this project on this machine, one for the ordinary case. */
25
+ readonly paths: readonly string[]
26
+ readonly origin: string | null
27
+ readonly source: TargetSource
28
+ /** When a sync last recorded this target, or null for a row only a sweep found. */
29
+ readonly stampedAt: string | null
30
+ /** True while the install stamp still sits at the retired path. */
31
+ readonly legacy: boolean
32
+ }
33
+
34
+ export interface ResolvedTargets {
35
+ readonly targets: readonly KnownTarget[]
36
+ /** Null when the caller named its targets, so no registry read was attempted. */
37
+ readonly registry: TargetRegistry | null
38
+ /** Null when no sweep ran, which is the ordinary case. */
39
+ readonly bound: SweepBound | null
40
+ }
41
+
42
+ export interface ResolveTargetsOptions extends SweepOptions {
43
+ /** Paths the caller named. These win outright and suppress both other sources. */
44
+ readonly paths?: readonly string[]
45
+ /** Roots to walk, supplementing the record rather than replacing it. */
46
+ readonly sweep?: readonly string[]
47
+ readonly registryFile?: string
48
+ }
49
+
50
+ /**
51
+ * Answers which projects the toolkit has installed into.
52
+ *
53
+ * The record written at install time is the primary source and a walk is the
54
+ * fallback, which is the shape the population needs: a sweep alone cannot see
55
+ * another machine or a clone under a path nobody named, and that is exactly how
56
+ * the count moved from four to seven inside one pass and was then wrong in both
57
+ * directions at once.
58
+ *
59
+ * A caller naming paths gets those and no lookup at all, since it has already
60
+ * answered the question this resolves.
61
+ */
62
+ export async function resolveTargets(
63
+ opts: ResolveTargetsOptions = {},
64
+ ): Promise<ResolvedTargets> {
65
+ if (opts.paths !== undefined && opts.paths.length > 0) {
66
+ return {
67
+ targets: opts.paths.map((path) => given(resolve(path))),
68
+ registry: null,
69
+ bound: null,
70
+ }
71
+ }
72
+
73
+ const file = opts.registryFile ?? registryPath()
74
+ const registry = readTargetRegistry(file)
75
+
76
+ const recorded: KnownTarget[] =
77
+ registry.kind === 'read'
78
+ ? registry.targets.map((row) => ({
79
+ paths: [row.path],
80
+ origin: null,
81
+ source: 'record' as const,
82
+ stampedAt: row.stampedAt,
83
+ legacy: isLegacyStamped(row.path),
84
+ }))
85
+ : []
86
+
87
+ if (opts.sweep === undefined || opts.sweep.length === 0) {
88
+ return { targets: recorded, registry, bound: null }
89
+ }
90
+
91
+ const swept = await sweepTargets(opts.sweep, opts)
92
+ const known = new Set(recorded.flatMap((target) => target.paths))
93
+
94
+ // A sweep row whose paths the record already holds is the same project read
95
+ // twice, so it adds nothing. A row holding one known path and one unknown one
96
+ // is the second-clone case, and it replaces the record's row rather than
97
+ // sitting beside it, since the sweep is the only source that can see both.
98
+ const added: KnownTarget[] = []
99
+ const superseded = new Set<string>()
100
+
101
+ for (const target of swept.targets) {
102
+ const overlap = target.paths.filter((path) => known.has(path))
103
+
104
+ if (overlap.length === target.paths.length) continue
105
+
106
+ for (const path of overlap) superseded.add(path)
107
+
108
+ // The recorded clone leads, because the record only names one a sync
109
+ // actually ran in, where the rest are checkouts a walk happened to find.
110
+ // Every caller reading a single path takes the first, and picking that by
111
+ // sort order is how a repair ran in one clone while the count was taken
112
+ // against another and the target read as untouched.
113
+ added.push({
114
+ paths: [...overlap, ...target.paths.filter((path) => !known.has(path))],
115
+ origin: target.origin,
116
+ source: overlap.length > 0 ? 'record' : 'sweep',
117
+ stampedAt:
118
+ recorded.find((row) => overlap.includes(row.paths[0] ?? ''))
119
+ ?.stampedAt ?? null,
120
+ legacy: target.legacy,
121
+ })
122
+ }
123
+
124
+ const kept = recorded.filter(
125
+ (row) => !row.paths.some((path) => superseded.has(path)),
126
+ )
127
+
128
+ return {
129
+ targets: [...kept, ...added].sort((a, b) =>
130
+ (a.paths[0] ?? '').localeCompare(b.paths[0] ?? ''),
131
+ ),
132
+ registry,
133
+ bound: swept.bound,
134
+ }
135
+ }
136
+
137
+ function given(path: string): KnownTarget {
138
+ return {
139
+ paths: [path],
140
+ origin: null,
141
+ source: 'given',
142
+ stampedAt: null,
143
+ legacy: isLegacyStamped(path),
144
+ }
145
+ }