@phyzess/pappra 0.0.0-stage → 0.1.0-beta.10

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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/integrations/skills/pappra/SKILL.md +71 -0
  5. package/assets/integrations/skills/pappra/references/hosts.md +40 -0
  6. package/assets/integrations/skills/pappra/references/lifecycle.md +41 -0
  7. package/assets/integrations/skills/pappra/references/operations.md +27 -0
  8. package/assets/manifest.json +4 -0
  9. package/dist/app/collaboration.d.ts +170 -0
  10. package/dist/app/collaboration.js +1643 -0
  11. package/dist/app/doctor.d.ts +9 -0
  12. package/dist/app/doctor.js +1030 -0
  13. package/dist/app/init.d.ts +171 -0
  14. package/dist/app/init.js +1442 -0
  15. package/dist/cli/commands/code.d.ts +16 -0
  16. package/dist/cli/commands/code.js +127 -0
  17. package/dist/cli/commands/collaboration.d.ts +13 -0
  18. package/dist/cli/commands/collaboration.js +186 -0
  19. package/dist/cli/commands/continuity.d.ts +29 -0
  20. package/dist/cli/commands/continuity.js +133 -0
  21. package/dist/cli/commands/docs.d.ts +12 -0
  22. package/dist/cli/commands/docs.js +234 -0
  23. package/dist/cli/commands/doctor.d.ts +14 -0
  24. package/dist/cli/commands/doctor.js +57 -0
  25. package/dist/cli/commands/host.d.ts +17 -0
  26. package/dist/cli/commands/host.js +199 -0
  27. package/dist/cli/commands/init.d.ts +59 -0
  28. package/dist/cli/commands/init.js +845 -0
  29. package/dist/cli/commands/runtime.d.ts +14 -0
  30. package/dist/cli/commands/runtime.js +263 -0
  31. package/dist/cli/main.d.ts +2 -0
  32. package/dist/cli/main.js +10 -0
  33. package/dist/cli/output.d.ts +4 -0
  34. package/dist/cli/output.js +1 -0
  35. package/dist/cli/run.d.ts +2 -0
  36. package/dist/cli/run.js +749 -0
  37. package/dist/code/budget.d.ts +14 -0
  38. package/dist/code/budget.js +39 -0
  39. package/dist/code/exec.d.ts +23 -0
  40. package/dist/code/exec.js +133 -0
  41. package/dist/code/identity.d.ts +13 -0
  42. package/dist/code/identity.js +214 -0
  43. package/dist/code/index.d.ts +6 -0
  44. package/dist/code/index.js +3 -0
  45. package/dist/code/providers/codegraph.d.ts +59 -0
  46. package/dist/code/providers/codegraph.js +567 -0
  47. package/dist/code/registration.d.ts +9 -0
  48. package/dist/code/registration.js +293 -0
  49. package/dist/code/service.d.ts +53 -0
  50. package/dist/code/service.js +633 -0
  51. package/dist/code/text.d.ts +3 -0
  52. package/dist/code/text.js +202 -0
  53. package/dist/collab/admission.d.ts +450 -0
  54. package/dist/collab/admission.js +797 -0
  55. package/dist/collab/canonical.d.ts +26 -0
  56. package/dist/collab/canonical.js +136 -0
  57. package/dist/collab/contracts.d.ts +73 -0
  58. package/dist/collab/contracts.js +76 -0
  59. package/dist/collab/envelope.d.ts +52 -0
  60. package/dist/collab/envelope.js +241 -0
  61. package/dist/collab/fingerprint.d.ts +7 -0
  62. package/dist/collab/fingerprint.js +58 -0
  63. package/dist/collab/handoff.d.ts +350 -0
  64. package/dist/collab/handoff.js +513 -0
  65. package/dist/collab/index.d.ts +9 -0
  66. package/dist/collab/index.js +8 -0
  67. package/dist/collab/lifecycle.d.ts +449 -0
  68. package/dist/collab/lifecycle.js +534 -0
  69. package/dist/collab/messages.d.ts +148 -0
  70. package/dist/collab/messages.js +583 -0
  71. package/dist/collab/notify.d.ts +47 -0
  72. package/dist/collab/notify.js +140 -0
  73. package/dist/collab/preflight.d.ts +31 -0
  74. package/dist/collab/preflight.js +271 -0
  75. package/dist/collab/resources.d.ts +119 -0
  76. package/dist/collab/resources.js +451 -0
  77. package/dist/collab/store.d.ts +143 -0
  78. package/dist/collab/store.js +606 -0
  79. package/dist/collab/types.d.ts +354 -0
  80. package/dist/collab/types.js +1 -0
  81. package/dist/collab/validation.d.ts +10 -0
  82. package/dist/collab/validation.js +303 -0
  83. package/dist/continuity/context.d.ts +24 -0
  84. package/dist/continuity/context.js +179 -0
  85. package/dist/continuity/index.d.ts +3 -0
  86. package/dist/continuity/index.js +1 -0
  87. package/dist/continuity/paths.d.ts +37 -0
  88. package/dist/continuity/paths.js +258 -0
  89. package/dist/continuity/service.d.ts +59 -0
  90. package/dist/continuity/service.js +1258 -0
  91. package/dist/hosts/claude/native.d.ts +79 -0
  92. package/dist/hosts/claude/native.js +853 -0
  93. package/dist/hosts/codex/native.d.ts +345 -0
  94. package/dist/hosts/codex/native.js +1528 -0
  95. package/dist/hosts/codex/protocol.d.ts +51 -0
  96. package/dist/hosts/codex/protocol.js +42 -0
  97. package/dist/hosts/detect.d.ts +29 -0
  98. package/dist/hosts/detect.js +73 -0
  99. package/dist/hosts/index.d.ts +10 -0
  100. package/dist/hosts/index.js +10 -0
  101. package/dist/hosts/integration/bundle-export.d.ts +24 -0
  102. package/dist/hosts/integration/bundle-export.js +128 -0
  103. package/dist/hosts/integration/bundle.d.ts +19 -0
  104. package/dist/hosts/integration/bundle.js +99 -0
  105. package/dist/hosts/integration/hook-entry.d.ts +1 -0
  106. package/dist/hosts/integration/hook-entry.js +68 -0
  107. package/dist/hosts/integration/hook.d.ts +32 -0
  108. package/dist/hosts/integration/hook.js +781 -0
  109. package/dist/hosts/integration/pappra-extension-runtime.mjs +24610 -0
  110. package/dist/hosts/integration/pappra-hook.mjs +24728 -0
  111. package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
  112. package/dist/hosts/integration/pi-extension-runtime.js +89 -0
  113. package/dist/hosts/integration/pi-template.d.ts +3 -0
  114. package/dist/hosts/integration/pi-template.js +5 -0
  115. package/dist/hosts/integration/service.d.ts +49 -0
  116. package/dist/hosts/integration/service.js +1421 -0
  117. package/dist/hosts/integration/support.d.ts +59 -0
  118. package/dist/hosts/integration/support.js +66 -0
  119. package/dist/hosts/integration/templates.d.ts +22 -0
  120. package/dist/hosts/integration/templates.js +75 -0
  121. package/dist/hosts/pi/protocol.d.ts +22 -0
  122. package/dist/hosts/pi/protocol.js +28 -0
  123. package/dist/hosts/pi/query-result.d.ts +22 -0
  124. package/dist/hosts/pi/query-result.js +710 -0
  125. package/dist/hosts/pi/rpc.d.ts +939 -0
  126. package/dist/hosts/pi/rpc.js +1659 -0
  127. package/dist/hosts/pilot.d.ts +485 -0
  128. package/dist/hosts/pilot.js +593 -0
  129. package/dist/index.d.ts +5 -0
  130. package/dist/index.js +4 -0
  131. package/dist/infra/fs.d.ts +27 -0
  132. package/dist/infra/fs.js +130 -0
  133. package/dist/infra/git.d.ts +60 -0
  134. package/dist/infra/git.js +358 -0
  135. package/dist/infra/owned-process-group.d.ts +173 -0
  136. package/dist/infra/owned-process-group.js +571 -0
  137. package/dist/install/apply.d.ts +65 -0
  138. package/dist/install/apply.js +663 -0
  139. package/dist/install/index.d.ts +5 -0
  140. package/dist/install/index.js +5 -0
  141. package/dist/install/ownership.d.ts +85 -0
  142. package/dist/install/ownership.js +430 -0
  143. package/dist/install/plan.d.ts +39 -0
  144. package/dist/install/plan.js +97 -0
  145. package/dist/install/rollback.d.ts +18 -0
  146. package/dist/install/rollback.js +544 -0
  147. package/dist/install/runtime.d.ts +76 -0
  148. package/dist/install/runtime.js +1252 -0
  149. package/dist/knowledge/catalog.d.ts +47 -0
  150. package/dist/knowledge/catalog.js +308 -0
  151. package/dist/knowledge/context.d.ts +28 -0
  152. package/dist/knowledge/context.js +61 -0
  153. package/dist/knowledge/index.d.ts +3 -0
  154. package/dist/knowledge/index.js +1 -0
  155. package/dist/knowledge/journal.d.ts +16 -0
  156. package/dist/knowledge/journal.js +109 -0
  157. package/dist/knowledge/lifecycle.d.ts +8 -0
  158. package/dist/knowledge/lifecycle.js +1001 -0
  159. package/dist/knowledge/paths.d.ts +37 -0
  160. package/dist/knowledge/paths.js +236 -0
  161. package/dist/knowledge/refs.d.ts +36 -0
  162. package/dist/knowledge/refs.js +267 -0
  163. package/dist/knowledge/service.d.ts +21 -0
  164. package/dist/knowledge/service.js +393 -0
  165. package/dist/knowledge/types.d.ts +171 -0
  166. package/dist/knowledge/types.js +1 -0
  167. package/dist/knowledge/write.d.ts +8 -0
  168. package/dist/knowledge/write.js +1223 -0
  169. package/dist/package-info.d.ts +8 -0
  170. package/dist/package-info.js +25 -0
  171. package/dist/schemas/code.d.ts +377 -0
  172. package/dist/schemas/code.js +145 -0
  173. package/dist/schemas/collaboration.d.ts +115 -0
  174. package/dist/schemas/collaboration.js +106 -0
  175. package/dist/schemas/continuity.d.ts +315 -0
  176. package/dist/schemas/continuity.js +199 -0
  177. package/dist/schemas/diagnostics.d.ts +28 -0
  178. package/dist/schemas/diagnostics.js +27 -0
  179. package/dist/schemas/doctor.d.ts +157 -0
  180. package/dist/schemas/doctor.js +75 -0
  181. package/dist/schemas/document.d.ts +644 -0
  182. package/dist/schemas/document.js +296 -0
  183. package/dist/schemas/errors.d.ts +177 -0
  184. package/dist/schemas/errors.js +176 -0
  185. package/dist/schemas/evidence.d.ts +24 -0
  186. package/dist/schemas/evidence.js +16 -0
  187. package/dist/schemas/host.d.ts +436 -0
  188. package/dist/schemas/host.js +203 -0
  189. package/dist/schemas/index.d.ts +15 -0
  190. package/dist/schemas/index.js +15 -0
  191. package/dist/schemas/init.d.ts +121 -0
  192. package/dist/schemas/init.js +94 -0
  193. package/dist/schemas/install.d.ts +227 -0
  194. package/dist/schemas/install.js +147 -0
  195. package/dist/schemas/machine.d.ts +43 -0
  196. package/dist/schemas/machine.js +96 -0
  197. package/dist/schemas/outcome.d.ts +23 -0
  198. package/dist/schemas/outcome.js +17 -0
  199. package/dist/schemas/paths.d.ts +19 -0
  200. package/dist/schemas/paths.js +63 -0
  201. package/dist/schemas/workspace.d.ts +120 -0
  202. package/dist/schemas/workspace.js +201 -0
  203. package/dist/workspace/config-file.d.ts +36 -0
  204. package/dist/workspace/config-file.js +237 -0
  205. package/dist/workspace/config.d.ts +28 -0
  206. package/dist/workspace/config.js +79 -0
  207. package/dist/workspace/discover.d.ts +34 -0
  208. package/dist/workspace/discover.js +195 -0
  209. package/dist/workspace/documents.d.ts +21 -0
  210. package/dist/workspace/documents.js +395 -0
  211. package/dist/workspace/ignore.d.ts +10 -0
  212. package/dist/workspace/ignore.js +48 -0
  213. package/dist/workspace/index.d.ts +7 -0
  214. package/dist/workspace/index.js +3 -0
  215. package/dist/workspace/repositories.d.ts +24 -0
  216. package/dist/workspace/repositories.js +411 -0
  217. package/dist/workspace/resolve.d.ts +24 -0
  218. package/dist/workspace/resolve.js +268 -0
  219. package/dist/workspace/types.d.ts +71 -0
  220. package/dist/workspace/types.js +1 -0
  221. package/dist/workspace/validation.d.ts +18 -0
  222. package/dist/workspace/validation.js +197 -0
  223. package/package.json +111 -4
  224. package/skills/pappra/SKILL.md +71 -0
  225. package/skills/pappra/references/hosts.md +40 -0
  226. package/skills/pappra/references/lifecycle.md +41 -0
  227. package/skills/pappra/references/operations.md +27 -0
