@nowcrew/daemon 0.6.16 → 0.6.17

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 (42) hide show
  1. package/dist/agent-ability/runtime-context.js +7 -1
  2. package/dist/atomic-private-write.js +54 -1
  3. package/dist/automatic-install-target.js +40 -11
  4. package/dist/console.js +9 -0
  5. package/dist/control-plane-url.js +2 -2
  6. package/dist/daemon-migration-controller.js +198 -0
  7. package/dist/daemon-migration-wiring.js +22 -0
  8. package/dist/daemon-update-eligibility.js +1 -1
  9. package/dist/directory-projection-identity.js +32 -0
  10. package/dist/directory-projection.js +922 -0
  11. package/dist/execution-protocol.js +78 -11
  12. package/dist/execution-runner.js +50 -2
  13. package/dist/i18n.js +1 -0
  14. package/dist/local-execution-prompt.js +57 -0
  15. package/dist/local-executor.js +99 -40
  16. package/dist/machine-info.js +45 -9
  17. package/dist/normalize.js +5 -0
  18. package/dist/profile-layout.js +41 -0
  19. package/dist/project-skills/controller.js +74 -14
  20. package/dist/project-skills/execution-adapter.js +11 -0
  21. package/dist/project-skills/initialized-reconciler.js +20 -0
  22. package/dist/project-skills/projection-set-switch.js +419 -0
  23. package/dist/project-skills/projection-state-domain.js +153 -0
  24. package/dist/project-skills/projection-state-store.js +841 -0
  25. package/dist/project-skills/projection-state-transaction.js +318 -0
  26. package/dist/project-skills/projection-state.js +3 -0
  27. package/dist/project-skills/reconciler.js +299 -68
  28. package/dist/project-skills/runtime-warning.js +6 -0
  29. package/dist/project-skills/scanner.js +30 -1
  30. package/dist/project-skills/types.js +9 -0
  31. package/dist/project-workspaces/resolver.js +179 -0
  32. package/dist/project-workspaces/types.js +1 -0
  33. package/dist/prompt.js +40 -0
  34. package/dist/runtimes/claude.js +235 -4
  35. package/dist/runtimes/codex-app-server-runner.js +92 -23
  36. package/dist/runtimes/codex-contract.js +123 -0
  37. package/dist/runtimes/codex.js +2 -0
  38. package/dist/serve.js +31 -17
  39. package/dist/session.js +3 -0
  40. package/dist/supervised-runtime.js +12 -4
  41. package/dist/workspace.js +14 -5
  42. package/package.json +1 -1
