@erclx/aitk 3.43.1 → 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 (62) 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/git-ship/REQUIREMENT.md +1 -1
  26. package/claude/skills/git-ship/SKILL.md +1 -2
  27. package/claude/skills/git-worktree/REQUIREMENT.md +1 -1
  28. package/claude/skills/git-worktree/SKILL.md +7 -4
  29. package/claude/skills/{restate → restate-plainly}/REQUIREMENT.md +2 -2
  30. package/claude/skills/{restate → restate-plainly}/SKILL.md +2 -2
  31. package/claude/skills/setup-init/REQUIREMENT.md +1 -1
  32. package/claude/skills/setup-init/SKILL.md +1 -1
  33. package/claude/skills/write-human/REQUIREMENT.md +1 -1
  34. package/claude/skills/write-human/SKILL.md +1 -1
  35. package/docs/agents/demo.md +1 -1
  36. package/docs/agents/index.md +1 -0
  37. package/docs/agents/overview.md +2 -2
  38. package/docs/agents/scripting.md +1 -1
  39. package/docs/agents/sessions.md +11 -5
  40. package/docs/agents/targets.md +83 -0
  41. package/docs/ai-workflow.md +16 -16
  42. package/docs/target-projects.md +1 -1
  43. package/governance/rules/lang/120-bash.md +1 -1
  44. package/package.json +1 -1
  45. package/scripts/core/regen-tooling-paths.sh +1 -1
  46. package/scripts/core/verify.sh +1 -1
  47. package/src/claude/cases/authoring.ts +2 -2
  48. package/src/claude/cases/claude-workflow.ts +2 -2
  49. package/src/claude/cases/setup.ts +6 -6
  50. package/src/cli.ts +3 -0
  51. package/src/commands/demo.ts +1 -1
  52. package/src/commands/sessions.ts +24 -8
  53. package/src/commands/targets.ts +319 -0
  54. package/src/demo/beats.ts +1 -1
  55. package/src/sessions/claim.ts +7 -0
  56. package/src/sync/stamp.ts +9 -0
  57. package/src/targets/pulls.ts +250 -0
  58. package/src/targets/registry.ts +161 -0
  59. package/src/targets/resolve.ts +145 -0
  60. package/src/targets/sweep.ts +246 -0
  61. package/standards/issue.md +1 -1
  62. /package/claude/skills/{cli-script → bash-cli-script}/references/template.md +0 -0
