@phyzess/pappra 0.1.0-beta.3 → 0.1.0-beta.4

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.
package/README.md CHANGED
@@ -46,6 +46,13 @@ npx @phyzess/pappra@beta runtime status --json
46
46
  npx @phyzess/pappra@beta runtime verify --json
47
47
  ```
48
48
 
49
+ A workspace that already has a valid `.pappra/workspace.json` is never rewritten silently.
50
+ `init --yes --reconfigure --apply` rebuilds it from discovery (for example to move the
51
+ recorded runtime version to the running package), backing the previous file up under
52
+ `.pappra/local/backups`. Apply also reports advisory `nextActions`: `pappra code prepare`
53
+ for a selected code engine and `pappra docs scan --apply` to register documents from the
54
+ configured knowledge sources.
55
+
49
56
  Optional examples have prerequisites rather than generated files: code-graph and
50
57
  host commands require the selected external CLI/support, and collaboration
51
58
  requires an explicit controller. They are not needed for the core workflow above.
@@ -56,7 +63,7 @@ requires an explicit controller. They are not needed for the core workflow above
56
63
  - **Knowledge:** workspace and private document catalogs with explicit preview/apply operations.
57
64
  - **Continuity:** private task checkpoints and bounded advisory resume packets. Resume never grants execution by itself.
58
65
  - **Code intelligence:** status, explicit preparation and bounded query for selected repositories. Text/structure fallback is available when the optional graph engine is absent.
59
- - **Host integrations:** optional Pi, Codex and Claude Code adapters that merge Pappra-owned entries without taking over user configuration. Hooks are copied as a dependency-free bundle into the host root; `host bundle` exports a native plugin/package directory without registering it.
66
+ - **Host integrations:** optional Pi, Codex and Claude Code adapters that merge Pappra-owned entries without taking over user configuration. Hooks are copied as a dependency-free bundle into the host root; `host bundle` exports a native plugin/package directory without registering it. `host install --mode plugin` fails closed until plugin registration and callability are verified.
60
67
  - **Bounded collaboration:** an optional, explicitly controlled one-request workflow. It is not a scheduler or daemon.
61
68
 
62
69
  ## Optional integrations and support
@@ -80,7 +87,7 @@ The check runs the source policy, type checks, formatting, deterministic tests a
80
87
 
81
88
  ## Status and limits
82
89
 
83
- Pappra is development software (`0.1.0-beta.3`) published as `@phyzess/pappra` under the `beta` dist-tag. Releases are published from GitHub Actions with npm trusted publishing (OIDC), so no npm token is stored in this repository. Pin either the moving `beta` tag or an exact version such as `@phyzess/pappra@0.1.0-beta.3`, because pre-release versions are never republished.
90
+ Pappra is development software (`0.1.0-beta.4`) published as `@phyzess/pappra` under the `beta` dist-tag. Releases are published from GitHub Actions with npm trusted publishing (OIDC), so no npm token is stored in this repository. Pin either the moving `beta` tag or an exact version such as `@phyzess/pappra@0.1.0-beta.4`, because pre-release versions are never republished.
84
91
 
85
92
  The deterministic suite covers synthetic workspaces, fixtures and local process boundaries. It does not establish real-model behavior, native host callability on every platform, CodeGraph completeness, Windows/Linux parity or production performance. A host adapter version pin is an observed support claim, not proof that a particular native installation will load or run correctly.
86
93
 
@@ -116,6 +116,8 @@ export interface PlanInitOptions {
116
116
  readonly root: string;
117
117
  readonly choices?: InitChoices | null;
118
118
  readonly runtimeVersion?: string;
119
+ /** Rebuilds workspace.json from selections even when a valid config exists. */
120
+ readonly reconfigure?: boolean;
119
121
  readonly planId?: string;
120
122
  readonly discoveredAt?: string;
121
123
  /** Reuse an immediately preceding inspectInitContext result. */
package/dist/app/init.js CHANGED
@@ -1,6 +1,6 @@
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
6
  import { INIT_CHOICES_SCHEMA, initPlanSchema, parseInitChoices, } from '../schemas/init.js';
@@ -16,6 +16,7 @@ import { inspectMachineConfig, inspectWorkspaceConfig, } from '../workspace/conf
16
16
  import { discoverWorkspace, } from '../workspace/discover.js';
17
17
  import { resolveWorkspaceRoot } from '../workspace/resolve.js';
18
18
  import { buildInitPlan } from '../install/plan.js';
19
+ import { backupPath, ensureStatePathBoundary, readWorkspaceManifest, writeWorkspaceManifest, } from '../install/ownership.js';
19
20
  import { beginManagedApply, completeManagedApply, failManagedApply, recordManagedFile, } from '../install/apply.js';
20
21
  const workspaceConfigRelativePath = '.pappra/workspace.json';
21
22
  const machineConfigRelativePath = '.pappra/local/machine.json';
@@ -666,22 +667,38 @@ function scopeRepositories(config, discovery) {
666
667
  };
667
668
  });
668
669
  }
669
- function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig) {
670
+ function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig, existing) {
670
671
  const workspaceBytes = needWorkspaceConfig ? jsonBytes(config) : null;
671
672
  const machineBytes = needMachineConfig ? jsonBytes(machine) : null;
672
673
  const files = [];
673
- if (workspaceBytes !== null) {
674
+ if (workspaceBytes !== null &&
675
+ existing.workspaceDigest !== sha256Digest(workspaceBytes)) {
674
676
  files.push({
675
677
  path: workspaceConfigRelativePath,
676
678
  bytes: workspaceBytes,
677
679
  beforeText: null,
680
+ ...(existing.workspaceDigest === null
681
+ ? {}
682
+ : {
683
+ operation: 'replace-managed',
684
+ expectedBeforeDigest: existing.workspaceDigest,
685
+ backupRequired: true,
686
+ }),
678
687
  });
679
688
  }
680
- if (machineBytes !== null) {
689
+ if (machineBytes !== null &&
690
+ existing.machineDigest !== sha256Digest(machineBytes)) {
681
691
  files.push({
682
692
  path: machineConfigRelativePath,
683
693
  bytes: machineBytes,
684
694
  beforeText: null,
695
+ ...(existing.machineDigest === null
696
+ ? {}
697
+ : {
698
+ operation: 'replace-managed',
699
+ expectedBeforeDigest: existing.machineDigest,
700
+ backupRequired: true,
701
+ }),
685
702
  });
686
703
  }
687
704
  return { files, workspaceBytes, machineBytes };
@@ -787,14 +804,16 @@ export async function planWorkspaceInit(options) {
787
804
  const existingMachine = context.machineInspection.value;
788
805
  const diagnostics = [...context.diagnostics];
789
806
  const runtimeVersion = options.runtimeVersion ??
790
- existingConfig?.runtimeVersion ??
807
+ (options.reconfigure === true
808
+ ? undefined
809
+ : existingConfig?.runtimeVersion) ??
791
810
  getPackageInfo().version;
792
811
  let config;
793
812
  let machine;
794
813
  let needWorkspaceConfig = false;
795
814
  let needMachineConfig = false;
796
815
  let selected = [];
797
- if (existingConfig !== null) {
816
+ if (existingConfig !== null && options.reconfigure !== true) {
798
817
  config = existingConfig;
799
818
  if (choices !== null) {
800
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.'));
@@ -816,7 +835,10 @@ export async function planWorkspaceInit(options) {
816
835
  if (choices === null) {
817
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.');
818
837
  }
819
- 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);
820
842
  diagnostics.push(...built.diagnostics);
821
843
  if (built.config === null) {
822
844
  return failureFromDiagnostics('invalid', diagnostics, diagnosticCodes.initInvalidInput, 'Init selections do not form a valid workspace configuration.');
@@ -846,7 +868,10 @@ export async function planWorkspaceInit(options) {
846
868
  machine = parsedMachine.data;
847
869
  }
848
870
  }
849
- 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
+ });
850
875
  const configurationDigest = filePlan.workspaceBytes !== null
851
876
  ? sha256Digest(filePlan.workspaceBytes)
852
877
  : (context.workspaceInspection.digest ??
@@ -895,7 +920,9 @@ export async function planWorkspaceInit(options) {
895
920
  workspaceId: config.workspaceId,
896
921
  name: config.name,
897
922
  runtimeVersion: config.runtimeVersion,
898
- configSource: existingConfig !== null ? 'existing' : 'planned',
923
+ configSource: existingConfig !== null && options.reconfigure !== true
924
+ ? 'existing'
925
+ : 'planned',
899
926
  repositories: scopeRepos,
900
927
  knowledge: config.knowledge,
901
928
  code: config.code,
@@ -1024,13 +1051,23 @@ async function verifyBundleWrites(bundle) {
1024
1051
  hint: 'Regenerate the plan.',
1025
1052
  }));
1026
1053
  }
1027
- if (file.operation !== 'create') {
1054
+ if (file.operation !== 'create' && file.operation !== 'replace-managed') {
1028
1055
  diagnostics.push(createDiagnostic({
1029
1056
  code: diagnosticCodes.initApplyUnsupported,
1030
1057
  severity: 'error',
1031
- 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}.`,
1032
1059
  source: 'init plan',
