@phyzess/pappra 0.1.0-beta.1 → 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 (49) hide show
  1. package/README.md +30 -11
  2. package/assets/integrations/skills/pappra/SKILL.md +7 -4
  3. package/assets/integrations/skills/pappra/references/hosts.md +21 -11
  4. package/assets/integrations/skills/pappra/references/lifecycle.md +4 -0
  5. package/assets/integrations/skills/pappra/references/operations.md +20 -7
  6. package/dist/app/collaboration.js +6 -13
  7. package/dist/app/doctor.js +2 -2
  8. package/dist/app/init.d.ts +28 -0
  9. package/dist/app/init.js +419 -88
  10. package/dist/cli/commands/docs.d.ts +1 -0
  11. package/dist/cli/commands/docs.js +104 -3
  12. package/dist/cli/commands/host.d.ts +5 -1
  13. package/dist/cli/commands/host.js +80 -6
  14. package/dist/cli/commands/init.d.ts +8 -0
  15. package/dist/cli/commands/init.js +186 -9
  16. package/dist/cli/commands/runtime.d.ts +14 -0
  17. package/dist/cli/commands/runtime.js +263 -0
  18. package/dist/cli/run.js +159 -7
  19. package/dist/hosts/detect.d.ts +1 -0
  20. package/dist/hosts/detect.js +1 -1
  21. package/dist/hosts/integration/bundle-export.d.ts +24 -0
  22. package/dist/hosts/integration/bundle-export.js +128 -0
  23. package/dist/hosts/integration/bundle.d.ts +4 -1
  24. package/dist/hosts/integration/bundle.js +20 -2
  25. package/dist/hosts/integration/pappra-extension-runtime.mjs +24610 -0
  26. package/dist/hosts/integration/pappra-hook.mjs +24728 -0
  27. package/dist/hosts/integration/service.d.ts +8 -0
  28. package/dist/hosts/integration/service.js +71 -31
  29. package/dist/hosts/integration/support.d.ts +23 -0
  30. package/dist/hosts/integration/support.js +25 -0
  31. package/dist/hosts/integration/templates.d.ts +3 -3
  32. package/dist/hosts/integration/templates.js +15 -10
  33. package/dist/hosts/pi/rpc.d.ts +2 -0
  34. package/dist/hosts/pi/rpc.js +39 -1
  35. package/dist/hosts/pilot.d.ts +77 -77
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/install/runtime.d.ts +1 -1
  39. package/dist/install/runtime.js +117 -10
  40. package/dist/package-info.d.ts +2 -0
  41. package/dist/package-info.js +6 -0
  42. package/dist/schemas/host.js +4 -2
  43. package/dist/schemas/install.d.ts +14 -0
  44. package/dist/schemas/install.js +8 -0
  45. package/package.json +8 -2
  46. package/skills/pappra/SKILL.md +7 -4
  47. package/skills/pappra/references/hosts.md +21 -11
  48. package/skills/pappra/references/lifecycle.md +4 -0
  49. package/skills/pappra/references/operations.md +20 -7
package/dist/app/init.js CHANGED
@@ -1,18 +1,22 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
- import { mkdir, open, readFile, rm, rmdir } from 'node:fs/promises';
3
+ import { mkdir, open, readFile, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
4
4
  import { createDiagnostic, hasErrors, pointer, } from '../schemas/diagnostics.js';
5
5
  import { diagnosticCodes } from '../schemas/errors.js';
6
- import { initPlanSchema, parseInitChoices, } from '../schemas/init.js';
6
+ import { INIT_CHOICES_SCHEMA, initPlanSchema, parseInitChoices, } from '../schemas/init.js';
7
7
  import { machineConfigSchema } from '../schemas/machine.js';
8
8
  import { isExternalRepositoryPath, isPathInside, isPortableRelativePath, normalizePortableRelativePath, resolvePortableRelativePath, samePath, } from '../schemas/paths.js';
9
9
  import { WORKSPACE_SCHEMA, validateWorkspaceConfigSemantics, workspaceConfigSchema, } from '../schemas/workspace.js';
10
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';
11
14
  import { observePath, resolveRealpath, sha256Digest } from '../infra/fs.js';
12
15
  import { inspectMachineConfig, inspectWorkspaceConfig, } from '../workspace/config.js';
13
16
  import { discoverWorkspace, } from '../workspace/discover.js';
14
17
  import { resolveWorkspaceRoot } from '../workspace/resolve.js';
15
18
  import { buildInitPlan } from '../install/plan.js';
19
+ import { backupPath, ensureStatePathBoundary, readWorkspaceManifest, writeWorkspaceManifest, } from '../install/ownership.js';
16
20
  import { beginManagedApply, completeManagedApply, failManagedApply, recordManagedFile, } from '../install/apply.js';
17
21
  const workspaceConfigRelativePath = '.pappra/workspace.json';
18
22
  const machineConfigRelativePath = '.pappra/local/machine.json';
@@ -239,6 +243,164 @@ export function listKnowledgeCandidates(discovery) {
239
243
  }
240
244
  return [...candidates.values()].sort((left, right) => left.path.localeCompare(right.path));
241
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
+ }
242
404
  function selectRepositories(choices, discovery, root) {
243
405
  const diagnostics = [];
244
406
  const byKey = new Map();
@@ -505,22 +667,38 @@ function scopeRepositories(config, discovery) {
505
667
  };
506
668
  });