@@ -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
+ }
@@ -0,0 +1,246 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { $ } from 'bun'
4
+ import { gitEnv } from '@/git-env'
5
+ import { isLegacyStamped, legacyStampPath, stampPath } from '@/sync/stamp'
6
+ import { isDirectory } from '@/target'
7
+
8
+ /**
9
+ * Folders a walk never descends into.
10
+ *
11
+ * Most hold vendored trees or the repository's own object store, where a target
12
+ * cannot live and walking costs the sweep most of its time. `.claude` is here
13
+ * for a different reason: a stamped folder is found by testing the folder
14
+ * itself rather than by walking into its `.claude`, so descending finds nothing
15
+ * new, and `.claude/worktrees/` holds a full checkout per linked worktree that
16
+ * carries a copy of its own target's stamp. One target on this machine had five
17
+ * of them, each of which would have reported as a target of its own.
18
+ */
19
+ const SKIP = new Set([
20
+ 'node_modules',
21
+ '.git',
22
+ '.claude',
23
+ 'dist',
24
+ 'build',
25
+ 'vendor',
26
+ 'target',
27
+ '.next',
28
+ '.venv',
29
+ ])
30
+
31
+ /** How deep below a root the walk goes before it stops and says so. */
32
+ export const DEFAULT_DEPTH = 4
33
+
34
+ /**
35
+ * One project, with every path on this machine that holds a stamp for it.
36
+ *
37
+ * The paths are plural because a project can be cloned more than once, which
38
+ * is not a hypothetical: the census taken on 2026-08-28 counted `caret` at the
39
+ * clone it walked while the repair had run in a second clone it never saw, and
40
+ * that gap is what left a task carrying a ticked outcome its own finding
41
+ * contradicted.
42
+ */
43
+ export interface SweptTarget {
44
+ readonly paths: readonly string[]
45
+ /** The origin every path agrees on, or null when git resolved none. */
46
+ readonly origin: string | null
47
+ /** True while every path still carries its stamp at the retired location. */
48
+ readonly legacy: boolean
49
+ }
50
+
51
+ /**
52
+ * What the answer is bounded by, reported alongside it.
53
+ *
54
+ * A sweep cannot see another machine, a clone under a path nobody named, or a
55
+ * tree it lacked permission to read. Stating the bound is what makes an
56
+ * incomplete answer legible as incomplete, which the two undercounts this
57
+ * exists to replace were not.
58
+ */
59
+ export interface SweepBound {
60
+ readonly roots: readonly string[]
61
+ readonly depth: number
62
+ /** Folders the walk stopped at on reaching the depth cap, so a target below one is unseen. */
63
+ readonly truncated: readonly string[]
64
+ /** Roots that could not be listed at all, as opposed to holding nothing. */
65
+ readonly unreadable: readonly string[]
66
+ /**
67
+ * Symlinks to directories, which the walk does not follow.
68
+ *
69
+ * `readdirSync` with `withFileTypes` answers `isDirectory()` false for one, so
70
+ * without this field a target reached only through a symlink is dropped
71
+ * before the walk and named nowhere, which is a third silent undercount
72
+ * beside the two this module replaces.
73
+ */
74
+ readonly symlinks: readonly string[]
75
+ }
76
+
77
+ export interface SweepReport {
78
+ readonly targets: readonly SweptTarget[]
79
+ readonly bound: SweepBound
80
+ }
81
+
82
+ export interface SweepOptions {
83
+ readonly depth?: number
84
+ /** Resolves a checkout's origin. Injected so a test needs no remote. */
85
+ readonly originOf?: (path: string) => Promise<string | null>
86
+ }
87
+
88
+ /** Whether a folder carries an install stamp at either the current or the retired path. */
89
+ function isStamped(path: string): boolean {
90
+ return (
91
+ Bun.file(stampPath(path)).size > 0 ||
92
+ Bun.file(legacyStampPath(path)).size > 0
93
+ )
94
+ }
95
+
96
+ /**
97
+ * Reads the origin a checkout pushes to, trimmed to a form two clones of one
98
+ * project agree on.
99
+ *
100
+ * The scheme and the `.git` suffix are dropped because one project is commonly
101
+ * cloned over ssh in one place and https in another, and a comparison keeping
102
+ * either reports those as two projects, which is the count this exists to fix.
103
+ *
104
+ * Userinfo goes with them, and it is the component that has to. A checkout
105
+ * cloned as `https://x-access-token:<token>@github.com/owner/repo.git` puts the
106
+ * token in the key, and the key is returned on `SweptTarget.origin` and printed
107
+ * by `aitk targets list --json`. Dropping it also fixes the count, since
108
+ * `https://someuser@github.com/owner/repo.git` keyed on the user and so failed
109
+ * to group with the same project's ssh clone.
110
+ */
111
+ export async function originOf(path: string): Promise<string | null> {
112
+ const result = await $`git -C ${path} remote get-url origin`
113
+ .env(gitEnv())
114
+ .quiet()
115
+ .nothrow()
116
+
117
+ if (result.exitCode !== 0) return null
118
+
119
+ const raw = result.stdout.toString().trim()
120
+ if (raw.length === 0) return null
121
+
122
+ return raw
123
+ .replace(/^[a-z+]+:\/\//, '')
124
+ .replace(/^[^/@]*@/, '')
125
+ .replace(/:/, '/')
126
+ .replace(/\.git$/, '')
127
+ .replace(/\/$/, '')
128
+ .toLowerCase()
129
+ }
130
+
131
+ /**
132
+ * Walks the given roots for installed targets and reports what bounds the walk.
133
+ *
134
+ * A stamped folder is descended into like any other, because a stamp is written
135
+ * by an install someone ran in that folder and a target can hold others. The
136
+ * machine this was measured on has exactly that shape: one stamped repository
137
+ * holds the eight the hand census walked, so stopping at the outer one would
138
+ * have hidden every target the sweep exists to find.
139
+ */
140
+ export async function sweepTargets(
141
+ roots: readonly string[],
142
+ opts: SweepOptions = {},
143
+ ): Promise<SweepReport> {
144
+ const depth = opts.depth ?? DEFAULT_DEPTH
145
+ const resolveOrigin = opts.originOf ?? originOf
146
+
147
+ const found: string[] = []
148
+ const truncated: string[] = []
149
+ const unreadable: string[] = []
150
+ const symlinks: string[] = []
151
+ const seen = new Set<string>()
152
+
153
+ const walk = (dir: string, level: number): void => {
154
+ if (seen.has(dir)) return
155
+ seen.add(dir)
156
+
157
+ if (isStamped(dir)) found.push(dir)
158
+
159
+ if (level >= depth) {
160
+ truncated.push(dir)
161
+ return
162
+ }
163
+
164
+ let entries: string[]
165
+ try {
166
+ const listed = readdirSync(dir, { withFileTypes: true }).filter(
167
+ (entry) => !SKIP.has(entry.name),
168
+ )
169
+
170
+ // A symlink is reported as neither a directory nor walked, so a directory
171
+ // symlink is named here rather than dropped. Following one is available,
172
+ // since `seen` already closes the cycle, and naming it is the answer the
173
+ // bound asks for: what the walk did not reach, stated rather than
174
+ // omitted. `isDirectory` follows the link, so a symlink to a file or a
175
+ // broken one is excluded rather than read as an unfollowed directory.
176
+ for (const entry of listed) {
177
+ if (!entry.isSymbolicLink()) continue
178
+ const full = join(dir, entry.name)
179
+ if (isDirectory(full)) symlinks.push(full)
180
+ }
181
+
182
+ entries = listed
183
+ .filter((entry) => entry.isDirectory())
184
+ .map((entry) => entry.name)
185
+ } catch {
186
+ unreadable.push(dir)
187
+ return
188
+ }
189
+
190
+ for (const name of entries) walk(join(dir, name), level + 1)
191
+ }
192
+
193
+ const resolved = roots.map((root) => resolve(root))
194
+
195
+ for (const root of resolved) {
196
+ if (!isDirectory(root)) {
197
+ unreadable.push(root)
198
+ continue
199
+ }
200
+ walk(root, 0)
201
+ }
202
+
203
+ return {
204
+ targets: await group(found.sort(), resolveOrigin),
205
+ bound: { roots: resolved, depth, truncated, unreadable, symlinks },
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Collapses the paths sharing one origin into a single target.
211
+ *
212
+ * A path whose origin does not resolve stays on its own, since two checkouts
213
+ * with no remote cannot be shown to be the same project and merging them on
214
+ * that absence would undercount in the other direction.
215
+ */
216
+ async function group(
217
+ paths: readonly string[],
218
+ resolveOrigin: (path: string) => Promise<string | null>,
219
+ ): Promise<readonly SweptTarget[]> {
220
+ const origins = await Promise.all(paths.map(resolveOrigin))
221
+ const byOrigin = new Map<string, string[]>()
222
+ const alone: SweptTarget[] = []
223
+
224
+ paths.forEach((path, index) => {
225
+ const origin = origins[index]
226
+
227
+ if (origin === null || origin === undefined) {
228
+ alone.push({ paths: [path], origin: null, legacy: isLegacyStamped(path) })
229
+ return
230
+ }
231
+
232
+ const group = byOrigin.get(origin)
233
+ if (group === undefined) byOrigin.set(origin, [path])
234
+ else group.push(path)
235
+ })
236
+
237
+ const merged = [...byOrigin.entries()].map(([origin, group]) => ({
238
+ paths: group,
239
+ origin,
240
+ legacy: group.every((path) => isLegacyStamped(path)),
241
+ }))
242
+
243
+ return [...merged, ...alone].sort((a, b) =>
244
+ (a.paths[0] ?? '').localeCompare(b.paths[0] ?? ''),
245
+ )
246
+ }
@@ -58,7 +58,7 @@ Fix the feedback CLI so it applies the `feedback` label.
58
58
 
59
59
  ## Details
60
60
 
61
- `aitk feedback --github` opens an issue with no label, so `toolkit-triage` never lists it.
61
+ `aitk feedback --github` opens an issue with no label, so `aitk-feedback-triage` never lists it.
62
62
 
63
63
  ## Context
64
64