1033
- 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.',
1034
1071
  }));
1035
1072
  }
1036
1073
  if (sha256Digest(write.bytes) !== file.nextDigest) {
@@ -1105,16 +1142,22 @@ export async function applyInitPlan(bundle, options = {}) {
1105
1142
  const workspaceInspection = await inspectWorkspaceConfig(root, {
1106
1143
  missing: 'info',
1107
1144
  });
1108
- const workspaceFilePlanned = bundle.plan.files.some((file) => file.path === workspaceConfigRelativePath);
1109
- if (workspaceFilePlanned) {
1110
- 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) {
1111
1155
  return applyFailure('conflict', diagnosticCodes.initTargetExists, 'Workspace configuration appeared after planning.', 'Remove or relocate the unexpected file, then retry init.');
1112
1156
  }
1113
- }
1114
- else if (!workspaceInspection.exists ||
1115
- workspaceInspection.value === null ||
1116
- workspaceInspection.digest !== bundle.plan.configurationDigest) {
1117
- 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
+ }
1118
1161
  }
1119
1162
  for (const file of bundle.plan.files) {
1120
1163
  const target = await safeTarget(root, file.path);
@@ -1122,29 +1165,28 @@ export async function applyInitPlan(bundle, options = {}) {
1122
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.');
1123
1166
  }
1124
1167
  const observation = await observePath(target.absolutePath);
1125
- if (observation.kind !== 'missing') {
1126
- 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);
1127
1181
  }
