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

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 --marketplace` exports a local marketplace that Claude Code and Codex accept, without registering it. `host install --mode plugin` fails closed until plugin registration and callability are verified in a real session.
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.5`) 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.5`, 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,7 @@ 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;
14
+ readonly marketplace: boolean;
13
15
  }
14
16
  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`);
@@ -131,6 +133,7 @@ export async function runHostCommand(options, output) {
131
133
  hostVersion: options.hostVersion ?? HOST_SUPPORT[options.host].version,
132
134
  outDir: options.out,
133
135
  packageVersion: getPackageInfo().version,
136
+ marketplace: options.marketplace,
134
137
  });
135
138
  if (options.json) {
136
139
  output.stdout(`${JSON.stringify({ ok: true, command: 'host', action: 'bundle', ...exported })}\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,12 @@ 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' },
28
+ marketplace: { type: 'boolean' },
26
29
  bind: { type: 'boolean' },
27
30
  force: { type: 'boolean' },
28
31
  'no-interactive': { type: 'boolean' },
@@ -182,7 +185,11 @@ function parseOptions(args) {
182
185
  };
183
186
  }
184
187
  function failure(output, json, code, error) {
185
- const message = error instanceof Error ? error.message : 'Operation failed.';
188
+ const message = typeof error === 'string'
189
+ ? error
190
+ : error instanceof Error
191
+ ? error.message
192
+ : 'Operation failed.';
186
193
  if (json) {
187
194
  output.stdout(`${JSON.stringify({ ok: false, code, message })}\n`);
188
195
  }
@@ -230,18 +237,36 @@ export async function runCli(args, output) {
230
237
  }
231
238
  const { values, command, docsAction, docsArgument, codeAction, hostAction, collaborationAction, resumeTaskId, runtimeAction, } = parsed;
232
239
  const json = values.json === true;
240
+ const applyRequested = values.apply === true;
233
241
  if (command !== 'init' &&
234
242
  (values.yes === true ||
235
243
  values['no-interactive'] === true ||
236
244
  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.');
245
+ values['no-runtime'] === true ||
246
+ values.reconfigure === true)) {
247
+ failure(output, json, 'invalid_arguments', '--yes, --no-interactive, --print-choices, --no-runtime and --reconfigure are only valid for init.');
239
248
  return 2;
240
249
  }
241
250
  if (command !== 'host' && values.out !== undefined) {
242
251
  failure(output, json, 'invalid_arguments', '--out is only valid for host.');
243
252
  return 2;
244
253
  }
254
+ if (command !== 'host' && values.marketplace === true) {
255
+ failure(output, json, 'invalid_arguments', '--marketplace is only valid for host bundle.');
256
+ return 2;
257
+ }
258
+ if (command !== 'host' &&
259
+ values.mode !== undefined &&
260
+ command !== 'runtime') {
261
+ failure(output, json, 'invalid_arguments', '--mode is only valid for host.');
262
+ return 2;
263
+ }
264
+ if (values.mode !== undefined &&
265
+ values.mode !== 'project-settings' &&
266
+ values.mode !== 'plugin') {
267
+ failure(output, json, 'invalid_arguments', '--mode accepts project-settings or plugin.');
268
+ return 2;
269
+ }
245
270
  if (command !== 'runtime' &&
246
271
  (values.from !== undefined ||
247
272
  values.tarball !== undefined ||
@@ -284,7 +309,6 @@ export async function runCli(args, output) {
284
309
  if (action === null ||
285
310
  values.version ||
286
311
  values.config !== undefined ||
287
- values.apply === true ||
288
312
  values['install-runtime'] === true ||
289
313
  values['runtime-tarball'] !== undefined ||
290
314
  values['runtime-version'] !== undefined ||
@@ -309,13 +333,12 @@ export async function runCli(args, output) {
309
333
  if (command === 'docs') {
310
334
  if (values.version ||
311
335
  values.config !== undefined ||
312
- values.apply === true ||
313
336
  values['install-runtime'] === true ||
314
337
  values['runtime-tarball'] !== undefined ||
315
338
  values['runtime-version'] !== undefined ||
316
339
  values.root !== undefined ||
317
340
  hasContinuityOptions(values)) {
318
- failure(output, json, 'invalid_arguments', 'docs only accepts list/check/archive, --include-private, --include-archived, --help, and --json.');
341
+ failure(output, json, 'invalid_arguments', 'docs only accepts list/check/archive/scan, --include-private, --include-archived, --apply (scan only), --help, and --json.');
319
342
  return 2;
320
343
  }
321
344
  if (values.help) {
@@ -334,6 +357,7 @@ export async function runCli(args, output) {
334
357
  json,
335
358
  includePrivate: values['include-private'] === true,
336
359
  includeArchived: values['include-archived'] === true,
360
+ apply: applyRequested,
337
361
  }, output);
338
362
  }
339
363
  if (command === 'doctor') {
@@ -395,6 +419,7 @@ export async function runCli(args, output) {
395
419
  yes: values.yes === true || values['no-interactive'] === true,
396
420
  printChoices: values['print-choices'] === true,
397
421
  noRuntime: values['no-runtime'] === true,
422
+ reconfigure: values.reconfigure === true,
398
423
  }, output);
399
424
  }
400
425
  if (command === 'runtime') {
@@ -638,6 +663,8 @@ export async function runCli(args, output) {
638
663
  values.rebuild === true ||
639
664
  hasContinuityOptions(values) ||
640
665
  (values.apply === true && action !== 'install') ||
666
+ (values.mode !== undefined && action !== 'install') ||
667
+ (values.marketplace === true && action !== 'bundle') ||
641
668
  (values['host-root'] !== undefined && action === null) ||
642
669
  (values['host-version'] !== undefined && action === null);
643
670
  if (values.help) {
@@ -662,6 +689,10 @@ export async function runCli(args, output) {
662
689
  hostVersion: values['host-version'] ?? null,
663
690
  apply: values.apply === true,
664
691
  out: values.out ?? null,
692
+ mode: values.mode === 'project-settings' || values.mode === 'plugin'
693
+ ? values.mode
694
+ : null,
695
+ marketplace: values.marketplace === true,
665
696
  }, output);
666
697
  }
667
698
  if (command !== null) {
@@ -19,4 +19,6 @@ export declare function exportHostPluginBundle(options: {
19
19
  readonly hostVersion: string;
20
20
  readonly outDir: string;
21
21
  readonly packageVersion: string;
22
+ /** Writes a marketplace root with the plugin under `pappra/`. */
23
+ readonly marketplace?: boolean;
22
24
  }): Promise<HostPluginBundle>;
@@ -10,24 +10,15 @@ function pluginHookExpression(host) {
10
10
  : './hooks/pappra-hook.mjs';
11
11
  }
12
12
  function hostManifest(host, version) {
13
- if (host === 'claude') {
13
+ if (host === 'claude' || host === 'codex') {
14
+ // Codex reads the Claude plugin manifest path, so one manifest serves both.
14
15
  return {
15
16
  path: '.claude-plugin/plugin.json',
16
17
  value: {
17
18
  name: 'pappra',
18
19
  version,
19
20
  description: 'Portable workspace knowledge and continuity for coding agents.',
20
- hooks: './hooks/hooks.json',
21
- },
22
- };
23
- }
24
- if (host === 'codex') {
25
- return {
26
- path: 'plugin.json',
27
- value: {
28
- name: 'pappra',
29
- version,
30
- description: 'Portable workspace knowledge and continuity for coding agents.',
21
+ author: { name: 'phyzess' },
31
22
  hooks: './hooks/hooks.json',
32
23
  },
33
24
  };
@@ -53,15 +44,20 @@ function hostManifest(host, version) {
53
44
  * load or trust it.
54
45
  */
55
46
  export async function exportHostPluginBundle(options) {
56
- const outDir = resolve(options.outDir);
47
+ const outRoot = resolve(options.outDir);
48
+ if (options.marketplace === true && options.host === 'pi') {
49
+ throw new Error('Pi installs a directory directly; a marketplace wrapper is not supported.');
50
+ }
51
+ const pluginPrefix = options.marketplace === true ? 'pappra/' : '';
52
+ const pluginRoot = options.marketplace === true ? join(outRoot, 'pappra') : outRoot;
57
53
  const payload = await loadHostBundle(options.host, options.packageVersion, {
58
54
  hookSource: join(getPackageRoot(), 'dist/hosts/integration/pappra-hook.mjs'),
59
55
  hookExpression: pluginHookExpression(options.host),
60
56
  });
61
57
  const written = [];
62
58
  const writeEntry = async (path, bytes) => {
63
- const target = join(outDir, ...path.split('/'));
64
- if (!isPathInside(outDir, target)) {
59
+ const target = join(outRoot, ...path.split('/'));
60
+ if (!isPathInside(outRoot, target)) {
65
61
  throw new Error(`Unsafe plugin bundle path: ${path}`);
66
62
  }
67
63
  await mkdir(dirname(target), { recursive: true });
@@ -73,7 +69,7 @@ export async function exportHostPluginBundle(options) {
73
69
  // templates are only an intermediate artifact.
74
70
  if (options.host !== 'pi' && path.startsWith('templates/'))
75
71
  continue;
76
- await writeEntry(path, bytes);
72
+ await writeEntry(`${pluginPrefix}${path}`, bytes);
77
73
  }
78
74
  const expression = pluginHookExpression(options.host);
79
75
  if (options.host === 'pi') {
@@ -81,20 +77,35 @@ export async function exportHostPluginBundle(options) {
81
77
  if (entry === undefined) {
82
78
  throw new Error('Pi plugin bundle is missing its extension entry.');
83
79
  }
84
- await writeEntry('extensions/pappra/index.ts', entry);
80
+ await writeEntry(`${pluginPrefix}extensions/pappra/index.ts`, entry);
85
81
  }
86
82
  else {
87
83
  const patch = renderHookConfigPatch(options.host, expression);
88
- await writeEntry('hooks/hooks.json', Buffer.from(`${JSON.stringify(patch, null, 2)}\n`));
84
+ await writeEntry(`${pluginPrefix}hooks/hooks.json`, Buffer.from(`${JSON.stringify(patch, null, 2)}\n`));
89
85
  }
90
86
  const manifest = hostManifest(options.host, options.packageVersion);
91
- await writeEntry(manifest.path, Buffer.from(`${JSON.stringify(manifest.value, null, 2)}\n`));
87
+ await writeEntry(`${pluginPrefix}${manifest.path}`, Buffer.from(`${JSON.stringify(manifest.value, null, 2)}\n`));
88
+ if (options.marketplace === true) {
89
+ await writeEntry('.claude-plugin/marketplace.json', Buffer.from(`${JSON.stringify({
90
+ name: 'pappra-local',
91
+ owner: { name: 'phyzess' },
92
+ description: 'Local Pappra plugin marketplace exported by pappra host bundle.',
93
+ plugins: [
94
+ {
95
+ name: 'pappra',
96
+ source: './pappra',
97
+ description: 'Portable workspace knowledge and continuity for coding agents.',
98
+ author: { name: 'phyzess' },
99
+ },
100
+ ],
101
+ }, null, 2)}\n`));
102
+ }
92
103
  return {
93
104
  host: options.host,
94
- outDir,
105
+ outDir: pluginRoot,
95
106
  files: written.sort((left, right) => left.localeCompare(right)),
96
- pluginManifest: manifest.path,
97
- hookConfig: options.host === 'pi' ? null : 'hooks/hooks.json',
107
+ pluginManifest: `${pluginPrefix}${manifest.path}`,
108
+ hookConfig: options.host === 'pi' ? null : `${pluginPrefix}hooks/hooks.json`,
98
109
  registration: options.host === 'claude'
99
110
  ? [
100
111
  'Register the directory as a local plugin or marketplace entry; Pappra does not add it to host settings.',
@@ -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.5",
4
4
  "description": "Portable workspace knowledge and continuity for coding agents.",
5
5
  "publishConfig": {
6
6
  "access": "public"