507
669
  }
508
- function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig) {
670
+ function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig, existing) {
509
671
  const workspaceBytes = needWorkspaceConfig ? jsonBytes(config) : null;
510
672
  const machineBytes = needMachineConfig ? jsonBytes(machine) : null;
511
673
  const files = [];
512
- if (workspaceBytes !== null) {
674
+ if (workspaceBytes !== null &&
675
+ existing.workspaceDigest !== sha256Digest(workspaceBytes)) {
513
676
  files.push({
514
677
  path: workspaceConfigRelativePath,
515
678
  bytes: workspaceBytes,
516
679
  beforeText: null,
680
+ ...(existing.workspaceDigest === null
681
+ ? {}
682
+ : {
683
+ operation: 'replace-managed',
684
+ expectedBeforeDigest: existing.workspaceDigest,
685
+ backupRequired: true,
686
+ }),
517
687
  });
518
688
  }
519
- if (machineBytes !== null) {
689
+ if (machineBytes !== null &&
690
+ existing.machineDigest !== sha256Digest(machineBytes)) {
520
691
  files.push({
521
692
  path: machineConfigRelativePath,
522
693
  bytes: machineBytes,
523
694
  beforeText: null,
695
+ ...(existing.machineDigest === null
696
+ ? {}
697
+ : {
698
+ operation: 'replace-managed',
699
+ expectedBeforeDigest: existing.machineDigest,
700
+ backupRequired: true,
701
+ }),
524
702
  });
525
703
  }
526
704
  return { files, workspaceBytes, machineBytes };
@@ -626,14 +804,16 @@ export async function planWorkspaceInit(options) {
626
804
  const existingMachine = context.machineInspection.value;
627
805
  const diagnostics = [...context.diagnostics];
628
806
  const runtimeVersion = options.runtimeVersion ??
629
- existingConfig?.runtimeVersion ??
807
+ (options.reconfigure === true
808
+ ? undefined
809
+ : existingConfig?.runtimeVersion) ??
630
810
  getPackageInfo().version;
631
811
  let config;
632
812
  let machine;
633
813
  let needWorkspaceConfig = false;
634
814
  let needMachineConfig = false;
635
815
  let selected = [];
636
- if (existingConfig !== null) {
816
+ if (existingConfig !== null && options.reconfigure !== true) {
637
817
  config = existingConfig;
638
818
  if (choices !== null) {
639
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.'));
@@ -655,7 +835,10 @@ export async function planWorkspaceInit(options) {
655
835
  if (choices === null) {
656
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.');
657
837
  }
658
- const built = buildWorkspaceConfig(context.root, choices, context.discovery, runtimeVersion);
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);
659
842
  diagnostics.push(...built.diagnostics);
660
843
  if (built.config === null) {
661
844
  return failureFromDiagnostics('invalid', diagnostics, diagnosticCodes.initInvalidInput, 'Init selections do not form a valid workspace configuration.');
@@ -685,7 +868,10 @@ export async function planWorkspaceInit(options) {
685
868
  machine = parsedMachine.data;
686
869
  }
687
870
  }
688
- const filePlan = planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig);
871
+ const filePlan = planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig, {
872
+ workspaceDigest: context.workspaceInspection.digest,
873
+ machineDigest: context.machineInspection.digest,
874
+ });
689
875
  const configurationDigest = filePlan.workspaceBytes !== null
690
876
  ? sha256Digest(filePlan.workspaceBytes)
691
877
  : (context.workspaceInspection.digest ??
@@ -734,7 +920,9 @@ export async function planWorkspaceInit(options) {
734
920
  workspaceId: config.workspaceId,
735
921
  name: config.name,
736
922
  runtimeVersion: config.runtimeVersion,
737
- configSource: existingConfig !== null ? 'existing' : 'planned',
923
+ configSource: existingConfig !== null && options.reconfigure !== true
924
+ ? 'existing'
925
+ : 'planned',
738
926
  repositories: scopeRepos,
739
927
  knowledge: config.knowledge,
740
928
  code: config.code,
@@ -863,13 +1051,23 @@ async function verifyBundleWrites(bundle) {
863
1051
  hint: 'Regenerate the plan.',
864
1052
  }));
865
1053
  }
866
- if (file.operation !== 'create') {
1054
+ if (file.operation !== 'create' && file.operation !== 'replace-managed') {
867
1055
  diagnostics.push(createDiagnostic({
868
1056
  code: diagnosticCodes.initApplyUnsupported,
869
1057
  severity: 'error',
870
- message: `P03 apply supports only create operations; ${file.path} is ${file.operation}.`,
1058
+ message: `Init apply does not support operation ${file.operation} for ${file.path}.`,
871
1059
  source: 'init plan',
872
- hint: 'P04 will implement managed replacement with a journal and rollback.',
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.',
873
1071
  }));
874
1072
  }
875
1073
  if (sha256Digest(write.bytes) !== file.nextDigest) {
@@ -944,16 +1142,22 @@ export async function applyInitPlan(bundle, options = {}) {
944
1142
  const workspaceInspection = await inspectWorkspaceConfig(root, {
945
1143
  missing: 'info',
946
1144
  });
947
- const workspaceFilePlanned = bundle.plan.files.some((file) => file.path === workspaceConfigRelativePath);
948
- if (workspaceFilePlanned) {
949
- if (workspaceInspection.exists) {
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) {
950
1155
  return applyFailure('conflict', diagnosticCodes.initTargetExists, 'Workspace configuration appeared after planning.', 'Remove or relocate the unexpected file, then retry init.');
951
1156
  }
952
- }
953
- else if (!workspaceInspection.exists ||
954
- workspaceInspection.value === null ||
955
- workspaceInspection.digest !== bundle.plan.configurationDigest) {
956
- return planDiagnostics('Workspace configuration changed since planning.', diagnosticCodes.initPlanStale);
1157
+ if (workspaceFile.operation === 'replace-managed' &&
1158
+ workspaceInspection.digest !== workspaceFile.expectedBeforeDigest) {
1159
+ return planDiagnostics('Workspace configuration changed since planning.', diagnosticCodes.initPlanStale);
1160
+ }
957
1161
  }
958
1162
  for (const file of bundle.plan.files) {
959
1163
  const target = await safeTarget(root, file.path);
@@ -961,29 +1165,28 @@ export async function applyInitPlan(bundle, options = {}) {
961
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.');
962
1166
  }
963
1167
  const observation = await observePath(target.absolutePath);
964
- if (observation.kind !== 'missing') {
965
- return applyFailure('conflict', diagnosticCodes.initTargetExists, `Planned target already exists: ${file.path}`, 'Remove or relocate the unexpected file, then retry init.');
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);
966
1181
  }
967
1182
  }
968
- const managedStart = await beginManagedApply(root, bundle.plan, bundle.writes, bundle.rootIdentity);
969
- if (!managedStart.ok) {
970
- const status = managedStart.status === 'invalid'
971
- ? 'stale'
972
- : managedStart.status === 'locked'
973
- ? 'conflict'
974
- : 'failed';
975
- return {
976
- ok: false,
977
- status,
978
- appliedFiles: [],
979
- pendingExternalActions: [],
980
- residualPaths: managedStart.residualPaths,
981
- diagnostics: managedStart.diagnostics,
982
- };
983
- }
984
- const managed = managedStart.context;
985
- let created = [];
986
- let createdDirectories = [];
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;
987
1190
  const ensureDirectory = async (directory) => {
988
1191
  const missing = [];
989
1192
  let current = directory;
@@ -1008,6 +1211,19 @@ export async function applyInitPlan(bundle, options = {}) {
1008
1211
  createdDirectories.push(path);
1009
1212
  }
1010
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
+ };
1011
1227
  const rootBeforeWrite = await observePath(root);
1012
1228
  if (rootBeforeWrite.kind !== 'directory' ||
1013
1229
  rootBeforeWrite.stats === null ||
@@ -1016,73 +1232,169 @@ export async function applyInitPlan(bundle, options = {}) {
1016
1232
  return applyFailure('stale', diagnosticCodes.initPlanStale, 'Workspace root identity changed before writing.', 'Retry init from the current workspace root; no files were written.');
1017
1233
  }
1018
1234
  try {
1019
- for (const [index, file] of bundle.plan.files.entries()) {
1235
+ for (const index of replaceIndexes) {
1236
+ const file = bundle.plan.files[index];
1020
1237
  const write = bundle.writes[index];
1021
- if (write === undefined) {
1022
- throw new Error(`Missing planned bytes for ${file.path}.`);
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}`);
1023
1248
  }
1024
- const target = await safeTarget(root, file.path);
1025
- if (target === null) {
1026
- throw new Error(`Unsafe target: ${file.path}`);
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}`);
1027
1259
  }
1028
- await options.beforeWrite?.(file.path, index);
1029
- await ensureDirectory(dirname(target.absolutePath));
1030
- const handle = await open(target.absolutePath, 'wx');
1031
- const record = {
1260
+ replacements.push({
1032
1261
  path: file.path,
1033
- digest: file.nextDigest,
1034
- dev: -1,
1035
- ino: -1,
1036
- };
1037
- created.push(record);
1038
- try {
1039
- const stats = await handle.stat();
1040
- record.dev = stats.dev;
1041
- record.ino = stats.ino;
1042
- await handle.writeFile(write.bytes);
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.');
1043
1275
  }
1044
- finally {
1045
- await handle.close().catch(() => undefined);
1276
+ if (manifestRead.value === null) {
1277
+ throw new Error('Workspace install manifest is missing; Pappra does not adopt an unowned replacement.');
1046
1278
  }
1047
- const written = await readFile(target.absolutePath);
1048
- if (sha256Digest(written) !== file.nextDigest) {
1049
- throw new Error(`Written digest mismatch for ${file.path}.`);
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;
1050
1300
  }
1051
- const recorded = await recordManagedFile(managed, file.path, file.nextDigest, {
1052
- dev: record.dev,
1053
- ino: record.ino,
1301
+ const written = await writeWorkspaceManifest(root, {
1302
+ ...manifestRead.value,
1303
+ entries,
1054
1304
  });
1055
- if (!recorded.ok) {
1056
- throw new Error(recorded.diagnostics[0]?.message ?? `Cannot record ${file.path}.`);
1057
- }
1305
+ if (!written.ok)
1306
+ throw new Error(written.message);
1058
1307
  }
1059
- const completed = await completeManagedApply(managed);
1060
- if (!completed.ok) {
1061
- const residual = await cleanupCreatedFiles(root, created);
1062
- const failed = await failManagedApply(managed, 'Cannot finalize managed installation manifest.', residual);
1063
- return {
1064
- ok: false,
1065
- status: 'failed',
1066
- appliedFiles: [],
1067
- pendingExternalActions: [],
1068
- residualPaths: failed.residualPaths,
1069
- diagnostics: failed.diagnostics,
1308
+ if (createIndexes.length > 0) {
1309
+ const createPlan = {
1310
+ ...bundle.plan,
1311
+ files: createIndexes.map((index) => bundle.plan.files[index]),
1070
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
+ }
1071
1378
  }
1379
+ const appliedFiles = [
1380
+ ...created.map((file) => file.path),
1381
+ ...replacements.map((replacement) => replacement.path),
1382
+ ];
1072
1383
  return {
1073
1384
  ok: true,
1074
1385
  status: bundle.plan.externalActions.length > 0
1075
1386
  ? 'external-actions-pending'
1076
- : created.length === 0
1387
+ : appliedFiles.length === 0
1077
1388
  ? 'no-changes'
1078
1389
  : 'applied',
1079
- appliedFiles: created.map((file) => file.path),
1390
+ appliedFiles,
1080
1391
  pendingExternalActions: bundle.plan.externalActions,
1081
- residualPaths: completed.residualPaths,
1082
- diagnostics: completed.diagnostics,
1392
+ residualPaths: [],
1393
+ diagnostics: [],
1083
1394
  };
1084
1395
  }
1085
1396
  catch (error) {
1397
+ await restoreReplacements();
1086
1398
  const residual = await cleanupCreatedFiles(root, created);
1087
1399
  for (const directory of createdDirectories.reverse()) {
1088
1400
  await rmdir(directory).catch(() => undefined);
@@ -1098,6 +1410,25 @@ export async function applyInitPlan(bundle, options = {}) {
1098
1410
  const residualNote = residual.length === 0
1099
1411
  ? 'No planned file from this attempt remains.'
1100
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
+ }
1101
1432
  const failed = await failManagedApply(managed, `${base}. ${residualNote}`, residual);
1102
1433
  return {
1103
1434
  ok: false,
@@ -7,5 +7,6 @@ export interface DocsCommandOptions {
7
7
  readonly json: boolean;
8
8
  readonly includePrivate: boolean;
9
9
  readonly includeArchived: boolean;
10
+ readonly apply: boolean;
10
11
  }
11
12
  export declare function runDocsCommand(options: DocsCommandOptions, output: CliOutput): Promise<number>;