1128
1182
  }
1129
- const managedStart = await beginManagedApply(root, bundle.plan, bundle.writes, bundle.rootIdentity);
1130
- if (!managedStart.ok) {
1131
- const status = managedStart.status === 'invalid'
1132
- ? 'stale'
1133
- : managedStart.status === 'locked'
1134
- ? 'conflict'
1135
- : 'failed';
1136
- return {
1137
- ok: false,
1138
- status,
1139
- appliedFiles: [],
1140
- pendingExternalActions: [],
1141
- residualPaths: managedStart.residualPaths,
1142
- diagnostics: managedStart.diagnostics,
1143
- };
1144
- }
1145
- const managed = managedStart.context;
1146
- let created = [];
1147
- 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;
1148
1190
  const ensureDirectory = async (directory) => {
1149
1191
  const missing = [];
1150
1192
  let current = directory;
@@ -1169,6 +1211,19 @@ export async function applyInitPlan(bundle, options = {}) {
1169
1211
  createdDirectories.push(path);
1170
1212
  }
1171
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
+ };
1172
1227
  const rootBeforeWrite = await observePath(root);
1173
1228
  if (rootBeforeWrite.kind !== 'directory' ||
1174
1229
  rootBeforeWrite.stats === null ||
@@ -1177,73 +1232,169 @@ export async function applyInitPlan(bundle, options = {}) {
1177
1232
  return applyFailure('stale', diagnosticCodes.initPlanStale, 'Workspace root identity changed before writing.', 'Retry init from the current workspace root; no files were written.');
1178
1233
  }
1179
1234
  try {
1180
- for (const [index, file] of bundle.plan.files.entries()) {
1235
+ for (const index of replaceIndexes) {
1236
+ const file = bundle.plan.files[index];
1181
1237
  const write = bundle.writes[index];
1182
- if (write === undefined) {
1183
- 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}`);
1184
1248
  }
1185
- const target = await safeTarget(root, file.path);
1186
- if (target === null) {
1187
- 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}`);
1188
1259
  }
1189
- await options.beforeWrite?.(file.path, index);
1190
- await ensureDirectory(dirname(target.absolutePath));
1191
- const handle = await open(target.absolutePath, 'wx');
1192
- const record = {
1260
+ replacements.push({
1193
1261
  path: file.path,
1194
- digest: file.nextDigest,
1195
- dev: -1,
1196
- ino: -1,
1197
- };
1198
- created.push(record);
1199
- try {
1200
- const stats = await handle.stat();
1201
- record.dev = stats.dev;
1202
- record.ino = stats.ino;
1203
- 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.');
1204
1275
  }
1205
- finally {
1206
- 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.');
1207
1278
  }
1208
- const written = await readFile(target.absolutePath);
1209
- if (sha256Digest(written) !== file.nextDigest) {
1210
- 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;
1211
1300
  }
1212
- const recorded = await recordManagedFile(managed, file.path, file.nextDigest, {
1213
- dev: record.dev,
1214
- ino: record.ino,
1301
+ const written = await writeWorkspaceManifest(root, {
1302
+ ...manifestRead.value,
1303
+ entries,
1215
1304
  });
1216
- if (!recorded.ok) {
1217
- throw new Error(recorded.diagnostics[0]?.message ?? `Cannot record ${file.path}.`);
1218
- }
1305
+ if (!written.ok)
1306
+ throw new Error(written.message);
1219
1307
  }
1220
- const completed = await completeManagedApply(managed);
1221
- if (!completed.ok) {
1222
- const residual = await cleanupCreatedFiles(root, created);
1223
- const failed = await failManagedApply(managed, 'Cannot finalize managed installation manifest.', residual);
1224
- return {
1225
- ok: false,
1226
- status: 'failed',
1227
- appliedFiles: [],
1228
- pendingExternalActions: [],
1229
- residualPaths: failed.residualPaths,
1230
- diagnostics: failed.diagnostics,
1308
+ if (createIndexes.length > 0) {
1309
+ const createPlan = {
1310
+ ...bundle.plan,
1311
+ files: createIndexes.map((index) => bundle.plan.files[index]),
1231
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
+ }
1232
1378
  }
1379
+ const appliedFiles = [
1380
+ ...created.map((file) => file.path),
1381
+ ...replacements.map((replacement) => replacement.path),
1382
+ ];
1233
1383
  return {
1234
1384
  ok: true,
1235
1385
  status: bundle.plan.externalActions.length > 0
1236
1386
  ? 'external-actions-pending'
1237
- : created.length === 0
1387
+ : appliedFiles.length === 0
1238
1388
  ? 'no-changes'
1239
1389
  : 'applied',
1240
- appliedFiles: created.map((file) => file.path),
1390
+ appliedFiles,
1241
1391
  pendingExternalActions: bundle.plan.externalActions,
1242
- residualPaths: completed.residualPaths,
1243
- diagnostics: completed.diagnostics,
1392
+ residualPaths: [],
1393
+ diagnostics: [],
1244
1394
  };
1245
1395
  }
1246
1396
  catch (error) {
1397
+ await restoreReplacements();
1247
1398
  const residual = await cleanupCreatedFiles(root, created);
1248
1399
  for (const directory of createdDirectories.reverse()) {
1249
1400
  await rmdir(directory).catch(() => undefined);
@@ -1259,6 +1410,25 @@ export async function applyInitPlan(bundle, options = {}) {
1259
1410
  const residualNote = residual.length === 0
1260
1411
  ? 'No planned file from this attempt remains.'
1261
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
+ }
1262
1432
  const failed = await failManagedApply(managed, `${base}. ${residualNote}`, residual);
1263
1433
  return {
1264
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>;
@@ -1,11 +1,106 @@
1
+ import { discoverWorkspace } from '../../workspace/discover.js';
1
2
  import { resolveWorkspaceRoot } from '../../workspace/resolve.js';
2
3
  import { formatDiagnostic } from '../../schemas/diagnostics.js';
3
4
  import { docsArchiveReportSchema, docsCheckReportSchema, docsListReportSchema, } from '../../schemas/document.js';
4
5
  import { KnowledgeService, } from '../../knowledge/index.js';
5
- export const docsUsage = 'pappra docs <list|check|archive> [options]\n' +
6
+ export const docsUsage = 'pappra docs <list|check|archive|scan> [options]\n' +
6
7
  ' list [--include-private] [--include-archived] [--json]\n' +
7
8
  ' check [--include-private] [--json]\n' +
8
- ' archive <documentId> [--json]';
9
+ ' archive <documentId> [--json]\n' +
10
+ ' scan [--apply] [--json]\n\n' +
11
+ 'scan registers documents discovered under the configured knowledge sources; without\n' +
12
+ '--apply it only reports the candidates.';
13
+ function classifyScanKind(path) {
14
+ const lower = path.toLowerCase();
15
+ if (lower.endsWith('agents.md') || lower.endsWith('claude.md'))
16
+ return 'rule';
17
+ if (lower.includes('/research/'))
18
+ return 'research';
19
+ if (lower.includes('/adr/') || lower.includes('decision'))
20
+ return 'decision';
21
+ if (lower.includes('review') || lower.includes('retro'))
22
+ return 'review';
23
+ if (lower.includes('plan'))
24
+ return 'plan';
25
+ return 'architecture';
26
+ }
27
+ async function runDocsScan(options, output, root) {
28
+ const discovery = await discoverWorkspace(root);
29
+ const config = discovery.config;
30
+ if (config === null) {
31
+ return invalidArguments(output, options.json, 'docs scan requires a valid .pappra/workspace.json; run pappra init first.');
32
+ }
33
+ const service = new KnowledgeService(root);
34
+ const listed = await service.list({ includePrivate: true });
35
+ const registered = new Set(listed.records.flatMap((record) => record.location.kind === 'workspace' ? [record.location.path] : []));
36
+ const sources = config.knowledge.sources;
37
+ const visibility = config.knowledge.taskVisibility === 'shared' ? 'shareable' : 'private';
38
+ const candidates = (discovery.documents ?? []).flatMap((document) => {
39
+ const path = document.workspaceRelativePath;
40
+ if (path === null)
41
+ return [];
42
+ if (!sources.some((source) => path === source || path.startsWith(`${source}/`))) {
43
+ return [];
44
+ }
45
+ if (registered.has(path))
46
+ return [];
47
+ return [{ path, kind: classifyScanKind(path), visibility }];
48
+ });
49
+ if (!options.apply) {
50
+ if (options.json) {
51
+ output.stdout(`${JSON.stringify({
52
+ ok: true,
53
+ schema: 'pappra/docs-scan/1',
54
+ mode: 'plan',
55
+ workspaceRoot: root,
56
+ candidates,
57
+ registered: registered.size,
58
+ nextActions: [
59
+ 'Rerun with --apply to register these documents in the knowledge catalog.',
60
+ ],
61
+ })}\n`);
62
+ }
63
+ else {
64
+ output.stdout(`Scan candidates: ${candidates.length}\n`);
65
+ for (const candidate of candidates) {
66
+ output.stdout(` ${candidate.kind} ${candidate.path}\n`);
67
+ }
68
+ output.stdout('Plan only; rerun with --apply to register them.\n');
69
+ }
70
+ return 0;
71
+ }
72
+ const registeredNow = [];
73
+ const diagnostics = [];
74
+ for (const candidate of candidates) {
75
+ const result = await service.register({
76
+ location: { kind: 'workspace', path: candidate.path },
77
+ kind: candidate.kind,
78
+ maturity: 'draft',
79
+ owner: 'human',
80
+ visibility: candidate.visibility,
81
+ });
82
+ diagnostics.push(...result.diagnostics);
83
+ if (result.ok)
84
+ registeredNow.push(result.record);
85
+ }
86
+ const ok = diagnostics.every((entry) => entry.severity !== 'error');
87
+ if (options.json) {
88
+ output.stdout(`${JSON.stringify({
89
+ ok,
90
+ schema: 'pappra/docs-scan/1',
91
+ mode: 'apply',
92
+ workspaceRoot: root,
93
+ registered: registeredNow,
94
+ skipped: candidates.length - registeredNow.length,
95
+ diagnostics,
96
+ })}\n`);
97
+ }
98
+ else {
99
+ output.stdout(`Registered ${registeredNow.length} document(s).\n`);
100
+ printDiagnostics(output, diagnostics);
101
+ }
102
+ return ok ? 0 : 1;
103
+ }
9
104
  function printDiagnostics(output, diagnostics) {
10
105
  for (const diagnostic of diagnostics) {
11
106
  output.stderr(`${formatDiagnostic(diagnostic)}\n`);
@@ -55,7 +150,7 @@ export async function runDocsCommand(options, output) {
55
150
  }
56
151
  const resolution = await resolveWorkspaceRoot({ startPath: options.cwd });
57
152
  const serviceRoot = resolution.root ?? options.cwd;
58
- if (!['list', 'check', 'archive'].includes(options.action)) {
153
+ if (!['list', 'check', 'archive', 'scan'].includes(options.action)) {
59
154
  return invalidArguments(output, options.json, `unknown docs action: ${options.action}`);
60
155
  }
61
156
  if (options.action === 'archive' && options.argument === null) {
@@ -64,6 +159,12 @@ export async function runDocsCommand(options, output) {
64
159
  if (options.action !== 'archive' && options.argument !== null) {
65
160
  return invalidArguments(output, options.json, `docs ${options.action} does not accept an argument`);
66
161
  }
162
+ if (options.action === 'scan') {
163
+ return runDocsScan(options, output, serviceRoot);
164
+ }
165
+ if (options.apply) {
166
+ return invalidArguments(output, options.json, 'only docs scan accepts --apply');
167
+ }
67
168
  if (options.action === 'archive' && options.argument !== null) {
68
169
  const service = new KnowledgeService(serviceRoot);
69
170
  const result = await service.archive(options.argument);
@@ -10,5 +10,6 @@ export interface HostCommandOptions {
10
10
  readonly hostVersion: string | null;
11
11
  readonly apply: boolean;
12
12
  readonly out: string | null;
13
+ readonly mode: 'project-settings' | 'plugin' | null;
13
14
  }
14
15
  export declare function runHostCommand(options: HostCommandOptions, output: CliOutput): Promise<number>;
@@ -5,7 +5,8 @@ import { exportHostPluginBundle } from '../../hosts/integration/bundle-export.js
5
5
  import { HOST_SUPPORT, isSupportedVersion, } from '../../hosts/integration/support.js';
6
6
  import { getPackageInfo } from '../../package-info.js';
7
7
  export const hostUsage = 'pappra host <detect|describe|plan|install|verify|bundle> --host <pi|codex|claude>\n' +
8
- ' [--host-root <dir>] [--host-version <version>] [--out <dir>] [--apply] [--json]\n' +
8
+ ' [--host-root <dir>] [--host-version <version>] [--out <dir>]\n' +
9
+ ' [--mode project-settings|plugin] [--apply] [--json]\n' +
9
10
  '\n' +
10
11
  'bundle exports a host-native plugin/package directory and never registers it or\n' +
11
12
  'claims host callability.';
@@ -102,6 +103,7 @@ export async function runHostCommand(options, output) {
102
103
  const result = await service.install(options.host, {
103
104
  ...target,
104
105
  apply: options.apply,
106
+ ...(options.mode === null ? {} : { mode: options.mode }),
105
107
  });
106
108
  if (options.json) {
107
109
  output.stdout(`${JSON.stringify(result)}\n`);
@@ -38,6 +38,8 @@ export interface InitCommandOptions {
38
38
  readonly printChoices?: boolean;
39
39
  /** Leaves the runtime action pending instead of installing a durable copy. */
40
40
  readonly noRuntime?: boolean;
41
+ /** Rebuilds workspace.json from the selections even when a valid config exists. */
42
+ readonly reconfigure?: boolean;
41
43
  }
42
44
  export interface RuntimeFlowResult {
43
45
  readonly ok: boolean;
@@ -586,12 +586,26 @@ function printApply(output, result, runtimeRequested) {
586
586
  : 'Only planned config files were applied. Initialization is not complete until runtime/provider actions are authorized and executed.\n');
587
587
  }
588
588
  }
589
+ function advisoryNextActions(bundle) {
590
+ const actions = [];
591
+ const repositoryId = bundle.scope.repositories[0]?.id;
592
+ if (bundle.scope.code.graph !== 'none' &&
593
+ bundle.scope.repositories.length > 0 &&
594
+ repositoryId !== undefined) {
595
+ actions.push(`pappra code prepare --repo ${repositoryId} --apply # register the ${bundle.scope.code.graph} index for this checkout`);
596
+ }
597
+ if (bundle.scope.knowledge.sources.length > 0) {
598
+ actions.push('pappra docs scan --apply # register discovered knowledge documents in the catalog');
599
+ }
600
+ return actions;
601
+ }
589
602
  function planJson(bundle) {
590
603
  return {
591
604
  command: 'init',
592
605
  mode: 'plan',
593
606
  plan: bundle.plan,
594
607
  scope: bundle.scope,
608
+ nextActions: advisoryNextActions(bundle),
595
609
  diagnostics: bundle.diagnostics,
596
610
  };
597
611
  }
@@ -716,6 +730,7 @@ export async function runInitCommand(options, output, deps = {}) {
716
730
  root: context.root,
717
731
  choices,
718
732
  context,
733
+ ...(options.reconfigure === true ? { reconfigure: true } : {}),
719
734
  });
720
735
  if (!prepared.ok)
721
736
  return writeFailure(output, options, prepared);
@@ -748,6 +763,7 @@ export async function runInitCommand(options, output, deps = {}) {
748
763
  apply: applied,
749
764
  runtime: runtimeFlow?.runtime ?? null,
750
765
  initComplete,
766
+ nextActions: advisoryNextActions(bundle),
751
767
  diagnostics: [
752
768
  ...bundle.diagnostics,
753
769
  ...(runtimeFlow?.diagnostics ?? []),
@@ -814,6 +830,9 @@ export async function runInitCommand(options, output, deps = {}) {
814
830
  return 1;
815
831
  }
816
832
  output.stdout('Durable runtime copy installed and bound. Initialization complete.\n');
833
+ for (const action of advisoryNextActions(bundle)) {
834
+ output.stdout(` next: ${action}\n`);
835
+ }
817
836
  return 0;
818
837
  }
819
838
  return applied.status === 'external-actions-pending' ? 1 : 0;
package/dist/cli/run.js CHANGED
@@ -20,9 +20,11 @@ function parseOptions(args) {
20
20
  apply: { type: 'boolean' },
21
21
  yes: { type: 'boolean', short: 'y' },
22
22
  'no-runtime': { type: 'boolean' },
23
+ reconfigure: { type: 'boolean' },
23
24
  from: { type: 'string' },
24
25
  tarball: { type: 'string' },
25
26
  out: { type: 'string' },
27
+ mode: { type: 'string' },
26
28
  bind: { type: 'boolean' },
27
29
  force: { type: 'boolean' },
28
30
  'no-interactive': { type: 'boolean' },
@@ -182,7 +184,11 @@ function parseOptions(args) {
182
184
  };
183
185
  }
184
186
  function failure(output, json, code, error) {
185
- const message = error instanceof Error ? error.message : 'Operation failed.';
187
+ const message = typeof error === 'string'
188
+ ? error
189
+ : error instanceof Error
190
+ ? error.message
191
+ : 'Operation failed.';
186
192
  if (json) {
187
193
  output.stdout(`${JSON.stringify({ ok: false, code, message })}\n`);
188
194
  }
@@ -230,18 +236,32 @@ export async function runCli(args, output) {
230
236
  }
231
237
  const { values, command, docsAction, docsArgument, codeAction, hostAction, collaborationAction, resumeTaskId, runtimeAction, } = parsed;
232
238
  const json = values.json === true;
239
+ const applyRequested = values.apply === true;
233
240
  if (command !== 'init' &&
234
241
  (values.yes === true ||
235
242
  values['no-interactive'] === true ||
236
243
  values['print-choices'] === true ||
237
- values['no-runtime'] === true)) {
238
- failure(output, json, 'invalid_arguments', '--yes, --no-interactive, --print-choices and --no-runtime are only valid for init.');
244
+ values['no-runtime'] === true ||
245
+ values.reconfigure === true)) {
246
+ failure(output, json, 'invalid_arguments', '--yes, --no-interactive, --print-choices, --no-runtime and --reconfigure are only valid for init.');
239
247
  return 2;
240
248
  }
241
249
  if (command !== 'host' && values.out !== undefined) {
242
250
  failure(output, json, 'invalid_arguments', '--out is only valid for host.');
243
251
  return 2;
244
252
  }
253
+ if (command !== 'host' &&
254
+ values.mode !== undefined &&
255
+ command !== 'runtime') {
256
+ failure(output, json, 'invalid_arguments', '--mode is only valid for host.');
257
+ return 2;
258
+ }
259
+ if (values.mode !== undefined &&
260
+ values.mode !== 'project-settings' &&
261
+ values.mode !== 'plugin') {
262
+ failure(output, json, 'invalid_arguments', '--mode accepts project-settings or plugin.');
263
+ return 2;
264
+ }
245
265
  if (command !== 'runtime' &&
246
266
  (values.from !== undefined ||
247
267
  values.tarball !== undefined ||
@@ -284,7 +304,6 @@ export async function runCli(args, output) {
284
304
  if (action === null ||
285
305
  values.version ||
286
306
  values.config !== undefined ||
287
- values.apply === true ||
288
307
  values['install-runtime'] === true ||
289
308
  values['runtime-tarball'] !== undefined ||
290
309
  values['runtime-version'] !== undefined ||
@@ -309,13 +328,12 @@ export async function runCli(args, output) {
309
328
  if (command === 'docs') {
310
329
  if (values.version ||
311
330
  values.config !== undefined ||
312
- values.apply === true ||
313
331
  values['install-runtime'] === true ||
314
332
  values['runtime-tarball'] !== undefined ||
315
333
  values['runtime-version'] !== undefined ||
316
334
  values.root !== undefined ||
317
335
  hasContinuityOptions(values)) {
318
- failure(output, json, 'invalid_arguments', 'docs only accepts list/check/archive, --include-private, --include-archived, --help, and --json.');
336
+ failure(output, json, 'invalid_arguments', 'docs only accepts list/check/archive/scan, --include-private, --include-archived, --apply (scan only), --help, and --json.');
319
337
  return 2;
320
338
  }
321
339
  if (values.help) {
@@ -334,6 +352,7 @@ export async function runCli(args, output) {
334
352
  json,
335
353
  includePrivate: values['include-private'] === true,
336
354
  includeArchived: values['include-archived'] === true,
355
+ apply: applyRequested,
337
356
  }, output);
338
357
  }
339
358
  if (command === 'doctor') {
@@ -395,6 +414,7 @@ export async function runCli(args, output) {
395
414
  yes: values.yes === true || values['no-interactive'] === true,
396
415
  printChoices: values['print-choices'] === true,
397
416
  noRuntime: values['no-runtime'] === true,
417
+ reconfigure: values.reconfigure === true,
398
418
  }, output);
399
419
  }
400
420
  if (command === 'runtime') {
@@ -638,6 +658,7 @@ export async function runCli(args, output) {
638
658
  values.rebuild === true ||
639
659
  hasContinuityOptions(values) ||
640
660
  (values.apply === true && action !== 'install') ||
661
+ (values.mode !== undefined && action !== 'install') ||
641
662
  (values['host-root'] !== undefined && action === null) ||
642
663
  (values['host-version'] !== undefined && action === null);
643
664
  if (values.help) {
@@ -662,6 +683,9 @@ export async function runCli(args, output) {
662
683
  hostVersion: values['host-version'] ?? null,
663
684
  apply: values.apply === true,
664
685
  out: values.out ?? null,
686
+ mode: values.mode === 'project-settings' || values.mode === 'plugin'
687
+ ? values.mode
688
+ : null,
665
689
  }, output);
666
690
  }
667
691
  if (command !== null) {
@@ -14,6 +14,12 @@ export interface HostTargetOptions {
14
14
  readonly hostVersion?: string | null;
15
15
  /** Rewrites a manifest previously pinned to a package checkout path. */
16
16
  readonly migrate?: boolean;
17
+ /**
18
+ * Plugin wiring is exported with `host bundle` but not installed here: host
19
+ * registration, trust and callability are unverified, so this mode fails
20
+ * closed instead of writing a bundle a host may never load.
21
+ */
22
+ readonly mode?: 'project-settings' | 'plugin';
17
23
  }
18
24
  export interface HostPlanBundle {
19
25
  readonly plan: HostIntegrationPlan;
@@ -884,6 +884,19 @@ export class HostIntegrationService {
884
884
  return planFromFiles(host, this.#root, hostRoot, target.expression, built.bundle.bundleDigest, built.files);
885
885
  }
886
886
  async install(host, options) {
887
+ if (options.mode === 'plugin') {
888
+ return hostIntegrationResultSchema.parse({
889
+ schema: 'pappra/host-integration/1',
890
+ ok: false,
891
+ status: 'unsupported',
892
+ plan: null,
893
+ appliedFiles: [],
894
+ residualPaths: [],
895
+ diagnostics: [
896
+ diagnostic(diagnosticCodes.host_unavailable, 'Plugin install mode is not wired: host registration, trust and callability are unverified.', this.#root, 'Use `pappra host bundle --host <name> --out <dir>` to export the plugin directory and register it yourself; project-settings install remains the verified mode.'),
897
+ ],
898
+ });
899
+ }
887
900
  let plan;
888
901
  let contents;
889
902
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phyzess/pappra",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Portable workspace knowledge and continuity for coding agents.",
5
5
  "publishConfig": {
6
6
  "access": "public"