@@ -1,7 +1,12 @@
1
- import { cp, lstat, mkdir, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
1
+ import { cp, lstat, mkdir, readdir, readlink, stat, symlink, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
- import { dirname, join, resolve, sep } from "node:path";
3
+ import { dirname, join, posix, win32 } from "node:path";
4
+ import { isManagedDirectoryProjectionCopy, projectDirectory, } from "../directory-projection.js";
4
5
  import { runtimeProjectionLifetime, } from "./agent-projection-coordinator.js";
6
+ import { commitPreparedProjectSkillManifestUpdate, computeProjectSkillBindingDigest, createAppliedProjectSkillManifest, prepareAppliedProjectSkillManifestRemoval, prepareAppliedProjectSkillManifestUpdate, publishPreparedProjectSkillManifestUpdate, readAppliedProjectSkillManifest, rollbackPreparedProjectSkillManifestUpdate, } from "./projection-state.js";
7
+ import { compareProjectSkillRefs, } from "./types.js";
8
+ import { projectSkillProjectionExpectation, projectSkillResolutionRecords, } from "./scanner.js";
9
+ import { cleanupProjectionStaging, isManagedProjectionRoot, markProjectionStaging, PROJECTION_ROOT_MARKER_NAME, projectionTargets, recoverProjectionSwitch, switchProjectionSet, } from "./projection-set-switch.js";
5
10
  export class ProjectProjectionError extends Error {
6
11
  code;
7
12
  constructor(code) {
@@ -10,21 +15,72 @@ export class ProjectProjectionError extends Error {
10
15
  this.name = "ProjectProjectionError";
11
16
  }
12
17
  }
18
+ export const decideProjectSkillProjection = (expected, applied) => {
19
+ if (applied === null || expected.generation > applied.generation) {
20
+ return Object.freeze({ kind: "ensure-applied" });
21
+ }
22
+ if (expected.generation < applied.generation) {
23
+ return Object.freeze({ kind: "use-advanced", warning: "skill_projection_advanced" });
24
+ }
25
+ if (expected.bindingDigest !== applied.bindingDigest) {
26
+ return Object.freeze({ kind: "reject", code: "skill_projection_snapshot_corrupt" });
27
+ }
28
+ return expected.resolutionDigest === applied.resolutionDigest
29
+ ? Object.freeze({ kind: "use-current" })
30
+ : Object.freeze({ kind: "ensure-applied" });
31
+ };
13
32
  const exists = async (path) => lstat(path).then(() => true, () => false);
14
- const preserveNonProjectSkills = async (target, staging, trainingSkillsRoot) => {
33
+ const normalizeWindowsLinkIdentity = (value) => {
34
+ if (/^\\\\\?\\UNC\\/iu.test(value))
35
+ return `\\\\${value.slice(8)}`;
36
+ if (/^\\\\\?\\[A-Za-z]:\\/u.test(value))
37
+ return value.slice(4);
38
+ return value;
39
+ };
40
+ const isSameOrWithin = (root, candidate, path) => {
41
+ const relative = path.relative(root, candidate);
42
+ return relative === ""
43
+ || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
44
+ };
45
+ export const canonicalTrainingSkillLinkTarget = (source, link, trainingSkillsRoot, platform) => {
46
+ const path = platform === "win32" ? win32 : posix;
47
+ const normalizedSource = platform === "win32" ? normalizeWindowsLinkIdentity(source) : source;
48
+ const normalizedLink = platform === "win32" ? normalizeWindowsLinkIdentity(link) : link;
49
+ const normalizedTrainingRoot = platform === "win32"
50
+ ? normalizeWindowsLinkIdentity(trainingSkillsRoot)
51
+ : trainingSkillsRoot;
52
+ const resolvedLink = path.resolve(path.dirname(normalizedSource), normalizedLink);
53
+ return isSameOrWithin(path.normalize(normalizedTrainingRoot), resolvedLink, path)
54
+ ? path.normalize(resolvedLink)
55
+ : null;
56
+ };
57
+ export const isTrainingSkillLink = (source, link, trainingSkillsRoot, platform) => canonicalTrainingSkillLinkTarget(source, link, trainingSkillsRoot, platform) !== null;
58
+ const preserveNonProjectSkills = async (projectionTarget, trainingSkillsRoot, platform) => {
15
59
  const names = new Set();
16
- if (!await exists(target))
60
+ if (!await exists(projectionTarget.target))
17
61
  return names;
18
- for (const entry of await readdir(target, { withFileTypes: true })) {
19
- const source = join(target, entry.name);
20
- const destination = join(staging, entry.name);
62
+ const rootInfo = await lstat(projectionTarget.target);
63
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
64
+ throw new ProjectProjectionError("skill_projection_failed");
65
+ }
66
+ const managedRoot = await isManagedProjectionRoot(projectionTarget.target, projectionTarget);
67
+ for (const entry of await readdir(projectionTarget.target, { withFileTypes: true })) {
68
+ if (entry.name === PROJECTION_ROOT_MARKER_NAME) {
69
+ if (managedRoot)
70
+ continue;
71
+ throw new ProjectProjectionError("skill_projection_failed");
72
+ }
73
+ const source = join(projectionTarget.target, entry.name);
74
+ const destination = join(projectionTarget.staging, entry.name);
75
+ if (platform === "win32" && await isManagedDirectoryProjectionCopy(source, platform))
76
+ continue;
21
77
  const info = await lstat(source);
22
78
  if (info.isSymbolicLink()) {
23
79
  const link = await readlink(source);
24
- const resolvedLink = resolve(dirname(source), link);
25
- if (resolvedLink !== trainingSkillsRoot && !resolvedLink.startsWith(`${trainingSkillsRoot}${sep}`))
80
+ const canonicalTarget = canonicalTrainingSkillLinkTarget(source, link, trainingSkillsRoot, platform);
81
+ if (canonicalTarget === null)
26
82
  continue;
27
- await symlink(link, destination, "dir");
83
+ await symlink(platform === "win32" ? canonicalTarget : link, destination, platform === "win32" ? "junction" : "dir");
28
84
  }
29
85
  else {
30
86
  await cp(source, destination, { recursive: true, preserveTimestamps: true });
@@ -33,41 +89,39 @@ const preserveNonProjectSkills = async (target, staging, trainingSkillsRoot) =>
33
89
  }
34
90
  return names;
35
91
  };
36
- async function switchProjectionSet(targets) {
37
- const moved = [];
38
- try {
39
- for (const target of targets) {
40
- const hadPrevious = await exists(target.target);
41
- if (hadPrevious)
42
- await rename(target.target, target.backup);
43
- const state = { target, hadPrevious, activated: false };
44
- moved.push(state);
45
- await rename(target.staging, target.target);
46
- state.activated = true;
47
- }
48
- }
49
- catch (error) {
50
- for (const state of [...moved].reverse()) {
51
- if (state.activated)
52
- await rm(state.target.target, { recursive: true, force: true }).catch(() => { });
53
- if (state.hadPrevious)
54
- await rename(state.target.backup, state.target.target).catch(() => { });
55
- }
56
- throw error;
57
- }
58
- await Promise.all(targets.map((target) => rm(target.backup, { recursive: true, force: true })));
59
- }
60
92
  export function createProjectSkillsReconciler(deps) {
61
93
  const platform = deps.platform ?? process.platform;
62
- const reconcileUnlocked = async (handle, bindings) => {
63
- if (platform !== "darwin" && platform !== "linux") {
64
- throw new ProjectProjectionError("skill_projection_failed");
94
+ const directoryProjector = deps.projectDirectory ?? projectDirectory;
95
+ const readOptions = { platform };
96
+ const readApplied = deps.readAppliedManifest ?? readAppliedProjectSkillManifest;
97
+ const prepareRemoval = deps.prepareAppliedManifestRemoval ?? prepareAppliedProjectSkillManifestRemoval;
98
+ const prepareUpdate = deps.prepareAppliedManifestUpdate ?? prepareAppliedProjectSkillManifestUpdate;
99
+ const publishUpdate = deps.publishAppliedManifestUpdate
100
+ ?? ((update) => publishPreparedProjectSkillManifestUpdate(update, readOptions));
101
+ const commitUpdate = deps.commitAppliedManifestUpdate
102
+ ?? ((update) => commitPreparedProjectSkillManifestUpdate(update, readOptions));
103
+ const rollbackUpdate = deps.rollbackAppliedManifestUpdate
104
+ ?? ((update) => rollbackPreparedProjectSkillManifestUpdate(update, readOptions));
105
+ const manifestOperations = Object.freeze({
106
+ publish: publishUpdate,
107
+ commit: commitUpdate,
108
+ rollback: rollbackUpdate,
109
+ });
110
+ let lastProjectionDiagnostics = Object.freeze([]);
111
+ const reconcileUnlocked = async (handle, bindings, projects = deps.scannedProjects(), recover = true, manifestPublication) => {
112
+ const agentRoot = join(deps.agentsRoot, handle);
113
+ if (recover) {
114
+ try {
115
+ await recoverProjectionSwitch(agentRoot, deps.syncDirectory);
116
+ }
117
+ catch {
118
+ throw new ProjectProjectionError("skill_projection_failed");
119
+ }
65
120
  }
66
- const projects = deps.scannedProjects();
67
121
  const uniqueBindings = [...new Map(bindings.map((binding) => [
68
122
  `${binding.projectId}\0${binding.skillName}`,
69
123
  binding,
70
- ])).values()].sort((left, right) => left.projectId.localeCompare(right.projectId) || left.skillName.localeCompare(right.skillName));
124
+ ])).values()].sort(compareProjectSkillRefs);
71
125
  const names = new Map();
72
126
  for (const binding of uniqueBindings) {
73
127
  const owner = names.get(binding.skillName);
@@ -78,68 +132,245 @@ export function createProjectSkillsReconciler(deps) {
78
132
  }
79
133
  const linked = [];
80
134
  const resolutions = [];
135
+ const resolutionRecords = [];
81
136
  for (const binding of uniqueBindings) {
82
137
  const project = projects.find((candidate) => candidate.inventory.projectId === binding.projectId
83
138
  && candidate.inventory.status === "available");
84
139
  const skill = project?.resolved.find((candidate) => candidate.name === binding.skillName);
85
140
  const available = skill !== undefined && (await stat(skill.sourcePath).catch(() => null))?.isDirectory() === true;
86
141
  resolutions.push(Object.freeze({ ...binding, status: available ? "linked" : "unavailable" }));
87
- if (available && skill !== undefined)
142
+ if (available && skill !== undefined) {
88
143
  linked.push({ binding, skill });
144
+ resolutionRecords.push(Object.freeze({
145
+ ...binding,
146
+ sourcePath: skill.sourcePath,
147
+ mode: "resolved",
148
+ }));
149
+ }
150
+ else {
151
+ resolutionRecords.push(Object.freeze({ ...binding, sourcePath: null, mode: "missing" }));
152
+ }
89
153
  }
90
- const agentRoot = join(deps.agentsRoot, handle);
91
154
  const id = randomUUID();
92
- const targets = [
93
- {
94
- runtime: "codex",
95
- target: join(agentRoot, ".agents", "skills"),
96
- staging: join(agentRoot, ".agents", `.skills-next-${id}`),
97
- backup: join(agentRoot, ".agents", `.skills-previous-${id}`),
98
- },
99
- {
100
- runtime: "claude",
101
- target: join(agentRoot, ".crew", "claude-skills", ".claude", "skills"),
102
- staging: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-next-${id}`),
103
- backup: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-previous-${id}`),
104
- },
105
- ];
155
+ const targets = projectionTargets(agentRoot, id);
156
+ const nextProjectionDiagnostics = [];
106
157
  try {
107
158
  for (const target of targets) {
108
159
  await mkdir(dirname(target.target), { recursive: true });
109
160
  await mkdir(target.staging, { mode: 0o700 });
110
- const preservedNames = await preserveNonProjectSkills(target.target, target.staging, join(agentRoot, "training", "skills"));
161
+ await markProjectionStaging(target, id);
162
+ const preservedNames = await preserveNonProjectSkills(target, join(agentRoot, "training", "skills"), platform);
111
163
  for (const item of linked) {
112
164
  if (preservedNames.has(item.binding.skillName)) {
113
165
  throw new ProjectProjectionError("skill_name_conflict");
114
166
  }
115
- await symlink(item.skill.sourcePath, join(target.staging, item.binding.skillName), "dir");
167
+ const projectionTarget = join(target.staging, item.binding.skillName);
168
+ const projection = await directoryProjector(item.skill.sourcePath, projectionTarget, platform, { finalTarget: join(target.target, item.binding.skillName) });
169
+ nextProjectionDiagnostics.push(Object.freeze({
170
+ runtime: target.runtime,
171
+ projectId: item.binding.projectId,
172
+ skillName: item.binding.skillName,
173
+ mode: projection.mode,
174
+ ...(projection.copyProtection === undefined
175
+ ? {}
176
+ : { copyProtection: projection.copyProtection }),
177
+ }));
116
178
  }
117
179
  }
180
+ await writeFile(join(agentRoot, ".nowwork-root"), "", { encoding: "utf8", mode: 0o600 });
118
181
  for (const target of targets)
119
182
  await deps.beforeSwitch?.(target.runtime);
120
- await switchProjectionSet(targets);
121
- await writeFile(join(agentRoot, ".nowwork-root"), "", { encoding: "utf8", mode: 0o600 });
122
- return Object.freeze(resolutions);
183
+ const publication = await manifestPublication?.(Object.freeze(resolutionRecords));
184
+ await switchProjectionSet(agentRoot, targets, id, deps.switchFault, deps.syncDirectory, publication);
185
+ lastProjectionDiagnostics = Object.freeze(nextProjectionDiagnostics);
186
+ return Object.freeze({
187
+ resolutions: Object.freeze(resolutions),
188
+ resolutionRecords: Object.freeze(resolutionRecords),
189
+ });
123
190
  }
124
191
  catch (error) {
125
- await Promise.all(targets.map((target) => rm(target.staging, { recursive: true, force: true })));
192
+ try {
193
+ await Promise.all(targets.map((target) => cleanupProjectionStaging(target, id, deps.syncDirectory)));
194
+ }
195
+ catch {
196
+ throw new ProjectProjectionError("skill_projection_failed");
197
+ }
126
198
  if (error instanceof ProjectProjectionError)
127
199
  throw error;
128
200
  throw new ProjectProjectionError("skill_projection_failed");
129
201
  }
130
202
  };
203
+ const validateExpectation = (expected) => {
204
+ const canonical = [...expected.bindings].sort(compareProjectSkillRefs);
205
+ const isCanonical = canonical.length === expected.bindings.length
206
+ && canonical.every((binding, index) => binding.projectId === expected.bindings[index]?.projectId
207
+ && binding.skillName === expected.bindings[index]?.skillName)
208
+ && canonical.every((binding, index) => index === 0
209
+ || binding.projectId !== canonical[index - 1]?.projectId
210
+ || binding.skillName !== canonical[index - 1]?.skillName);
211
+ const digestShape = /^sha256:[a-f0-9]{64}$/u;
212
+ let bindingDigestMatches = false;
213
+ try {
214
+ bindingDigestMatches = computeProjectSkillBindingDigest(expected.bindings) === expected.bindingDigest;
215
+ }
216
+ catch {
217
+ bindingDigestMatches = false;
218
+ }
219
+ if (!isCanonical
220
+ || !Number.isInteger(expected.generation)
221
+ || expected.generation < 0
222
+ || expected.generation > 2_147_483_647
223
+ || !digestShape.test(expected.resolutionDigest)
224
+ || !bindingDigestMatches) {
225
+ throw new ProjectProjectionError("skill_projection_snapshot_corrupt");
226
+ }
227
+ };
228
+ const rejectDecision = (decision) => {
229
+ if (decision.kind === "reject")
230
+ throw new ProjectProjectionError(decision.code);
231
+ };
232
+ const ensureAppliedLocked = async (handle, expected, projectsSnapshot) => {
233
+ const agentRoot = join(deps.agentsRoot, handle);
234
+ try {
235
+ await recoverProjectionSwitch(agentRoot, deps.syncDirectory, manifestOperations);
236
+ }
237
+ catch {
238
+ throw new ProjectProjectionError("skill_projection_failed");
239
+ }
240
+ const current = await readApplied(agentRoot, readOptions);
241
+ const lockedDecision = decideProjectSkillProjection(expected, current);
242
+ rejectDecision(lockedDecision);
243
+ if (lockedDecision.kind !== "ensure-applied")
244
+ return lockedDecision;
245
+ const projects = projectsSnapshot ?? deps.scannedProjects();
246
+ const reconciled = await reconcileUnlocked(handle, expected.bindings, projects, false, async (records) => {
247
+ const applied = createAppliedProjectSkillManifest({
248
+ bindings: expected.bindings,
249
+ generation: expected.generation,
250
+ resolutions: records,
251
+ platform,
252
+ });
253
+ const lockedExpectation = Object.freeze({
254
+ bindings: expected.bindings,
255
+ generation: expected.generation,
256
+ bindingDigest: expected.bindingDigest,
257
+ resolutionDigest: applied.resolutionDigest,
258
+ });
259
+ const update = await prepareUpdate(agentRoot, applied, readOptions);
260
+ return Object.freeze({
261
+ update,
262
+ operations: manifestOperations,
263
+ verify: async () => {
264
+ const rechecked = await readApplied(agentRoot, readOptions);
265
+ const finalDecision = decideProjectSkillProjection(lockedExpectation, rechecked);
266
+ rejectDecision(finalDecision);
267
+ if (finalDecision.kind !== "use-current") {
268
+ throw new ProjectProjectionError("skill_projection_failed");
269
+ }
270
+ },
271
+ ...(deps.afterManifestPublishedBeforeCommit === undefined
272
+ ? {}
273
+ : { afterPublishedBeforeCommit: deps.afterManifestPublishedBeforeCommit }),
274
+ ...(deps.unlinkSwitchJournal === undefined
275
+ ? {}
276
+ : { unlinkJournal: deps.unlinkSwitchJournal }),
277
+ });
278
+ });
279
+ const warnings = reconciled.resolutions
280
+ .filter((resolution) => resolution.status === "unavailable")
281
+ .map((resolution) => Object.freeze({
282
+ projectId: resolution.projectId,
283
+ skillName: resolution.skillName,
284
+ code: "project_skill_unavailable",
285
+ }));
286
+ return warnings.length === 0
287
+ ? Object.freeze({ kind: "ensure-applied" })
288
+ : Object.freeze({ kind: "ensure-applied", warnings: Object.freeze(warnings) });
289
+ };
290
+ const ensureApplied = async (handle, expected) => {
291
+ validateExpectation(expected);
292
+ return deps.coordinator.runExclusive(deps.agentsRoot, handle, () => ensureAppliedLocked(handle, expected));
293
+ };
294
+ const ensureSnapshot = async (handle, snapshot) => deps.coordinator.runExclusive(deps.agentsRoot, handle, async () => {
295
+ const projects = deps.scannedProjects();
296
+ let expected;
297
+ try {
298
+ expected = projectSkillProjectionExpectation(snapshot, projects, platform);
299
+ validateExpectation(expected);
300
+ }
301
+ catch (error) {
302
+ if (error instanceof ProjectProjectionError)
303
+ throw error;
304
+ throw new ProjectProjectionError("skill_projection_snapshot_corrupt");
305
+ }
306
+ const decision = await ensureAppliedLocked(handle, expected, projects);
307
+ if (decision.kind !== "use-current")
308
+ return decision;
309
+ const warnings = projectSkillResolutionRecords(snapshot.bindings, projects, platform)
310
+ .filter((record) => record.mode === "missing")
311
+ .map((record) => Object.freeze({
312
+ projectId: record.projectId,
313
+ skillName: record.skillName,
314
+ code: "project_skill_unavailable",
315
+ }));
316
+ return warnings.length === 0
317
+ ? decision
318
+ : Object.freeze({ kind: "use-current", warnings: Object.freeze(warnings) });
319
+ });
320
+ const reconcileLegacyUnlocked = async (handle, bindings) => {
321
+ const agentRoot = join(deps.agentsRoot, handle);
322
+ try {
323
+ await recoverProjectionSwitch(agentRoot, deps.syncDirectory, manifestOperations);
324
+ }
325
+ catch {
326
+ throw new ProjectProjectionError("skill_projection_failed");
327
+ }
328
+ return reconcileUnlocked(handle, bindings, deps.scannedProjects(), false, async () => {
329
+ const update = await prepareRemoval(agentRoot, readOptions);
330
+ return Object.freeze({
331
+ update,
332
+ operations: manifestOperations,
333
+ verify: async () => {
334
+ if (await readApplied(agentRoot, readOptions) !== null) {
335
+ throw new ProjectProjectionError("skill_projection_failed");
336
+ }
337
+ },
338
+ ...(deps.afterManifestPublishedBeforeCommit === undefined
339
+ ? {}
340
+ : { afterPublishedBeforeCommit: deps.afterManifestPublishedBeforeCommit }),
341
+ ...(deps.unlinkSwitchJournal === undefined
342
+ ? {}
343
+ : { unlinkJournal: deps.unlinkSwitchJournal }),
344
+ });
345
+ });
346
+ };
131
347
  return {
132
348
  reconcile(handle, bindings) {
133
- return deps.coordinator.runExclusive(deps.agentsRoot, handle, () => reconcileUnlocked(handle, bindings));
349
+ return deps.coordinator.runExclusive(deps.agentsRoot, handle, async () => (await reconcileLegacyUnlocked(handle, bindings)).resolutions);
134
350
  },
135
- prepareAndLaunch(agentsRoot, handle, bindings, launch) {
351
+ ensureSnapshot,
352
+ ensureApplied,
353
+ projectionDiagnostics: () => lastProjectionDiagnostics,
354
+ async prepareAndLaunch(agentsRoot, handle, projection, launch, onWarning) {
136
355
  if (agentsRoot !== deps.agentsRoot) {
137
- return Promise.reject(new ProjectProjectionError("skill_projection_failed"));
356
+ throw new ProjectProjectionError("skill_projection_failed");
357
+ }
358
+ if (Array.isArray(projection)) {
359
+ return deps.coordinator.runExclusiveUntil(deps.agentsRoot, handle, async () => {
360
+ await reconcileLegacyUnlocked(handle, projection);
361
+ return launch();
362
+ }, runtimeProjectionLifetime);
363
+ }
364
+ const decision = "resolutionDigest" in projection
365
+ ? await ensureApplied(handle, projection)
366
+ : await ensureSnapshot(handle, projection);
367
+ if (decision.kind === "use-advanced")
368
+ onWarning?.({ code: decision.warning });
369
+ if ("warnings" in decision) {
370
+ for (const warning of decision.warnings ?? [])
371
+ onWarning?.(warning);
138
372
  }
139
- return deps.coordinator.runExclusiveUntil(deps.agentsRoot, handle, async () => {
140
- await reconcileUnlocked(handle, bindings);
141
- return launch();
142
- }, runtimeProjectionLifetime);
373
+ return launch();
143
374
  },
144
375
  };
145
376
  }
@@ -0,0 +1,6 @@
1
+ /** Stable, path-free console text; only logical IDs from the server snapshot are included. */
2
+ export function formatProjectSkillRuntimeWarning(warning) {
3
+ return warning.code === "skill_projection_advanced"
4
+ ? "[runtime-warning] skill_projection_advanced"
5
+ : `[runtime-warning] project_skill_unavailable project=${warning.projectId} skill=${warning.skillName}`;
6
+ }
@@ -1,7 +1,8 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { isProjectSkillName, MAX_MACHINE_PROJECT_SKILLS, MAX_PROJECT_SKILL_DESCRIPTION_LENGTH, MAX_PROJECT_SKILL_FILE_BYTES, MAX_PROJECT_SKILLS_PER_PROJECT, } from "./types.js";
3
+ import { compareProjectSkillRefs, isProjectSkillName, MAX_MACHINE_PROJECT_SKILLS, MAX_PROJECT_SKILL_DESCRIPTION_LENGTH, MAX_PROJECT_SKILL_FILE_BYTES, MAX_PROJECT_SKILLS_PER_PROJECT, } from "./types.js";
4
4
  import { parseSkillFrontmatter } from "../skill-frontmatter.js";
5
+ import { computeProjectSkillBindingDigest, computeProjectSkillResolutionDigest, normalizeProjectSkillResolutionRecords, } from "./projection-state.js";
5
6
  const unavailable = (projectId, scannedAt, errorCode) => Object.freeze({
6
7
  inventory: Object.freeze({
7
8
  projectId,
@@ -130,3 +131,31 @@ export async function scanProjects(projects, now = () => new Date(), retainedPro
130
131
  const sorted = [...projects].sort((left, right) => left.projectId.localeCompare(right.projectId));
131
132
  return boundScannedProjects(await Promise.all(sorted.map((project) => scanProject(project, now))), retainedProjectIds);
132
133
  }
134
+ /** V2-only source identity mapper; existing v1 inventory and resolved scan shapes remain unchanged. */
135
+ export function projectSkillResolutionRecords(bindings, projects, platform = process.platform) {
136
+ const records = [...bindings].sort(compareProjectSkillRefs).map((binding) => {
137
+ const project = projects.find((candidate) => candidate.inventory.projectId === binding.projectId
138
+ && candidate.inventory.status === "available");
139
+ const skill = project?.resolved.find((candidate) => candidate.name === binding.skillName);
140
+ return skill === undefined
141
+ ? Object.freeze({ ...binding, sourcePath: null, mode: "missing" })
142
+ : Object.freeze({ ...binding, sourcePath: skill.sourcePath, mode: "resolved" });
143
+ });
144
+ return normalizeProjectSkillResolutionRecords(records, platform);
145
+ }
146
+ /** Daemon-local expectation derived from one immutable scanner snapshot; no path crosses the wire. */
147
+ export function projectSkillProjectionExpectation(snapshot, projects, platform = process.platform) {
148
+ const bindings = Object.freeze(snapshot.bindings
149
+ .map((binding) => Object.freeze({
150
+ projectId: binding.projectId,
151
+ skillName: binding.skillName,
152
+ }))
153
+ .sort(compareProjectSkillRefs));
154
+ const resolutions = projectSkillResolutionRecords(bindings, projects, platform);
155
+ return Object.freeze({
156
+ bindings,
157
+ generation: snapshot.generation,
158
+ bindingDigest: computeProjectSkillBindingDigest(bindings),
159
+ resolutionDigest: computeProjectSkillResolutionDigest(resolutions, platform),
160
+ });
161
+ }
@@ -11,3 +11,12 @@ const PROJECT_ID = /^[a-z0-9][a-z0-9._-]*$/u;
11
11
  const SKILL_NAME = /^[a-z0-9][a-z0-9._:-]*$/u;
12
12
  export const isProjectId = (value) => value.length > 0 && value.length <= MAX_PROJECT_ID_LENGTH && PROJECT_ID.test(value);
13
13
  export const isProjectSkillName = (value) => value.length > 0 && value.length <= MAX_PROJECT_SKILL_NAME_LENGTH && SKILL_NAME.test(value);
14
+ const compareBinary = (left, right) => {
15
+ if (left < right)
16
+ return -1;
17
+ if (left > right)
18
+ return 1;
19
+ return 0;
20
+ };
21
+ export const compareProjectSkillRefs = (left, right) => compareBinary(left.projectId, right.projectId)
22
+ || compareBinary(left.skillName, right.skillName);