@ikon85/agent-workflow-kit 0.44.1 → 0.45.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 (67) hide show
  1. package/.agents/skills/audit-skills/SKILL.md +7 -4
  2. package/.agents/skills/code-review/SKILL.md +7 -4
  3. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  4. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  5. package/.agents/skills/improve-codebase-architecture/SKILL.md +7 -4
  6. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  7. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  8. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  9. package/.agents/skills/research/SKILL.md +7 -4
  10. package/.agents/skills/to-issues/SKILL.md +25 -4
  11. package/.claude/skills/audit-skills/SKILL.md +7 -4
  12. package/.claude/skills/code-review/SKILL.md +7 -4
  13. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  14. package/.claude/skills/codex-build/SKILL.md +13 -0
  15. package/.claude/skills/codex-review/SKILL.md +13 -0
  16. package/.claude/skills/grill-me-codex/SKILL.md +14 -0
  17. package/.claude/skills/grill-with-docs-codex/SKILL.md +14 -0
  18. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  19. package/.claude/skills/improve-codebase-architecture/SKILL.md +7 -4
  20. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  21. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  22. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  23. package/.claude/skills/research/SKILL.md +7 -4
  24. package/.claude/skills/skill-manifest.json +34 -23
  25. package/.claude/skills/to-issues/SKILL.md +25 -4
  26. package/README.md +71 -0
  27. package/agent-workflow-kit.package.json +149 -45
  28. package/package.json +1 -1
  29. package/scripts/doctrine-migration/index.mjs +296 -0
  30. package/scripts/kit-release.mjs +41 -9
  31. package/src/cli.mjs +521 -80
  32. package/src/commands/routing-status.mjs +288 -0
  33. package/src/commands/update.mjs +9 -1
  34. package/src/consumer-migrations.json +23 -1
  35. package/src/lib/bundle.mjs +158 -2
  36. package/src/lib/consumerMigrations.mjs +55 -0
  37. package/src/lib/dispatchJournal.mjs +300 -0
  38. package/src/lib/dispatchPlan.mjs +286 -0
  39. package/src/lib/dispatchReceipt.mjs +226 -89
  40. package/src/lib/frontendWorkloads.mjs +35 -33
  41. package/src/lib/routeDispatcher.mjs +367 -70
  42. package/src/lib/routingAccessGraph.mjs +265 -20
  43. package/src/lib/routingAccessGraphStore.mjs +300 -0
  44. package/src/lib/routingAdapters/claude.mjs +104 -4
  45. package/src/lib/routingAdapters/codex.mjs +132 -7
  46. package/src/lib/routingAdapters/hostBridge.mjs +291 -0
  47. package/src/lib/routingCatalog.mjs +201 -24
  48. package/src/lib/routingDispatchLease.mjs +253 -0
  49. package/src/lib/routingEvidenceCache.mjs +78 -0
  50. package/src/lib/routingIntent.mjs +176 -10
  51. package/src/lib/routingIntentClassifier.mjs +137 -0
  52. package/src/lib/routingInventory/snapshots/claude.json +49 -0
  53. package/src/lib/routingInventory/snapshots/codex.json +109 -0
  54. package/src/lib/routingInventory.mjs +182 -0
  55. package/src/lib/routingPolicy.mjs +193 -6
  56. package/src/lib/routingProfile.mjs +1251 -123
  57. package/src/lib/routingProfilePolicy.mjs +156 -0
  58. package/src/lib/routingProfileStorage.mjs +299 -0
  59. package/src/lib/routingResolver.mjs +369 -86
  60. package/src/lib/routingSources/artificialAnalysis.mjs +19 -7
  61. package/src/lib/routingSources/benchlm.mjs +5 -0
  62. package/src/lib/routingSources/codeArena.mjs +13 -3
  63. package/src/lib/routingSources/deepswe.mjs +19 -5
  64. package/src/lib/routingSources/openhands.mjs +17 -4
  65. package/src/lib/routingSources/openhandsFrontend.mjs +13 -3
  66. package/src/lib/safeText.mjs +26 -0
  67. package/src/lib/updateCandidate.mjs +36 -3
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Deriving the Routing policy from a composed Routing profile.
3
+ *
4
+ * A Routing profile is the stored personal choice and carries no revision; the
5
+ * Routing policy is the revisioned constraint object derived from it for one
6
+ * dispatch. Derivation is a pure function: the same composed profile, the same
7
+ * two document generations and the same inventory revision always produce the
8
+ * same policy and the same revision, so a Dispatch receipt can name one
9
+ * revision and a later re-derivation can prove the constraints did not move.
10
+ *
11
+ * The revision is a canonical hash over exactly three facts — the global
12
+ * generation, the project generation, and the inventory revision. Those three
13
+ * are what a composed authorization is a function of: every profile change
14
+ * commits a new immutable generation, and every roster reconcile names an
15
+ * inventory revision. Hashing the composed values themselves would add nothing
16
+ * and would stop the revision being a token a store can compare.
17
+ *
18
+ * Nothing here consults the Access graph, the Evidence catalog, the resolver or
19
+ * a dispatcher. Composition and derivation answer "what is authorized";
20
+ * executability is decided at decision time.
21
+ */
22
+ import { createHash } from 'node:crypto';
23
+
24
+ import { STANDARD_ROUTE_CLASSES } from './routingProfile.mjs';
25
+ import { ROUTING_POLICY_VERSION, validateRoutingPolicy } from './routingPolicy.mjs';
26
+
27
+ /** The revision inputs — those three, nothing else. */
28
+ export const ROUTING_POLICY_REVISION_INPUTS = Object.freeze([
29
+ 'globalGeneration',
30
+ 'projectGeneration',
31
+ 'inventoryRevision',
32
+ ]);
33
+
34
+ /** The fail-closed fallbacks a profile does not carry and a caller may override. */
35
+ const DEFAULT_UNREACHABLE = 'block';
36
+ const DEFAULT_MISSING_INFRASTRUCTURE = 'block';
37
+
38
+ const COMPOSED_FIELDS = ['selectedSurfaces', 'authorizedTransports', 'switching', 'roster',
39
+ 'standardRoutes'];
40
+
41
+ function object(value, field) {
42
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
43
+ throw new TypeError(`${field} must be an object`);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ function assertFields(input, allowed, label) {
49
+ for (const key of Object.keys(input)) {
50
+ if (!allowed.has(key)) throw new TypeError(`unknown ${label} field: ${key}`);
51
+ }
52
+ }
53
+
54
+ /** Key order is fixed, so the same three facts always hash to the same digest. */
55
+ function canonical(value) {
56
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
57
+ if (value && typeof value === 'object') {
58
+ return `{${Object.keys(value).sort()
59
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
60
+ }
61
+ return JSON.stringify(value ?? null);
62
+ }
63
+
64
+ const sha256 = (text) => `sha256-${createHash('sha256').update(text).digest('base64url')}`;
65
+
66
+ function generation(value, field, { required }) {
67
+ if (value === null || value === undefined) {
68
+ if (required) throw new TypeError(`${field} must be a non-negative integer`);
69
+ return null;
70
+ }
71
+ if (!Number.isInteger(value) || value < 0) {
72
+ throw new TypeError(`${field} must be a non-negative integer`);
73
+ }
74
+ return value;
75
+ }
76
+
77
+ /**
78
+ * The policy revision: a canonical hash over exactly the global generation, the
79
+ * project generation and the inventory revision. A policy without a committed
80
+ * global authorization cannot exist, so the global generation is required; a
81
+ * project without a narrowing is a normal state and contributes `null`.
82
+ */
83
+ export function routingPolicyRevision(input) {
84
+ object(input, 'routing policy revision input');
85
+ assertFields(input, new Set(ROUTING_POLICY_REVISION_INPUTS), 'routing policy revision input');
86
+ const inventoryRevision = input.inventoryRevision ?? null;
87
+ if (inventoryRevision !== null
88
+ && (typeof inventoryRevision !== 'string' || inventoryRevision.trim() === '')) {
89
+ throw new TypeError('inventoryRevision must be a non-empty string or null');
90
+ }
91
+ return sha256(canonical({
92
+ globalGeneration: generation(input.globalGeneration, 'globalGeneration', { required: true }),
93
+ projectGeneration: generation(input.projectGeneration, 'projectGeneration', { required: false }),
94
+ inventoryRevision,
95
+ }));
96
+ }
97
+
98
+ /** Project a composed profile onto the policy's axes, without reinterpreting it. */
99
+ function authorizationOf(composed) {
100
+ object(composed, 'composed routing profile');
101
+ for (const field of COMPOSED_FIELDS) {
102
+ if (composed[field] === undefined) {
103
+ throw new TypeError(`composed routing profile is missing ${field}`);
104
+ }
105
+ }
106
+ if (!Array.isArray(composed.authorizedTransports)) {
107
+ throw new TypeError('composed routing profile authorizedTransports must be an array');
108
+ }
109
+ return {
110
+ allowedSurfaces: [...composed.selectedSurfaces],
111
+ // The resolver matches a path's transport id, so the policy carries ids.
112
+ allowedTransports: composed.authorizedTransports.map(({ transport }) => transport),
113
+ roster: composed.roster.map(({ model, effort }) => ({ model, effort })),
114
+ standardRoutes: Object.fromEntries(
115
+ STANDARD_ROUTE_CLASSES.map((cls) => [cls, composed.standardRoutes?.[cls] ?? null]),
116
+ ),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Derive the Routing policy for one dispatch. The inventory revision defaults to
122
+ * the one the composition actually reconciled the roster against, so the
123
+ * revision names the inventory that produced the effective roster rather than a
124
+ * separately supplied one. `unreachable` and `missingInfrastructure` are not
125
+ * profile fields; they default fail-closed and every explicit value is
126
+ * validated. A composed profile that authorizes nothing throws with the named
127
+ * reason instead of yielding an empty policy.
128
+ */
129
+ export function deriveRoutingPolicy({
130
+ composed,
131
+ globalGeneration,
132
+ projectGeneration = null,
133
+ inventoryRevision,
134
+ unreachable = DEFAULT_UNREACHABLE,
135
+ missingInfrastructure = DEFAULT_MISSING_INFRASTRUCTURE,
136
+ }) {
137
+ const authorization = authorizationOf(composed);
138
+ const reconciled = inventoryRevision === undefined
139
+ ? (composed.rosterState?.inventoryRevision ?? composed.inventoryRevision ?? null)
140
+ : inventoryRevision;
141
+ return validateRoutingPolicy({
142
+ schemaVersion: ROUTING_POLICY_VERSION,
143
+ revision: routingPolicyRevision({
144
+ globalGeneration,
145
+ projectGeneration,
146
+ inventoryRevision: reconciled,
147
+ }),
148
+ allowedSurfaces: authorization.allowedSurfaces,
149
+ allowedTransports: authorization.allowedTransports,
150
+ switching: composed.switching,
151
+ roster: authorization.roster,
152
+ standardRoutes: authorization.standardRoutes,
153
+ unreachable,
154
+ missingInfrastructure,
155
+ });
156
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The two-level Routing profile store: one immutable generation per document,
3
+ * global and project independent. The generation id lives in the storage
4
+ * envelope *around* a document, never in the Routing profile schema — a profile
5
+ * carries no revision. Composition takes the latest committed global generation
6
+ * plus this project's own narrowing, so a global choice made after a narrowing
7
+ * is never invisible to that project; the generation a narrowing was authored
8
+ * against travels with it for diagnostics, never as the read key.
9
+ *
10
+ * A transaction descriptor is written only when one interview changes both
11
+ * documents, and exists solely so recovery can discard a half-written pair: a
12
+ * generation the descriptor names is not committed, reads skip it, recovery
13
+ * deletes it — leaving the last committed pair.
14
+ *
15
+ * The project key is a deliberately opaque identity: a UUID in a marker
16
+ * file inside the git common directory, so every worktree of one repository
17
+ * narrows once and the marker stays outside `git status` and outside the
18
+ * consumer's project layer. A fresh clone is a project without a narrowing yet.
19
+ * Without git the canonical project-root path keys it at lower confidence,
20
+ * because a path does not survive a move.
21
+ */
22
+ import { execFile } from 'node:child_process';
23
+ import { createHash, randomUUID } from 'node:crypto';
24
+ import { access, mkdir, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises';
25
+ import { dirname, join, resolve } from 'node:path';
26
+ import { promisify } from 'node:util';
27
+
28
+ import { writeAtomic } from './atomicWrite.mjs';
29
+
30
+ export const ROUTING_PROFILE_ENVELOPE_VERSION = 1;
31
+
32
+ /** User-local routing evidence: owner-only, like every other routing document. */
33
+ const STORE_MODE = 0o600;
34
+ const LOCK_TIMEOUT_MS = 2000;
35
+ const LOCK_POLL_MS = 10;
36
+ const GENERATION_FILE = /^generation-(\d+)\.json$/;
37
+ const PENDING_FILE = 'pending-transaction.json';
38
+ const MARKER_SEGMENTS = ['agent-workflow-kit', 'project-id'];
39
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
40
+
41
+ const execFileAsync = promisify(execFile);
42
+ const sleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
43
+ const exists = (path) => access(path).then(() => true, () => false);
44
+ /** Missing is a state, not a failure; anything else stays an error. */
45
+ const readTextOrNull = (file) => readFile(file, 'utf8').catch((error) => {
46
+ if (error.code === 'ENOENT') return null;
47
+ throw error;
48
+ });
49
+
50
+ /** Git is evidence, not a dependency: an unavailable git yields no common dir. */
51
+ const defaultRunGit = (args, cwd) => execFileAsync('git', args, { cwd, encoding: 'utf8' })
52
+ .then(({ stdout }) => stdout, () => null);
53
+
54
+ /** Where one generation of one document lives. Immutable once written. */
55
+ export function routingProfileGenerationPath({ root, scope, projectKey = null, generation }) {
56
+ if (scope !== 'global' && !projectKey) throw new TypeError('a project generation needs a key');
57
+ const dir = scope === 'global' ? join(root, 'global') : join(root, 'projects', projectKey);
58
+ return join(dir, `generation-${generation}.json`);
59
+ }
60
+
61
+ const writeJson = (file, value) =>
62
+ writeAtomic(file, `${JSON.stringify(value, null, 2)}\n`, STORE_MODE);
63
+
64
+ async function readJson(file, label) {
65
+ const raw = await readTextOrNull(file);
66
+ if (raw === null) return null;
67
+ try {
68
+ return JSON.parse(raw);
69
+ } catch {
70
+ throw new Error(`${label} is not valid JSON: ${file}`);
71
+ }
72
+ }
73
+
74
+ function validateEnvelope(value, { scope, generation, file }) {
75
+ const object = (candidate) => Boolean(candidate) && typeof candidate === 'object'
76
+ && !Array.isArray(candidate);
77
+ if (!object(value) || value.envelopeVersion !== ROUTING_PROFILE_ENVELOPE_VERSION
78
+ || value.scope !== scope || value.generation !== generation) {
79
+ throw new Error(`routing profile envelope does not match its generation file: ${file}`);
80
+ }
81
+ if (!object(value.document)) {
82
+ throw new Error(`routing profile envelope carries no document: ${file}`);
83
+ }
84
+ return Object.freeze({ ...value, file });
85
+ }
86
+
87
+ async function listGenerations(root, scope, projectKey) {
88
+ const dir = dirname(routingProfileGenerationPath({ root, scope, projectKey, generation: 0 }));
89
+ const names = await readdir(dir).catch((error) => {
90
+ if (error.code === 'ENOENT') return [];
91
+ throw error;
92
+ });
93
+ return names.map((name) => GENERATION_FILE.exec(name)).filter(Boolean)
94
+ .map((match) => Number(match[1])).sort((a, b) => a - b);
95
+ }
96
+
97
+ /** Generations a pending descriptor names are not committed. */
98
+ function pendingGenerations(pending, scope, projectKey) {
99
+ const entries = Array.isArray(pending?.entries) ? pending.entries : [];
100
+ return new Set(entries
101
+ .filter((entry) => entry.scope === scope
102
+ && (scope === 'global' || entry.projectKey === projectKey))
103
+ .map((entry) => entry.generation));
104
+ }
105
+
106
+ async function readLatestCommitted(root, scope, projectKey, pending) {
107
+ const uncommitted = pendingGenerations(pending, scope, projectKey);
108
+ const generation = (await listGenerations(root, scope, projectKey))
109
+ .filter((candidate) => !uncommitted.has(candidate)).at(-1);
110
+ if (generation === undefined) return null;
111
+ const file = routingProfileGenerationPath({ root, scope, projectKey, generation });
112
+ const document = await readJson(file, 'routing profile envelope');
113
+ return validateEnvelope(document, { scope, generation, file });
114
+ }
115
+
116
+ /**
117
+ * The committed pair: the latest committed global generation plus this project's
118
+ * own narrowing. No project document is a normal, safe state — a fresh clone
119
+ * simply falls back to the global authorization.
120
+ */
121
+ export async function readCommittedRoutingProfilePair({ root, projectKey = null }) {
122
+ const pending = await readJson(join(root, PENDING_FILE), 'routing profile transaction');
123
+ return Object.freeze({
124
+ global: await readLatestCommitted(root, 'global', null, pending),
125
+ project: projectKey ? await readLatestCommitted(root, 'project', projectKey, pending) : null,
126
+ pendingTransactionId: pending?.id ?? null,
127
+ });
128
+ }
129
+
130
+ async function withStorageLock(root, lockTimeoutMs, run) {
131
+ await mkdir(root, { recursive: true });
132
+ const lock = join(root, '.lock');
133
+ const deadline = Date.now() + lockTimeoutMs;
134
+ for (;;) {
135
+ try { await mkdir(lock); break; } catch (error) {
136
+ if (error.code !== 'EEXIST') throw error;
137
+ if (Date.now() >= deadline) throw new Error(`routing profile store is locked: ${lock}`);
138
+ await sleep(LOCK_POLL_MS);
139
+ }
140
+ }
141
+ try {
142
+ return await run();
143
+ } finally {
144
+ await rmdir(lock).catch(() => {});
145
+ }
146
+ }
147
+
148
+ async function discardPendingTransaction(root) {
149
+ const pending = await readJson(join(root, PENDING_FILE), 'routing profile transaction');
150
+ if (!pending) return Object.freeze({ transactionId: null, discarded: [] });
151
+ const discarded = [];
152
+ for (const entry of pending.entries ?? []) {
153
+ const file = routingProfileGenerationPath({
154
+ root, scope: entry.scope, projectKey: entry.projectKey ?? null, generation: entry.generation,
155
+ });
156
+ if (await exists(file)) {
157
+ await rm(file, { recursive: true, force: true });
158
+ discarded.push(file);
159
+ }
160
+ }
161
+ await rm(join(root, PENDING_FILE), { force: true });
162
+ return Object.freeze({ transactionId: pending.id ?? null, discarded });
163
+ }
164
+
165
+ /** Discard a half-written pair: the store reads back as the last committed pair. */
166
+ export async function recoverRoutingProfileStorage({ root, lockTimeoutMs = LOCK_TIMEOUT_MS }) {
167
+ return withStorageLock(root, lockTimeoutMs, () => discardPendingTransaction(root));
168
+ }
169
+
170
+ /**
171
+ * Never rewrite a generation: the counter only moves forward, and a caller that
172
+ * states the generation it read is rejected once the store moved on.
173
+ */
174
+ function nextGeneration(current, document, expected) {
175
+ const committed = current?.generation ?? null;
176
+ if (expected !== undefined && expected !== committed) {
177
+ throw new Error(`stale routing profile generation: expected ${expected ?? 'none'}, `
178
+ + `found ${committed ?? 'none'}`);
179
+ }
180
+ return document ? (committed ?? 0) + 1 : null;
181
+ }
182
+
183
+ const writeGeneration = (root, envelope) => writeJson(routingProfileGenerationPath({
184
+ root, scope: envelope.scope, projectKey: envelope.projectKey ?? null,
185
+ generation: envelope.generation,
186
+ }), envelope);
187
+
188
+ async function commitUnderLock({
189
+ root, identity, globalDocument, projectDocument,
190
+ expectedGlobalGeneration, expectedProjectGeneration, now,
191
+ }) {
192
+ const projectKey = identity?.key ?? null;
193
+ await discardPendingTransaction(root);
194
+ const current = await readCommittedRoutingProfilePair({ root, projectKey });
195
+ const global = nextGeneration(current.global, globalDocument, expectedGlobalGeneration);
196
+ const project = nextGeneration(current.project, projectDocument, expectedProjectGeneration);
197
+ const committedAt = now();
198
+ const paired = Boolean(globalDocument && projectDocument);
199
+ const transactionId = paired ? randomUUID() : null;
200
+ if (paired) {
201
+ await writeJson(join(root, PENDING_FILE), {
202
+ id: transactionId,
203
+ startedAt: committedAt,
204
+ entries: [
205
+ { scope: 'global', projectKey: null, generation: global },
206
+ { scope: 'project', projectKey, generation: project },
207
+ ],
208
+ });
209
+ }
210
+ if (globalDocument) {
211
+ await writeGeneration(root, {
212
+ envelopeVersion: ROUTING_PROFILE_ENVELOPE_VERSION, scope: 'global',
213
+ generation: global, committedAt, document: globalDocument,
214
+ });
215
+ }
216
+ if (projectDocument) {
217
+ await writeGeneration(root, {
218
+ envelopeVersion: ROUTING_PROFILE_ENVELOPE_VERSION, scope: 'project',
219
+ generation: project, committedAt, projectKey,
220
+ identity: { source: identity.source, confidence: identity.confidence },
221
+ authoredAgainstGlobalGeneration: global ?? current.global?.generation ?? null,
222
+ document: projectDocument,
223
+ });
224
+ }
225
+ if (paired) await rm(join(root, PENDING_FILE), { force: true });
226
+ return Object.freeze({ transactionId, globalGeneration: global, projectGeneration: project });
227
+ }
228
+
229
+ /**
230
+ * Commit a global authorization, a project narrowing, or both. Both together are
231
+ * one transaction: the descriptor names the generations being written and only
232
+ * its removal commits them.
233
+ */
234
+ export async function commitRoutingProfileGenerations(options) {
235
+ const { root, identity = null, globalDocument = null, projectDocument = null,
236
+ now = () => new Date().toISOString(), lockTimeoutMs = LOCK_TIMEOUT_MS } = options;
237
+ if (!globalDocument && !projectDocument) {
238
+ throw new TypeError('a routing profile commit must carry a global or a project document');
239
+ }
240
+ if (projectDocument && !identity?.key) {
241
+ throw new TypeError('a project narrowing needs a resolved project identity');
242
+ }
243
+ return withStorageLock(root, lockTimeoutMs, () => commitUnderLock({
244
+ ...options, identity, globalDocument, projectDocument, now,
245
+ }));
246
+ }
247
+
248
+ async function readMarker(markerPath) {
249
+ const raw = await readTextOrNull(markerPath);
250
+ if (raw === null) return null;
251
+ const value = raw.trim();
252
+ if (!UUID.test(value)) {
253
+ throw new Error(`routing project identity marker is unreadable: ${markerPath}`);
254
+ }
255
+ return value;
256
+ }
257
+
258
+ /** Write the marker once. A concurrent writer that got there first wins. */
259
+ async function createMarker(markerPath) {
260
+ await mkdir(dirname(markerPath), { recursive: true });
261
+ const id = randomUUID();
262
+ try {
263
+ await writeFile(markerPath, `${id}\n`, { encoding: 'utf8', flag: 'wx', mode: STORE_MODE });
264
+ return id;
265
+ } catch (error) {
266
+ if (error.code !== 'EEXIST') throw error;
267
+ return readMarker(markerPath);
268
+ }
269
+ }
270
+
271
+ function pathIdentity(projectRoot) {
272
+ const digest = createHash('sha256').update(projectRoot).digest('hex').slice(0, 20);
273
+ return Object.freeze({
274
+ key: `path-${digest}`, value: projectRoot,
275
+ source: 'project-path', confidence: 'lower', markerPath: null,
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Resolve the project identity that keys the narrowing: the UUID marker in the
281
+ * git common directory, shared by every worktree of one repository, or the
282
+ * canonical project-root path at lower confidence outside git.
283
+ */
284
+ export async function resolveProjectIdentity({ projectRoot, runGit = defaultRunGit }) {
285
+ if (typeof projectRoot !== 'string' || projectRoot.trim() === '') {
286
+ throw new TypeError('a project identity needs a project root');
287
+ }
288
+ const root = resolve(projectRoot);
289
+ const stdout = await runGit(['rev-parse', '--git-common-dir'], root);
290
+ const commonDir = typeof stdout === 'string' && stdout.trim()
291
+ ? resolve(root, stdout.trim())
292
+ : null;
293
+ if (!commonDir) return pathIdentity(root);
294
+ const markerPath = join(commonDir, ...MARKER_SEGMENTS);
295
+ const key = (await readMarker(markerPath)) ?? (await createMarker(markerPath));
296
+ return Object.freeze({
297
+ key, value: key, source: 'git-marker', confidence: 'stable', markerPath,
298
+ });
299
+ }