@@ -0,0 +1,1442 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { mkdir, open, readFile, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
4
+ import { createDiagnostic, hasErrors, pointer, } from '../schemas/diagnostics.js';
5
+ import { diagnosticCodes } from '../schemas/errors.js';
6
+ import { INIT_CHOICES_SCHEMA, initPlanSchema, parseInitChoices, } from '../schemas/init.js';
7
+ import { machineConfigSchema } from '../schemas/machine.js';
8
+ import { isExternalRepositoryPath, isPathInside, isPortableRelativePath, normalizePortableRelativePath, resolvePortableRelativePath, samePath, } from '../schemas/paths.js';
9
+ import { WORKSPACE_SCHEMA, validateWorkspaceConfigSemantics, workspaceConfigSchema, } from '../schemas/workspace.js';
10
+ import { getPackageInfo } from '../package-info.js';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { executableOnPath } from '../hosts/detect.js';
13
+ import { HOST_SUPPORT } from '../hosts/integration/support.js';
14
+ import { observePath, resolveRealpath, sha256Digest } from '../infra/fs.js';
15
+ import { inspectMachineConfig, inspectWorkspaceConfig, } from '../workspace/config.js';
16
+ import { discoverWorkspace, } from '../workspace/discover.js';
17
+ import { resolveWorkspaceRoot } from '../workspace/resolve.js';
18
+ import { buildInitPlan } from '../install/plan.js';
19
+ import { backupPath, ensureStatePathBoundary, readWorkspaceManifest, writeWorkspaceManifest, } from '../install/ownership.js';
20
+ import { beginManagedApply, completeManagedApply, failManagedApply, recordManagedFile, } from '../install/apply.js';
21
+ const workspaceConfigRelativePath = '.pappra/workspace.json';
22
+ const machineConfigRelativePath = '.pappra/local/machine.json';
23
+ function failure(status, code, message, diagnostics = [], nextAction = null) {
24
+ return { ok: false, status, code, message, diagnostics, nextAction };
25
+ }
26
+ function failureFromDiagnostics(status, diagnostics, fallbackCode, message) {
27
+ const first = diagnostics.find((entry) => entry.severity === 'error') ?? diagnostics[0];
28
+ return failure(status, first?.code ?? fallbackCode, first?.message ?? message, diagnostics, first?.hint ?? null);
29
+ }
30
+ function info(code, message, source, hint = null) {
31
+ return createDiagnostic({
32
+ code,
33
+ severity: 'info',
34
+ message,
35
+ source,
36
+ hint,
37
+ });
38
+ }
39
+ function jsonBytes(value) {
40
+ return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
41
+ }
42
+ function dedupeDiagnostics(diagnostics) {
43
+ const seen = new Set();
44
+ const unique = [];
45
+ for (const entry of diagnostics) {
46
+ const key = [
47
+ entry.code,
48
+ entry.severity,
49
+ entry.source,
50
+ entry.pointer ?? '',
51
+ entry.message,
52
+ ].join('\0');
53
+ if (seen.has(key))
54
+ continue;
55
+ seen.add(key);
56
+ unique.push(entry);
57
+ }
58
+ return unique;
59
+ }
60
+ function sanitizeIdentifier(value) {
61
+ const normalized = value
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9._-]+/g, '-')
64
+ .replace(/^[^a-z0-9]+/, '')
65
+ .replace(/[-.]+$/g, '')
66
+ .slice(0, 50);
67
+ return normalized === '' ? 'repo' : normalized;
68
+ }
69
+ function uniqueIdentifier(base, used) {
70
+ let candidate = base;
71
+ let index = 2;
72
+ while (used.has(candidate)) {
73
+ candidate = `${base}-${index}`;
74
+ index += 1;
75
+ }
76
+ used.add(candidate);
77
+ return candidate;
78
+ }
79
+ function repositoryKey(repository) {
80
+ if (repository.configuredPath !== null) {
81
+ return isExternalRepositoryPath(repository.configuredPath)
82
+ ? repository.configuredPath
83
+ : normalizePortableRelativePath(repository.configuredPath);
84
+ }
85
+ return repository.relativePath === null
86
+ ? repository.root
87
+ : normalizePortableRelativePath(repository.relativePath);
88
+ }
89
+ function candidateKey(value) {
90
+ return isExternalRepositoryPath(value)
91
+ ? value
92
+ : normalizePortableRelativePath(value);
93
+ }
94
+ /** Resolves the explicit root and refuses to create a hidden nested workspace. */
95
+ export async function resolveInitRoot(rootPath) {
96
+ const requestedRoot = resolve(rootPath);
97
+ const resolution = await resolveWorkspaceRoot({ startPath: requestedRoot });
98
+ if (resolution.state === 'unavailable' || resolution.state === 'incomplete') {
99
+ return failureFromDiagnostics('unavailable', resolution.diagnostics, diagnosticCodes.workspaceStartInvalid, `Cannot initialize from ${requestedRoot}.`);
100
+ }
101
+ if (resolution.root !== null) {
102
+ const diagnostics = [...resolution.diagnostics];
103
+ if (!samePath(resolution.root, requestedRoot)) {
104
+ diagnostics.push(info(diagnosticCodes.initRegisteredRootUsed, `Using the nearest registered workspace root: ${resolution.root}`, resolution.root, 'Run init from the workspace root or select a different directory; Pappra does not create a nested workspace implicitly.'));
105
+ }
106
+ return {
107
+ ok: true,
108
+ value: {
109
+ requestedRoot,
110
+ root: resolution.root,
111
+ registered: true,
112
+ diagnostics,
113
+ },
114
+ };
115
+ }
116
+ if (resolution.startDirectory === null) {
117
+ return failure('unavailable', diagnosticCodes.workspaceStartInvalid, `Cannot resolve initialization root ${requestedRoot}.`, resolution.diagnostics, 'Pass an existing readable directory.');
118
+ }
119
+ return {
120
+ ok: true,
121
+ value: {
122
+ requestedRoot,
123
+ root: resolution.startDirectory,
124
+ registered: false,
125
+ diagnostics: resolution.diagnostics,
126
+ },
127
+ };
128
+ }
129
+ async function observeRootIdentity(root) {
130
+ const observation = await observePath(root);
131
+ return observation.kind === 'directory' && observation.stats !== null
132
+ ? { dev: observation.stats.dev, ino: observation.stats.ino }
133
+ : null;
134
+ }
135
+ function sameRootIdentity(left, right) {
136
+ return left.dev === right.dev && left.ino === right.ino;
137
+ }
138
+ /**
139
+ * Loads the discovery inputs and rejects changed or invalid existing state.
140
+ * This function is read-only.
141
+ */
142
+ export async function inspectInitContext(rootPath) {
143
+ const rootResult = await resolveInitRoot(rootPath);
144
+ if (!rootResult.ok)
145
+ return rootResult;
146
+ const root = rootResult.value.root;
147
+ const rootIdentity = await observeRootIdentity(root);
148
+ if (rootIdentity === null) {
149
+ return failure('unavailable', diagnosticCodes.workspaceRootInvalid, `Workspace root is no longer a directory: ${root}`, rootResult.value.diagnostics, 'Retry init from an existing workspace root; no plan was produced.');
150
+ }
151
+ const workspaceInspection = await inspectWorkspaceConfig(root, {
152
+ missing: 'info',
153
+ });
154
+ const machineInspection = await inspectMachineConfig(root);
155
+ const initialDiagnostics = [
156
+ ...workspaceInspection.diagnostics,
157
+ ...machineInspection.diagnostics,
158
+ ];
159
+ if (workspaceInspection.exists && workspaceInspection.value === null) {
160
+ return failureFromDiagnostics('invalid', initialDiagnostics, diagnosticCodes.configSchemaInvalid, 'Existing workspace configuration is invalid.');
161
+ }
162
+ if (machineInspection.exists && machineInspection.value === null) {
163
+ return failureFromDiagnostics('invalid', initialDiagnostics, diagnosticCodes.configSchemaInvalid, 'Existing machine configuration is invalid.');
164
+ }
165
+ const discovery = await discoverWorkspace(root);
166
+ const finalWorkspace = await inspectWorkspaceConfig(root, {
167
+ missing: 'info',
168
+ });
169
+ const finalMachine = await inspectMachineConfig(root);
170
+ const finalRootIdentity = await observeRootIdentity(root);
171
+ const changedDuringDiscovery = workspaceInspection.digest !== finalWorkspace.digest ||
172
+ workspaceInspection.exists !== finalWorkspace.exists ||
173
+ machineInspection.digest !== finalMachine.digest ||
174
+ machineInspection.exists !== finalMachine.exists;
175
+ const finalDiagnostics = [...initialDiagnostics, ...discovery.diagnostics];
176
+ if (finalRootIdentity === null ||
177
+ !sameRootIdentity(rootIdentity, finalRootIdentity)) {
178
+ return failure('conflict', diagnosticCodes.initPlanStale, 'Workspace root directory was replaced while discovery was running.', finalDiagnostics, 'Retry init from the current workspace root; no plan was produced.');
179
+ }
180
+ if (changedDuringDiscovery) {
181
+ return failure('conflict', diagnosticCodes.initPlanStale, 'Workspace inputs changed while discovery was running.', finalDiagnostics, 'Retry init; no plan was produced.');
182
+ }
183
+ if ((workspaceInspection.value === null && discovery.configState === 'valid') ||
184
+ (workspaceInspection.value !== null && discovery.configState !== 'valid') ||
185
+ (machineInspection.value === null && discovery.machineState === 'valid') ||
186
+ (machineInspection.value !== null && discovery.machineState !== 'valid')) {
187
+ return failure('conflict', diagnosticCodes.initPlanStale, 'Workspace configuration changed while discovery was running.', finalDiagnostics, 'Retry init; no plan was produced.');
188
+ }
189
+ return {
190
+ ok: true,
191
+ context: {
192
+ requestedRoot: rootResult.value.requestedRoot,
193
+ root,
194
+ registered: rootResult.value.registered,
195
+ rootIdentity,
196
+ workspaceInspection,
197
+ machineInspection,
198
+ discovery,
199
+ diagnostics: dedupeDiagnostics([
200
+ ...rootResult.value.diagnostics,
201
+ ...finalDiagnostics,
202
+ ]),
203
+ },
204
+ };
205
+ }
206
+ /** Lists discovered repository scope choices for a UI. */
207
+ export function listRepositoryCandidates(discovery) {
208
+ return discovery.repositories.map((repository) => ({
209
+ key: repositoryKey(repository),
210
+ id: repository.id,
211
+ external: repository.external,
212
+ access: repository.access,
213
+ root: repository.root,
214
+ kind: repository.kind,
215
+ dirty: repository.git.dirty,
216
+ configured: repository.origin === 'workspace-config',
217
+ }));
218
+ }
219
+ /** Lists knowledge source choices derived only from current discovery. */
220
+ export function listKnowledgeCandidates(discovery) {
221
+ const candidates = new Map();
222
+ for (const document of discovery.documents) {
223
+ if (document.workspaceRelativePath === null)
224
+ continue;
225
+ candidates.set(document.workspaceRelativePath, {
226
+ path: document.workspaceRelativePath,
227
+ kind: document.kind,
228
+ origin: document.origin,
229
+ repositoryId: document.repositoryId,
230
+ });
231
+ const segments = document.workspaceRelativePath.split('/');
232
+ for (let index = 1; index < segments.length; index += 1) {
233
+ const path = segments.slice(0, index).join('/');
234
+ if (path.startsWith('.'))
235
+ continue;
236
+ candidates.set(path, {
237
+ path,
238
+ kind: 'directory',
239
+ origin: document.origin,
240
+ repositoryId: document.repositoryId,
241
+ });
242
+ }
243
+ }
244
+ return [...candidates.values()].sort((left, right) => left.path.localeCompare(right.path));
245
+ }
246
+ const derivedExcludes = [
247
+ 'node_modules',
248
+ 'dist',
249
+ 'coverage',
250
+ 'tmp',
251
+ 'test-results',
252
+ ];
253
+ const derivedValidationScripts = ['lint', 'typecheck', 'test'];
254
+ function detectedHostVersion(command, pathEnv) {
255
+ const executable = executableOnPath(command, pathEnv);
256
+ if (executable === null)
257
+ return null;
258
+ const probe = spawnSync(executable, ['--version'], {
259
+ encoding: 'utf8',
260
+ shell: false,
261
+ timeout: 3_000,
262
+ maxBuffer: 256 * 1024,
263
+ });
264
+ if (probe.error !== undefined || probe.status !== 0)
265
+ return null;
266
+ const output = `${probe.stdout ?? ''}\n${probe.stderr ?? ''}`;
267
+ const match = /(\d+\.\d+\.\d+)/.exec(output);
268
+ return match?.[1] ?? null;
269
+ }
270
+ /**
271
+ * Derives deterministic init selections from discovery alone. Every value is
272
+ * reported with the rule that produced it so a caller can audit the choice
273
+ * before applying it; nothing here writes, installs or prompts.
274
+ */
275
+ export async function deriveInitChoices(discovery, options = {}) {
276
+ const pathEnv = options.pathEnv ?? process.env['PATH'] ?? '';
277
+ const rules = [];
278
+ const repositories = discovery.repositories.map((repository) => {
279
+ const path = repositoryKey(repository);
280
+ const access = samePath(repository.root, discovery.root)
281
+ ? 'write'
282
+ : 'read';
283
+ rules.push({
284
+ field: 'repositories',
285
+ value: path,
286
+ reason: access === 'write'
287
+ ? 'workspace-root checkout is selected for write access'
288
+ : 'discovered checkout outside the workspace root is read-only',
289
+ });
290
+ return { path, access };
291
+ });
292
+ const knowledgeCandidates = listKnowledgeCandidates(discovery);
293
+ const rootRuleFiles = ['AGENTS.md', 'CLAUDE.md'];
294
+ const knowledgeSources = [];
295
+ for (const name of rootRuleFiles) {
296
+ const candidate = knowledgeCandidates.find((entry) => entry.path.toLowerCase() === name.toLowerCase());
297
+ if (candidate === undefined)
298
+ continue;
299
+ knowledgeSources.push(candidate.path);
300
+ rules.push({
301
+ field: 'knowledge.sources',
302
+ value: candidate.path,
303
+ reason: 'workspace-root rule file reported by discovery',
304
+ });
305
+ }
306
+ const docs = knowledgeCandidates.find((entry) => entry.path === 'docs');
307
+ if (docs !== undefined) {
308
+ knowledgeSources.push(docs.path);
309
+ rules.push({
310
+ field: 'knowledge.sources',
311
+ value: docs.path,
312
+ reason: 'documentation directory reported by discovery',
313
+ });
314
+ }
315
+ const codegraphDetected = executableOnPath('codegraph', pathEnv) !== null &&
316
+ (await Promise.all(discovery.repositories.map(async (repository) => observePath(join(repository.root, '.codegraph'))))).some((observation) => observation.kind !== 'missing');
317
+ const graph = codegraphDetected ? 'codegraph' : 'none';
318
+ rules.push({
319
+ field: 'code.graph',
320
+ value: graph,
321
+ reason: codegraphDetected
322
+ ? 'codegraph executable and .codegraph index were both observed'
323
+ : 'no codegraph executable plus .codegraph index pair was observed',
324
+ });
325
+ rules.push({
326
+ field: 'code.symbols',
327
+ value: 'auto',
328
+ reason: 'symbol providers are detected passively at use time',
329
+ });
330
+ rules.push({
331
+ field: 'code.prepare',
332
+ value: 'on-demand',
333
+ reason: 'index preparation stays an explicit, bounded action',
334
+ });
335
+ const validation = [];
336
+ for (const script of derivedValidationScripts) {
337
+ const candidate = discovery.validationCandidates.find((entry) => entry.id.endsWith(`:${script}`) && !validation.includes(entry.id));
338
+ if (candidate === undefined)
339
+ continue;
340
+ validation.push(candidate.id);
341
+ rules.push({
342
+ field: 'validation',
343
+ value: candidate.id,
344
+ reason: `package script "${script}" reported by discovery`,
345
+ });
346
+ }
347
+ rules.push({
348
+ field: 'excludes',
349
+ value: derivedExcludes.join(', '),
350
+ reason: 'built-in defaults keep generated and dependency trees out of scope',
351
+ });
352
+ const hosts = [];
353
+ for (const host of ['claude', 'codex', 'pi']) {
354
+ const command = host;
355
+ const version = detectedHostVersion(command, pathEnv);
356
+ if (version === null || version !== HOST_SUPPORT[host].version) {
357
+ rules.push({
358
+ field: 'hosts',
359
+ value: host,
360
+ reason: version === null
361
+ ? 'host executable was not observed on PATH'
362
+ : `observed version ${version} is outside the pinned support matrix`,
363
+ });
364
+ continue;
365
+ }
366
+ hosts.push(host);
367
+ rules.push({
368
+ field: 'hosts',
369
+ value: host,
370
+ reason: `host executable reports the pinned version ${version}`,
371
+ });
372
+ }
373
+ const name = basename(discovery.root);
374
+ rules.push({
375
+ field: 'name',
376
+ value: name,
377
+ reason: 'workspace name derived from the workspace root directory',
378
+ });
379
+ const choices = {
380
+ schema: INIT_CHOICES_SCHEMA,
381
+ name,
382
+ repositories,
383
+ knowledge: {
384
+ sources: knowledgeSources,
385
+ newDocuments: '.pappra/knowledge',
386
+ taskVisibility: 'private',
387
+ },
388
+ code: { graph, symbols: 'auto', prepare: 'on-demand' },
389
+ validation,
390
+ excludes: [...derivedExcludes],
391
+ hosts,
392
+ };
393
+ return {
394
+ choices,
395
+ rules,
396
+ candidates: {
397
+ repositories: listRepositoryCandidates(discovery).map((candidate) => candidate.key),
398
+ knowledge: knowledgeCandidates.map((candidate) => candidate.path),
399
+ validation: discovery.validationCandidates.map((candidate) => candidate.id),
400
+ hosts,
401
+ },
402
+ };
403
+ }
404
+ function selectRepositories(choices, discovery, root) {
405
+ const diagnostics = [];
406
+ const byKey = new Map();
407
+ for (const repository of discovery.repositories) {
408
+ byKey.set(repositoryKey(repository), repository);
409
+ if (repository.relativePath !== null) {
410
+ byKey.set(normalizePortableRelativePath(repository.relativePath), repository);
411
+ }
412
+ }
413
+ const usedIds = new Set();
414
+ const selected = [];
415
+ const seenRepositories = new Set();
416
+ for (const [index, choice] of choices.repositories.entries()) {
417
+ const key = candidateKey(choice.path);
418
+ const repository = byKey.get(key);
419
+ if (repository === undefined) {
420
+ diagnostics.push(createDiagnostic({
421
+ code: diagnosticCodes.initRepositoryUnknown,
422
+ severity: 'error',
423
+ message: `Init selection references an undiscovered repository: ${choice.path}`,
424
+ source: 'init choices',
425
+ pointer: pointer('repositories', index, 'path'),
426
+ hint: 'Select a repository path reported by discovery; Pappra does not invent repository scopes.',
427
+ }));
428
+ continue;
429
+ }
430
+ if (seenRepositories.has(repository.root)) {
431
+ diagnostics.push(createDiagnostic({
432
+ code: diagnosticCodes.initRepositoryDuplicate,
433
+ severity: 'error',
434
+ message: `Repository selection repeats ${repository.root}.`,
435
+ source: 'init choices',
436
+ pointer: pointer('repositories', index, 'path'),
437
+ hint: 'Keep one selection per discovered checkout.',
438
+ }));
439
+ continue;
440
+ }
441
+ seenRepositories.add(repository.root);
442
+ const external = repository.external;
443
+ const derivedBase = repository.id ??
444
+ (repository.relativePath === null || repository.relativePath === '.'
445
+ ? basename(root)
446
+ : basename(repository.relativePath));
447
+ const id = choice.id
448
+ ? choice.id
449
+ : uniqueIdentifier(sanitizeIdentifier(derivedBase), usedIds);
450
+ if (choice.id !== undefined) {
451
+ if (usedIds.has(choice.id)) {
452
+ diagnostics.push(createDiagnostic({
453
+ code: diagnosticCodes.initRepositoryDuplicate,
454
+ severity: 'error',
455
+ message: `Repository id "${choice.id}" is selected more than once.`,
456
+ source: 'init choices',
457
+ pointer: pointer('repositories', index, 'id'),
458
+ hint: 'Use unique repository ids.',
459
+ }));
460
+ continue;
461
+ }
462
+ usedIds.add(choice.id);
463
+ }
464
+ const configPath = external
465
+ ? repository.configuredPath
466
+ : (repository.relativePath ?? repository.configuredPath ?? '.');
467
+ if (configPath === null) {
468
+ diagnostics.push(createDiagnostic({
469
+ code: diagnosticCodes.initRepositoryUnknown,
470
+ severity: 'error',
471
+ message: `Repository ${repository.root} has no portable configuration path.`,
472
+ source: 'init choices',
473
+ pointer: pointer('repositories', index, 'path'),
474
+ hint: 'Declare the checkout explicitly before initialization.',
475
+ }));
476
+ continue;
477
+ }
478
+ selected.push({
479
+ repository,
480
+ id,
481
+ path: configPath,
482
+ access: choice.access,
483
+ originalId: repository.id,
484
+ });
485
+ }
486
+ return { selected, diagnostics };
487
+ }
488
+ function selectKnowledgeSources(choices, discovery) {
489
+ const allowed = new Map(listKnowledgeCandidates(discovery).map((candidate) => [
490
+ candidateKey(candidate.path),
491
+ candidate.path,
492
+ ]));
493
+ const diagnostics = [];
494
+ const sources = [];
495
+ const seen = new Set();
496
+ for (const [index, source] of choices.knowledge.sources.entries()) {
497
+ const key = candidateKey(source);
498
+ const canonical = allowed.get(key);
499
+ if (canonical === undefined) {
500
+ diagnostics.push(createDiagnostic({
501
+ code: diagnosticCodes.initKnowledgeUnknown,
502
+ severity: 'error',
503
+ message: `Init selection references an undiscovered knowledge source: ${source}`,
504
+ source: 'init choices',
505
+ pointer: pointer('knowledge', 'sources', index),
506
+ hint: 'Select a rule or document reported by discovery; Pappra does not copy or create documentation.',
507
+ }));
508
+ continue;
509
+ }
510
+ if (!seen.has(canonical)) {
511
+ seen.add(canonical);
512
+ sources.push(canonical);
513
+ }
514
+ }
515
+ return { sources, diagnostics };
516
+ }
517
+ function findSelectedRepositoryForCandidate(candidate, selected) {
518
+ for (const repository of selected) {
519
+ if (candidate.repositoryId !== null &&
520
+ (candidate.repositoryId === repository.originalId ||
521
+ candidate.repositoryId === repository.id)) {
522
+ return repository;
523
+ }
524
+ if (candidate.workingDirectory !== null &&
525
+ samePath(candidate.workingDirectory, repository.repository.root)) {
526
+ return repository;
527
+ }
528
+ }
529
+ return null;
530
+ }
531
+ function selectValidationEntries(choices, discovery, selected) {
532
+ const byId = new Map(discovery.validationCandidates.map((candidate) => [
533
+ candidate.id,
534
+ candidate,
535
+ ]));
536
+ const diagnostics = [];
537
+ const validation = [];
538
+ const usedIds = new Set();
539
+ const seen = new Set();
540
+ for (const [index, candidateId] of choices.validation.entries()) {
541
+ if (seen.has(candidateId))
542
+ continue;
543
+ seen.add(candidateId);
544
+ const candidate = byId.get(candidateId);
545
+ if (candidate === undefined) {
546
+ diagnostics.push(createDiagnostic({
547
+ code: diagnosticCodes.initValidationUnknown,
548
+ severity: 'error',
549
+ message: `Init selection references an undiscovered validation candidate: ${candidateId}`,
550
+ source: 'init choices',
551
+ pointer: pointer('validation', index),
552
+ hint: 'Select candidates reported by discovery; discovery does not prove that a command is runnable.',
553
+ }));
554
+ continue;
555
+ }
556
+ const repository = findSelectedRepositoryForCandidate(candidate, selected);
557
+ if (repository === null) {
558
+ diagnostics.push(createDiagnostic({
559
+ code: diagnosticCodes.initValidationUnbound,
560
+ severity: 'warning',
561
+ message: `Validation candidate "${candidateId}" is not bound to a selected repository.`,
562
+ source: 'init choices',
563
+ pointer: pointer('validation', index),
564
+ hint: 'Select its repository or omit this candidate; unbound commands are not written to workspace config.',
565
+ }));
566
+ continue;
567
+ }
568
+ const base = sanitizeIdentifier(candidate.id.replace(/^(?:workspace-config|package-script):/, ''));
569
+ validation.push({
570
+ id: uniqueIdentifier(base, usedIds),
571
+ repositoryId: repository.id,
572
+ executable: candidate.executable,
573
+ args: [...candidate.args],
574
+ });
575
+ }
576
+ return { validation, diagnostics };
577
+ }
578
+ function buildWorkspaceConfig(root, choices, discovery, runtimeVersion) {
579
+ const diagnostics = [];
580
+ const repositorySelection = selectRepositories(choices, discovery, root);
581
+ diagnostics.push(...repositorySelection.diagnostics);
582
+ const knowledgeSelection = selectKnowledgeSources(choices, discovery);
583
+ diagnostics.push(...knowledgeSelection.diagnostics);
584
+ const validationSelection = selectValidationEntries(choices, discovery, repositorySelection.selected);
585
+ diagnostics.push(...validationSelection.diagnostics);
586
+ if (hasErrors(diagnostics)) {
587
+ return {
588
+ config: null,
589
+ selected: repositorySelection.selected,
590
+ diagnostics,
591
+ };
592
+ }
593
+ const name = choices.name?.trim() ?? '';
594
+ const config = {
595
+ schema: WORKSPACE_SCHEMA,
596
+ workspaceId: choices.workspaceId ?? randomUUID(),
597
+ name: name === '' ? basename(root) || 'workspace' : name,
598
+ runtimeVersion,
599
+ repositories: repositorySelection.selected.map((repository) => ({
600
+ id: repository.id,
601
+ path: repository.path,
602
+ access: repository.access,
603
+ })),
604
+ knowledge: {
605
+ sources: [...knowledgeSelection.sources],
606
+ newDocuments: choices.knowledge.newDocuments,
607
+ taskVisibility: choices.knowledge.taskVisibility,
608
+ },
609
+ code: choices.code,
610
+ validation: [...validationSelection.validation],
611
+ excludes: [...new Set(choices.excludes)],
612
+ extensions: {},
613
+ };
614
+ const parsed = workspaceConfigSchema.safeParse(config);
615
+ if (!parsed.success) {
616
+ for (const issue of parsed.error.issues) {
617
+ diagnostics.push(createDiagnostic({
618
+ code: diagnosticCodes.initInvalidInput,
619
+ severity: 'error',
620
+ message: issue.message,
621
+ source: 'effective workspace config',
622
+ pointer: issue.path.length === 0
623
+ ? null
624
+ : pointer(...issue.path.map((item) => String(item))),
625
+ hint: 'Correct the init selections; Pappra does not coerce invalid configuration.',
626
+ }));
627
+ }
628
+ return {
629
+ config: null,
630
+ selected: repositorySelection.selected,
631
+ diagnostics,
632
+ };
633
+ }
634
+ const semantic = validateWorkspaceConfigSemantics(parsed.data, {
635
+ configPath: join(root, workspaceConfigRelativePath),
636
+ });
637
+ diagnostics.push(...semantic);
638
+ if (hasErrors(semantic)) {
639
+ return {
640
+ config: null,
641
+ selected: repositorySelection.selected,
642
+ diagnostics,
643
+ };
644
+ }
645
+ return {
646
+ config: parsed.data,
647
+ selected: repositorySelection.selected,
648
+ diagnostics,
649
+ };
650
+ }
651
+ function scopeRepositories(config, discovery) {
652
+ const byConfiguredPath = new Map(discovery.repositories.map((repository) => [
653
+ repository.configuredPath,
654
+ repository,
655
+ ]));
656
+ return config.repositories.map((repository) => {
657
+ const discovered = byConfiguredPath.get(repository.path);
658
+ return {
659
+ id: repository.id,
660
+ path: repository.path,
661
+ access: repository.access,
662
+ external: isExternalRepositoryPath(repository.path),
663
+ root: discovered?.root ?? null,
664
+ kind: discovered?.kind ?? null,
665
+ dirty: discovered?.git.dirty ?? null,
666
+ declaredBy: 'existing',
667
+ };
668
+ });
669
+ }
670
+ function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig, existing) {
671
+ const workspaceBytes = needWorkspaceConfig ? jsonBytes(config) : null;
672
+ const machineBytes = needMachineConfig ? jsonBytes(machine) : null;
673
+ const files = [];
674
+ if (workspaceBytes !== null &&
675
+ existing.workspaceDigest !== sha256Digest(workspaceBytes)) {
676
+ files.push({
677
+ path: workspaceConfigRelativePath,
678
+ bytes: workspaceBytes,
679
+ beforeText: null,
680
+ ...(existing.workspaceDigest === null
681
+ ? {}
682
+ : {
683
+ operation: 'replace-managed',
684
+ expectedBeforeDigest: existing.workspaceDigest,
685
+ backupRequired: true,
686
+ }),
687
+ });
688
+ }
689
+ if (machineBytes !== null &&
690
+ existing.machineDigest !== sha256Digest(machineBytes)) {
691
+ files.push({
692
+ path: machineConfigRelativePath,
693
+ bytes: machineBytes,
694
+ beforeText: null,
695
+ ...(existing.machineDigest === null
696
+ ? {}
697
+ : {
698
+ operation: 'replace-managed',
699
+ expectedBeforeDigest: existing.machineDigest,
700
+ backupRequired: true,
701
+ }),
702
+ });
703
+ }
704
+ return { files, workspaceBytes, machineBytes };
705
+ }
706
+ async function pendingRuntimeActions(config, machine) {
707
+ if (machine.runtime !== null) {
708
+ const executable = await observePath(machine.runtime.executable);
709
+ if (machine.runtime.version === config.runtimeVersion &&
710
+ executable.kind === 'file') {
711
+ return [];
712
+ }
713
+ }
714
+ return [
715
+ {
716
+ kind: 'install-runtime',
717
+ target: 'pappra',
718
+ version: config.runtimeVersion,
719
+ },
720
+ ];
721
+ }
722
+ function existingMachineForExistingConfig(root, config, choices, discovery) {
723
+ if (choices === null || choices === undefined) {
724
+ return {
725
+ machine: null,
726
+ diagnostics: [
727
+ createDiagnostic({
728
+ code: diagnosticCodes.initInputRequired,
729
+ severity: 'error',
730
+ message: 'Host selection is required to create local machine.json.',
731
+ source: 'init choices',
732
+ hint: 'Provide --config with hosts, or run the interactive wizard.',
733
+ }),
734
+ ],
735
+ };
736
+ }
737
+ const externalRepositoryUnknown = config.repositories.some((repository) => isExternalRepositoryPath(repository.path));
738
+ if (externalRepositoryUnknown) {
739
+ return {
740
+ machine: null,
741
+ diagnostics: [
742
+ createDiagnostic({
743
+ code: diagnosticCodes.initExternalMappingRequired,
744
+ severity: 'error',
745
+ message: 'External repository mappings are missing, so machine.json cannot be completed safely.',
746
+ source: join(root, workspaceConfigRelativePath),
747
+ hint: 'Create or repair .pappra/local/machine.json explicitly; Pappra does not guess absolute external paths.',
748
+ }),
749
+ ...discovery.unresolvedRepositories.flatMap((repository) => repository.diagnostics),
750
+ ],
751
+ };
752
+ }
753
+ const machine = {
754
+ schema: 'pappra/machine/1',
755
+ workspaceRealpath: root,
756
+ checkoutId: randomUUID(),
757
+ externalRepositories: [],
758
+ selectedHosts: [...new Set(choices.hosts)],
759
+ runtime: null,
760
+ };
761
+ const parsed = machineConfigSchema.safeParse(machine);
762
+ if (!parsed.success) {
763
+ return {
764
+ machine: null,
765
+ diagnostics: [
766
+ createDiagnostic({
767
+ code: diagnosticCodes.initInvalidInput,
768
+ severity: 'error',
769
+ message: parsed.error.issues[0]?.message ??
770
+ 'Generated machine configuration is invalid.',
771
+ source: join(root, machineConfigRelativePath),
772
+ hint: 'Correct the host selection.',
773
+ }),
774
+ ],
775
+ };
776
+ }
777
+ return { machine: parsed.data, diagnostics: [] };
778
+ }
779
+ /**
780
+ * Produces a strict plan and exact in-memory writes. No target file is created
781
+ * or modified by this function.
782
+ */
783
+ export async function planWorkspaceInit(options) {
784
+ const contextResult = options.context === undefined
785
+ ? await inspectInitContext(options.root)
786
+ : { ok: true, context: options.context };
787
+ if (!contextResult.ok)
788
+ return contextResult;
789
+ const context = contextResult.context;
790
+ const contextRootIdentity = await observeRootIdentity(context.root);
791
+ if (contextRootIdentity === null ||
792
+ !sameRootIdentity(context.rootIdentity, contextRootIdentity)) {
793
+ return failure('conflict', diagnosticCodes.initPlanStale, 'Workspace root directory was replaced after discovery.', context.diagnostics, 'Retry init from the current workspace root; no plan was produced.');
794
+ }
795
+ let choices = options.choices ?? null;
796
+ if (choices !== null) {
797
+ const parsedChoices = parseInitChoices(choices, 'init choices');
798
+ if (parsedChoices.value === null) {
799
+ return failureFromDiagnostics('invalid', parsedChoices.diagnostics, diagnosticCodes.initInvalidInput, 'Init selections are invalid.');
800
+ }
801
+ choices = parsedChoices.value;
802
+ }
803
+ const existingConfig = context.workspaceInspection.value;
804
+ const existingMachine = context.machineInspection.value;
805
+ const diagnostics = [...context.diagnostics];
806
+ const runtimeVersion = options.runtimeVersion ??
807
+ (options.reconfigure === true
808
+ ? undefined
809
+ : existingConfig?.runtimeVersion) ??
810
+ getPackageInfo().version;
811
+ let config;
812
+ let machine;
813
+ let needWorkspaceConfig = false;
814
+ let needMachineConfig = false;
815
+ let selected = [];
816
+ if (existingConfig !== null && options.reconfigure !== true) {
817
+ config = existingConfig;
818
+ if (choices !== null) {
819
+ diagnostics.push(info(diagnosticCodes.initExistingConfigReused, 'Existing valid workspace configuration is reused; init selections do not overwrite it.', context.workspaceInspection.path, 'Use a future configure flow to change existing workspace policy.'));
820
+ }
821
+ if (existingMachine !== null) {
822
+ machine = existingMachine;
823
+ }
824
+ else {
825
+ const machineResult = existingMachineForExistingConfig(context.root, existingConfig, choices, context.discovery);
826
+ if (machineResult.machine === null) {
827
+ return failureFromDiagnostics('needs_input', [...diagnostics, ...machineResult.diagnostics], diagnosticCodes.initInputRequired, 'More init input is required.');
828
+ }
829
+ machine = machineResult.machine;
830
+ diagnostics.push(...machineResult.diagnostics);
831
+ needMachineConfig = true;
832
+ }
833
+ }
834
+ else {
835
+ if (choices === null) {
836
+ return failure('needs_input', diagnosticCodes.initInputRequired, 'Workspace configuration is missing; init selections are required.', diagnostics, 'Provide --config with pappra/init-choices/1 selections, or run the interactive wizard.');
837
+ }
838
+ const effectiveChoices = existingConfig !== null && choices.workspaceId === undefined
839
+ ? { ...choices, workspaceId: existingConfig.workspaceId }
840
+ : choices;
841
+ const built = buildWorkspaceConfig(context.root, effectiveChoices, context.discovery, runtimeVersion);
842
+ diagnostics.push(...built.diagnostics);
843
+ if (built.config === null) {
844
+ return failureFromDiagnostics('invalid', diagnostics, diagnosticCodes.initInvalidInput, 'Init selections do not form a valid workspace configuration.');
845
+ }
846
+ config = built.config;
847
+ selected = built.selected;
848
+ needWorkspaceConfig = true;
849
+ if (existingMachine !== null) {
850
+ machine = existingMachine;
851
+ diagnostics.push(info(diagnosticCodes.initExistingConfigReused, 'Existing valid machine.json is reused while workspace.json is created.', context.machineInspection.path, 'Use a future configure flow to change local host or external repository mappings.'));
852
+ }
853
+ else {
854
+ needMachineConfig = true;
855
+ machine = {
856
+ schema: 'pappra/machine/1',
857
+ workspaceRealpath: context.root,
858
+ checkoutId: randomUUID(),
859
+ externalRepositories: [],
860
+ selectedHosts: [...new Set(choices.hosts)],
861
+ runtime: null,
862
+ };
863
+ const parsedMachine = machineConfigSchema.safeParse(machine);
864
+ if (!parsedMachine.success) {
865
+ return failure('invalid', diagnosticCodes.initInvalidInput, parsedMachine.error.issues[0]?.message ??
866
+ 'Generated machine configuration is invalid.', diagnostics, 'Correct the host selection.');
867
+ }
868
+ machine = parsedMachine.data;
869
+ }
870
+ }
871
+ const filePlan = planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig, {
872
+ workspaceDigest: context.workspaceInspection.digest,
873
+ machineDigest: context.machineInspection.digest,
874
+ });
875
+ const configurationDigest = filePlan.workspaceBytes !== null
876
+ ? sha256Digest(filePlan.workspaceBytes)
877
+ : (context.workspaceInspection.digest ??
878
+ sha256Digest(JSON.stringify(config)));
879
+ const externalActions = await pendingRuntimeActions(config, machine);
880
+ const built = buildInitPlan({
881
+ workspaceRoot: context.root,
882
+ configurationDigest,
883
+ files: filePlan.files,
884
+ externalActions,
885
+ ...(options.planId === undefined ? {} : { planId: options.planId }),
886
+ ...(options.discoveredAt === undefined
887
+ ? {}
888
+ : { discoveredAt: options.discoveredAt }),
889
+ });
890
+ const finalWorkspace = await inspectWorkspaceConfig(context.root, {
891
+ missing: 'info',
892
+ });
893
+ const finalMachine = await inspectMachineConfig(context.root);
894
+ if (context.workspaceInspection.digest !== finalWorkspace.digest ||
895
+ context.workspaceInspection.exists !== finalWorkspace.exists ||
896
+ context.machineInspection.digest !== finalMachine.digest ||
897
+ context.machineInspection.exists !== finalMachine.exists) {
898
+ return failure('conflict', diagnosticCodes.initPlanStale, 'Workspace inputs changed after planning; the plan is stale.', diagnostics, 'Retry init; no target files were written.');
899
+ }
900
+ const finalRootIdentity = await observeRootIdentity(context.root);
901
+ if (finalRootIdentity === null ||
902
+ !sameRootIdentity(context.rootIdentity, finalRootIdentity)) {
903
+ return failure('conflict', diagnosticCodes.initPlanStale, 'Workspace root directory changed while planning.', diagnostics, 'Retry init from the current workspace root; no target files were written.');
904
+ }
905
+ const rootIdentity = context.rootIdentity;
906
+ const scopeRepos = existingConfig !== null
907
+ ? scopeRepositories(config, context.discovery)
908
+ : selected.map((repository) => ({
909
+ id: repository.id,
910
+ path: repository.path,
911
+ access: repository.access,
912
+ external: repository.repository.external,
913
+ root: repository.repository.root,
914
+ kind: repository.repository.kind,
915
+ dirty: repository.repository.git.dirty,
916
+ declaredBy: 'planned',
917
+ }));
918
+ const scope = {
919
+ workspaceRoot: context.root,
920
+ workspaceId: config.workspaceId,
921
+ name: config.name,
922
+ runtimeVersion: config.runtimeVersion,
923
+ configSource: existingConfig !== null && options.reconfigure !== true
924
+ ? 'existing'
925
+ : 'planned',
926
+ repositories: scopeRepos,
927
+ knowledge: config.knowledge,
928
+ code: config.code,
929
+ validation: config.validation,
930
+ excludes: config.excludes,
931
+ hosts: machine.selectedHosts,
932
+ boundaries: context.discovery.boundaries,
933
+ };
934
+ return {
935
+ ok: true,
936
+ bundle: {
937
+ plan: built.plan,
938
+ scope,
939
+ writes: built.writes,
940
+ diagnostics: dedupeDiagnostics(diagnostics),
941
+ rootIdentity,
942
+ },
943
+ };
944
+ }
945
+ function planDiagnostics(message, code) {
946
+ return {
947
+ ok: false,
948
+ status: 'stale',
949
+ appliedFiles: [],
950
+ pendingExternalActions: [],
951
+ residualPaths: [],
952
+ diagnostics: [
953
+ createDiagnostic({
954
+ code,
955
+ severity: 'error',
956
+ message,
957
+ source: 'init apply',
958
+ hint: 'Retry init to produce a fresh plan; no target files were written.',
959
+ }),
960
+ ],
961
+ };
962
+ }
963
+ function applyFailure(status, code, message, hint) {
964
+ return {
965
+ ok: false,
966
+ status,
967
+ appliedFiles: [],
968
+ pendingExternalActions: [],
969
+ residualPaths: [],
970
+ diagnostics: [
971
+ createDiagnostic({
972
+ code,
973
+ severity: 'error',
974
+ message,
975
+ source: 'init apply',
976
+ hint,
977
+ }),
978
+ ],
979
+ };
980
+ }
981
+ async function safeTarget(root, relativePath) {
982
+ if (!isPortableRelativePath(relativePath) || relativePath === '.') {
983
+ return null;
984
+ }
985
+ const absolutePath = resolvePortableRelativePath(root, relativePath);
986
+ if (!isPathInside(root, absolutePath) || samePath(root, absolutePath)) {
987
+ return null;
988
+ }
989
+ let ancestor = dirname(absolutePath);
990
+ for (;;) {
991
+ const observation = await observePath(ancestor);
992
+ if (observation.kind !== 'missing') {
993
+ if (observation.kind !== 'directory')
994
+ return null;
995
+ const real = await resolveRealpath(ancestor);
996
+ if (real === null || !isPathInside(root, real))
997
+ return null;
998
+ return { absolutePath, existingAncestor: real };
999
+ }
1000
+ if (samePath(ancestor, root))
1001
+ return null;
1002
+ const parent = dirname(ancestor);
1003
+ if (samePath(parent, ancestor))
1004
+ return null;
1005
+ ancestor = parent;
1006
+ }
1007
+ }
1008
+ async function verifyBundleWrites(bundle) {
1009
+ const parsed = initPlanSchema.safeParse(bundle.plan);
1010
+ if (!parsed.success) {
1011
+ return [
1012
+ createDiagnostic({
1013
+ code: diagnosticCodes.initPlanInvalid,
1014
+ severity: 'error',
1015
+ message: `Init plan does not match pappra/init-plan/1: ${parsed.error.issues[0]?.message ?? 'invalid plan'}`,
1016
+ source: 'init plan',
1017
+ hint: 'Regenerate the plan; Pappra does not apply malformed plans.',
1018
+ }),
1019
+ ];
1020
+ }
1021
+ if (bundle.writes.length !== bundle.plan.files.length) {
1022
+ return [
1023
+ createDiagnostic({
1024
+ code: diagnosticCodes.initPlanInvalid,
1025
+ severity: 'error',
1026
+ message: 'Init plan writes do not match planned file entries.',
1027
+ source: 'init plan',
1028
+ hint: 'Regenerate the plan; Pappra does not apply partial in-memory content.',
1029
+ }),
1030
+ ];
1031
+ }
1032
+ const diagnostics = [];
1033
+ for (const [index, file] of bundle.plan.files.entries()) {
1034
+ const write = bundle.writes[index];
1035
+ if (write === undefined || write.path !== file.path) {
1036
+ diagnostics.push(createDiagnostic({
1037
+ code: diagnosticCodes.initPlanInvalid,
1038
+ severity: 'error',
1039
+ message: `Init plan write ${index} does not match its planned path.`,
1040
+ source: 'init plan',
1041
+ hint: 'Regenerate the plan.',
1042
+ }));
1043
+ continue;
1044
+ }
1045
+ if (write.operation !== file.operation) {
1046
+ diagnostics.push(createDiagnostic({
1047
+ code: diagnosticCodes.initPlanInvalid,
1048
+ severity: 'error',
1049
+ message: `Planned operation for ${file.path} does not match its write.`,
1050
+ source: 'init plan',
1051
+ hint: 'Regenerate the plan.',
1052
+ }));
1053
+ }
1054
+ if (file.operation !== 'create' && file.operation !== 'replace-managed') {
1055
+ diagnostics.push(createDiagnostic({
1056
+ code: diagnosticCodes.initApplyUnsupported,
1057
+ severity: 'error',
1058
+ message: `Init apply does not support operation ${file.operation} for ${file.path}.`,
1059
+ source: 'init plan',
1060
+ hint: 'Regenerate the plan with create or replace-managed operations.',
1061
+ }));
1062
+ }
1063
+ if (file.operation === 'replace-managed' &&
1064
+ file.expectedBeforeDigest === null) {
1065
+ diagnostics.push(createDiagnostic({
1066
+ code: diagnosticCodes.initPlanInvalid,
1067
+ severity: 'error',
1068
+ message: `Managed replacement for ${file.path} has no expected before digest.`,
1069
+ source: 'init plan',
1070
+ hint: 'Regenerate the plan; Pappra does not replace an unverified file.',
1071
+ }));
1072
+ }
1073
+ if (sha256Digest(write.bytes) !== file.nextDigest) {
1074
+ diagnostics.push(createDiagnostic({
1075
+ code: diagnosticCodes.initPlanInvalid,
1076
+ severity: 'error',
1077
+ message: `Planned bytes for ${file.path} do not match nextDigest.`,
1078
+ source: 'init plan',
1079
+ hint: 'Regenerate the plan from the same selections.',
1080
+ }));
1081
+ }
1082
+ }
1083
+ return diagnostics;
1084
+ }
1085
+ async function cleanupCreatedFiles(root, created) {
1086
+ const residual = [];
1087
+ for (const record of [...created].reverse()) {
1088
+ const absolute = resolvePortableRelativePath(root, record.path);
1089
+ try {
1090
+ const observation = await observePath(absolute);
1091
+ if (observation.kind === 'missing')
1092
+ continue;
1093
+ if (observation.kind !== 'file' ||
1094
+ observation.stats === null ||
1095
+ observation.stats.dev !== record.dev ||
1096
+ observation.stats.ino !== record.ino) {
1097
+ residual.push(record.path);
1098
+ continue;
1099
+ }
1100
+ const written = await readFile(absolute);
1101
+ if (sha256Digest(written) !== record.digest) {
1102
+ residual.push(record.path);
1103
+ continue;
1104
+ }
1105
+ await rm(absolute, { force: false });
1106
+ }
1107
+ catch {
1108
+ residual.push(record.path);
1109
+ }
1110
+ }
1111
+ return residual;
1112
+ }
1113
+ /**
1114
+ * Applies only create operations from a plan, after revalidating root,
1115
+ * configuration digest, target absence and exact bytes. External actions are
1116
+ * reported as pending and never executed here.
1117
+ */
1118
+ export async function applyInitPlan(bundle, options = {}) {
1119
+ const diagnostics = await verifyBundleWrites(bundle);
1120
+ if (diagnostics.length > 0) {
1121
+ return {
1122
+ ok: false,
1123
+ status: diagnostics.some((entry) => entry.code === diagnosticCodes.initApplyUnsupported)
1124
+ ? 'unsupported'
1125
+ : 'stale',
1126
+ appliedFiles: [],
1127
+ pendingExternalActions: [],
1128
+ residualPaths: [],
1129
+ diagnostics,
1130
+ };
1131
+ }
1132
+ const root = await resolveRealpath(bundle.plan.workspaceRoot);
1133
+ const rootObservation = await observePath(bundle.plan.workspaceRoot);
1134
+ if (root === null ||
1135
+ !samePath(root, bundle.plan.workspaceRoot) ||
1136
+ rootObservation.kind !== 'directory' ||
1137
+ rootObservation.stats === null ||
1138
+ rootObservation.stats.dev !== bundle.rootIdentity.dev ||
1139
+ rootObservation.stats.ino !== bundle.rootIdentity.ino) {
1140
+ return applyFailure('stale', diagnosticCodes.initPlanStale, 'Workspace root identity changed since planning.', 'Retry init from the current workspace root; no files were written.');
1141
+ }
1142
+ const workspaceInspection = await inspectWorkspaceConfig(root, {
1143
+ missing: 'info',
1144
+ });
1145
+ const workspaceFileIndex = bundle.plan.files.findIndex((file) => file.path === workspaceConfigRelativePath);
1146
+ const workspaceFile = workspaceFileIndex === -1 ? null : bundle.plan.files[workspaceFileIndex];
1147
+ if (workspaceFile === null &&
1148
+ (!workspaceInspection.exists ||
1149
+ workspaceInspection.value === null ||
1150
+ workspaceInspection.digest !== bundle.plan.configurationDigest)) {
1151
+ return planDiagnostics('Workspace configuration changed since planning.', diagnosticCodes.initPlanStale);
1152
+ }
1153
+ if (workspaceFile !== null) {
1154
+ if (workspaceFile.operation === 'create' && workspaceInspection.exists) {
1155
+ return applyFailure('conflict', diagnosticCodes.initTargetExists, 'Workspace configuration appeared after planning.', 'Remove or relocate the unexpected file, then retry init.');
1156
+ }
1157
+ if (workspaceFile.operation === 'replace-managed' &&
1158
+ workspaceInspection.digest !== workspaceFile.expectedBeforeDigest) {
1159
+ return planDiagnostics('Workspace configuration changed since planning.', diagnosticCodes.initPlanStale);
1160
+ }
1161
+ }
1162
+ for (const file of bundle.plan.files) {
1163
+ const target = await safeTarget(root, file.path);
1164
+ if (target === null) {
1165
+ return applyFailure('conflict', diagnosticCodes.initTargetUnsafe, `Planned target is not a safe workspace-relative path: ${file.path}`, 'Regenerate the plan; Pappra does not follow symlinked parents outside the workspace.');
1166
+ }
1167
+ const observation = await observePath(target.absolutePath);
1168
+ if (file.operation === 'create') {
1169
+ if (observation.kind !== 'missing') {
1170
+ return applyFailure('conflict', diagnosticCodes.initTargetExists, `Planned target already exists: ${file.path}`, 'Remove or relocate the unexpected file, then retry init.');
1171
+ }
1172
+ continue;
1173
+ }
1174
+ if (observation.kind !== 'file' || observation.stats === null) {
1175
+ return applyFailure('conflict', diagnosticCodes.initTargetExists, `Planned replacement target is not a regular file: ${file.path}`, 'Inspect the target; Pappra does not replace directories or special files.');
1176
+ }
1177
+ const before = await readFile(target.absolutePath);
1178
+ if (file.expectedBeforeDigest === null ||
1179
+ sha256Digest(before) !== file.expectedBeforeDigest) {
1180
+ return planDiagnostics(`Planned replacement target changed since planning: ${file.path}`, diagnosticCodes.initPlanStale);
1181
+ }
1182
+ }
1183
+ const createIndexes = bundle.plan.files.flatMap((file, index) => file.operation === 'create' ? [index] : []);
1184
+ const replaceIndexes = bundle.plan.files.flatMap((file, index) => file.operation === 'replace-managed' ? [index] : []);
1185
+ let managed = null;
1186
+ const created = [];
1187
+ const createdDirectories = [];
1188
+ const replacements = [];
1189
+ let originalManifest = null;
1190
+ const ensureDirectory = async (directory) => {
1191
+ const missing = [];
1192
+ let current = directory;
1193
+ for (;;) {
1194
+ const observation = await observePath(current);
1195
+ if (observation.kind !== 'missing') {
1196
+ if (observation.kind !== 'directory') {
1197
+ throw new Error(`Parent path is not a directory: ${current}`);
1198
+ }
1199
+ break;
1200
+ }
1201
+ missing.push(current);
1202
+ if (samePath(current, root))
1203
+ break;
1204
+ const parent = dirname(current);
1205
+ if (samePath(parent, current))
1206
+ break;
1207
+ current = parent;
1208
+ }
1209
+ for (const path of missing.reverse()) {
1210
+ await mkdir(path);
1211
+ createdDirectories.push(path);
1212
+ }
1213
+ };
1214
+ const restoreReplacements = async () => {
1215
+ for (const replacement of [...replacements].reverse()) {
1216
+ try {
1217
+ await writeFile(replacement.absolutePath, replacement.beforeBytes);
1218
+ }
1219
+ catch {
1220
+ // The residual list below and the failure diagnostic report this case.
1221
+ }
1222
+ }
1223
+ if (originalManifest !== null) {
1224
+ await writeWorkspaceManifest(root, originalManifest).catch(() => undefined);
1225
+ }
1226
+ };
1227
+ const rootBeforeWrite = await observePath(root);
1228
+ if (rootBeforeWrite.kind !== 'directory' ||
1229
+ rootBeforeWrite.stats === null ||
1230
+ rootBeforeWrite.stats.dev !== bundle.rootIdentity.dev ||
1231
+ rootBeforeWrite.stats.ino !== bundle.rootIdentity.ino) {
1232
+ return applyFailure('stale', diagnosticCodes.initPlanStale, 'Workspace root identity changed before writing.', 'Retry init from the current workspace root; no files were written.');
1233
+ }
1234
+ try {
1235
+ for (const index of replaceIndexes) {
1236
+ const file = bundle.plan.files[index];
1237
+ const write = bundle.writes[index];
1238
+ await options.beforeWrite?.(file.path, index);
1239
+ const absolutePath = resolvePortableRelativePath(root, file.path);
1240
+ const backupRef = backupPath(root, bundle.plan.planId, file.path);
1241
+ const stateBoundary = await ensureStatePathBoundary(root, backupRef);
1242
+ if (!stateBoundary.ok)
1243
+ throw new Error(stateBoundary.message);
1244
+ const beforeBytes = await readFile(absolutePath);
1245
+ if (file.expectedBeforeDigest === null ||
1246
+ sha256Digest(beforeBytes) !== file.expectedBeforeDigest) {
1247
+ throw new Error(`Replacement target changed again: ${file.path}`);
1248
+ }
1249
+ const observation = await observePath(absolutePath);
1250
+ const mode = observation.stats === null ? 0o644 : observation.stats.mode & 0o777;
1251
+ await mkdir(dirname(backupRef), { recursive: true });
1252
+ await writeFile(backupRef, beforeBytes, { mode: 0o600, flag: 'wx' });
1253
+ const temporary = `${absolutePath}.pappra-replace-${randomUUID().slice(0, 8)}`;
1254
+ await writeFile(temporary, write.bytes, { mode });
1255
+ await rename(temporary, absolutePath);
1256
+ const after = await observePath(absolutePath);
1257
+ if (after.kind !== 'file' || after.stats === null) {
1258
+ throw new Error(`Replacement target is unreadable after writing: ${file.path}`);
1259
+ }
1260
+ replacements.push({
1261
+ path: file.path,
1262
+ absolutePath,
1263
+ beforeBytes,
1264
+ backupRef,
1265
+ backupDigest: sha256Digest(beforeBytes),
1266
+ afterDigest: file.nextDigest,
1267
+ identity: { dev: after.stats.dev, ino: after.stats.ino },
1268
+ });
1269
+ }
1270
+ if (replacements.length > 0) {
1271
+ const manifestRead = await readWorkspaceManifest(root);
1272
+ if (manifestRead.diagnostics.length > 0) {
1273
+ throw new Error(manifestRead.diagnostics[0]?.message ??
1274
+ 'Workspace install manifest is unreadable.');
1275
+ }
1276
+ if (manifestRead.value === null) {
1277
+ throw new Error('Workspace install manifest is missing; Pappra does not adopt an unowned replacement.');
1278
+ }
1279
+ originalManifest = manifestRead.value;
1280
+ const entries = [...manifestRead.value.entries];
1281
+ const verifiedAt = new Date().toISOString();
1282
+ for (const replacement of replacements) {
1283
+ const entry = {
1284
+ path: replacement.path,
1285
+ installId: bundle.plan.planId,
1286
+ operation: 'replace-managed',
1287
+ beforeDigest: replacement.backupDigest,
1288
+ afterDigest: replacement.afterDigest,
1289
+ backupRef: replacement.backupRef,
1290
+ backupDigest: replacement.backupDigest,
1291
+ identity: replacement.identity,
1292
+ owner: 'pappra',
1293
+ verifiedAt,
1294
+ };
1295
+ const existingIndex = entries.findIndex((item) => item.path === replacement.path);
1296
+ if (existingIndex === -1)
1297
+ entries.push(entry);
1298
+ else
1299
+ entries[existingIndex] = entry;
1300
+ }
1301
+ const written = await writeWorkspaceManifest(root, {
1302
+ ...manifestRead.value,
1303
+ entries,
1304
+ });
1305
+ if (!written.ok)
1306
+ throw new Error(written.message);
1307
+ }
1308
+ if (createIndexes.length > 0) {
1309
+ const createPlan = {
1310
+ ...bundle.plan,
1311
+ files: createIndexes.map((index) => bundle.plan.files[index]),
1312
+ };
1313
+ const createWrites = createIndexes.map((index) => bundle.writes[index]);
1314
+ const managedStart = await beginManagedApply(root, createPlan, createWrites, bundle.rootIdentity);
1315
+ if (!managedStart.ok) {
1316
+ const status = managedStart.status === 'invalid'
1317
+ ? 'stale'
1318
+ : managedStart.status === 'locked'
1319
+ ? 'conflict'
1320
+ : 'failed';
1321
+ await restoreReplacements();
1322
+ return {
1323
+ ok: false,
1324
+ status,
1325
+ appliedFiles: [],
1326
+ pendingExternalActions: [],
1327
+ residualPaths: managedStart.residualPaths,
1328
+ diagnostics: managedStart.diagnostics,
1329
+ };
1330
+ }
1331
+ managed = managedStart.context;
1332
+ for (const index of createIndexes) {
1333
+ const file = bundle.plan.files[index];
1334
+ const write = bundle.writes[index];
1335
+ const target = await safeTarget(root, file.path);
1336
+ if (target === null) {
1337
+ throw new Error(`Unsafe target: ${file.path}`);
1338
+ }
1339
+ await options.beforeWrite?.(file.path, index);
1340
+ await ensureDirectory(dirname(target.absolutePath));
1341
+ const handle = await open(target.absolutePath, 'wx');
1342
+ const record = {
1343
+ path: file.path,
1344
+ digest: file.nextDigest,
1345
+ dev: 0,
1346
+ ino: 0,
1347
+ };
1348
+ try {
1349
+ await handle.writeFile(write.bytes);
1350
+ await handle.sync();
1351
+ const stats = await handle.stat();
1352
+ record.dev = stats.dev;
1353
+ record.ino = stats.ino;
1354
+ }
1355
+ finally {
1356
+ await handle.close();
1357
+ }
1358
+ created.push(record);
1359
+ const recorded = await recordManagedFile(managed, file.path, file.nextDigest, { dev: record.dev, ino: record.ino });
1360
+ if (!recorded.ok) {
1361
+ throw new Error(recorded.diagnostics[0]?.message ?? `Cannot record ${file.path}.`);
1362
+ }
1363
+ }
1364
+ const completed = await completeManagedApply(managed);
1365
+ if (!completed.ok) {
1366
+ const residual = await cleanupCreatedFiles(root, created);
1367
+ const failed = await failManagedApply(managed, 'Cannot finalize managed installation manifest.', residual);
1368
+ await restoreReplacements();
1369
+ return {
1370
+ ok: false,
1371
+ status: 'failed',
1372
+ appliedFiles: [],
1373
+ pendingExternalActions: [],
1374
+ residualPaths: failed.residualPaths,
1375
+ diagnostics: failed.diagnostics,
1376
+ };
1377
+ }
1378
+ }
1379
+ const appliedFiles = [
1380
+ ...created.map((file) => file.path),
1381
+ ...replacements.map((replacement) => replacement.path),
1382
+ ];
1383
+ return {
1384
+ ok: true,
1385
+ status: bundle.plan.externalActions.length > 0
1386
+ ? 'external-actions-pending'
1387
+ : appliedFiles.length === 0
1388
+ ? 'no-changes'
1389
+ : 'applied',
1390
+ appliedFiles,
1391
+ pendingExternalActions: bundle.plan.externalActions,
1392
+ residualPaths: [],
1393
+ diagnostics: [],
1394
+ };
1395
+ }
1396
+ catch (error) {
1397
+ await restoreReplacements();
1398
+ const residual = await cleanupCreatedFiles(root, created);
1399
+ for (const directory of createdDirectories.reverse()) {
1400
+ await rmdir(directory).catch(() => undefined);
1401
+ }
1402
+ const code = typeof error === 'object' &&
1403
+ error !== null &&
1404
+ 'code' in error &&
1405
+ error.code === 'EEXIST'
1406
+ ? diagnosticCodes.initTargetExists
1407
+ : diagnosticCodes.initApplyFailed;
1408
+ const status = code === diagnosticCodes.initTargetExists ? 'conflict' : 'failed';
1409
+ const base = `Init apply ${status === 'conflict' ? 'stopped because a target appeared' : 'failed'}: ${error instanceof Error ? error.message : String(error)}`;
1410
+ const residualNote = residual.length === 0
1411
+ ? 'No planned file from this attempt remains.'
1412
+ : `Residual planned files require manual inspection: ${residual.join(', ')}`;
1413
+ if (managed === null) {
1414
+ return {
1415
+ ok: false,
1416
+ status,
1417
+ appliedFiles: [],
1418
+ pendingExternalActions: [],
1419
+ residualPaths: residual,
1420
+ diagnostics: [
1421
+ createDiagnostic({
1422
+ code,
1423
+ severity: 'error',
1424
+ message: `${base}. ${residualNote}`,
1425
+ source: 'init apply',
1426
+ pointer: null,
1427
+ hint: 'Inspect the workspace state; backups from this attempt were restored when possible.',
1428
+ }),
1429
+ ],
1430
+ };
1431
+ }
1432
+ const failed = await failManagedApply(managed, `${base}. ${residualNote}`, residual);
1433
+ return {
1434
+ ok: false,
1435
+ status,
1436
+ appliedFiles: [],
1437
+ pendingExternalActions: [],
1438
+ residualPaths: failed.residualPaths,
1439
+ diagnostics: failed.diagnostics,
1440
+ };
1441
+ }
1442
+ }