@productbrain/cli 0.1.0-beta.107 → 0.1.0-beta.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/dist/__tests__/canonicalRefs.vocab.test.d.ts +2 -0
  2. package/dist/__tests__/canonicalRefs.vocab.test.d.ts.map +1 -0
  3. package/dist/__tests__/canonicalRefs.vocab.test.js +251 -0
  4. package/dist/__tests__/canonicalRefs.vocab.test.js.map +1 -0
  5. package/dist/__tests__/constants.test.js +6 -1
  6. package/dist/__tests__/constants.test.js.map +1 -1
  7. package/dist/__tests__/envelope-contract.test.js +29 -3
  8. package/dist/__tests__/envelope-contract.test.js.map +1 -1
  9. package/dist/__tests__/errors.test.js +1 -0
  10. package/dist/__tests__/errors.test.js.map +1 -1
  11. package/dist/__tests__/handshake-preview.test.js +501 -1
  12. package/dist/__tests__/handshake-preview.test.js.map +1 -1
  13. package/dist/__tests__/handshake.e2e.test.d.ts +2 -0
  14. package/dist/__tests__/handshake.e2e.test.d.ts.map +1 -0
  15. package/dist/__tests__/handshake.e2e.test.js +257 -0
  16. package/dist/__tests__/handshake.e2e.test.js.map +1 -0
  17. package/dist/__tests__/handshake.test.js +251 -1
  18. package/dist/__tests__/handshake.test.js.map +1 -1
  19. package/dist/__tests__/manifest.test.js +118 -1
  20. package/dist/__tests__/manifest.test.js.map +1 -1
  21. package/dist/__tests__/orient.test.js +65 -24
  22. package/dist/__tests__/orient.test.js.map +1 -1
  23. package/dist/__tests__/perimeter.test.d.ts +2 -0
  24. package/dist/__tests__/perimeter.test.d.ts.map +1 -0
  25. package/dist/__tests__/perimeter.test.js +143 -0
  26. package/dist/__tests__/perimeter.test.js.map +1 -0
  27. package/dist/__tests__/promote.test.js +2 -2
  28. package/dist/__tests__/promote.test.js.map +1 -1
  29. package/dist/__tests__/setup-ingest.test.js +16 -0
  30. package/dist/__tests__/setup-ingest.test.js.map +1 -1
  31. package/dist/__tests__/skill-vocabulary.test.d.ts +21 -0
  32. package/dist/__tests__/skill-vocabulary.test.d.ts.map +1 -0
  33. package/dist/__tests__/skill-vocabulary.test.js +187 -0
  34. package/dist/__tests__/skill-vocabulary.test.js.map +1 -0
  35. package/dist/__tests__/update-check.test.d.ts +2 -0
  36. package/dist/__tests__/update-check.test.d.ts.map +1 -0
  37. package/dist/__tests__/update-check.test.js +56 -0
  38. package/dist/__tests__/update-check.test.js.map +1 -0
  39. package/dist/__tests__/upgrade-runner.test.d.ts +2 -0
  40. package/dist/__tests__/upgrade-runner.test.d.ts.map +1 -0
  41. package/dist/__tests__/upgrade-runner.test.js +42 -0
  42. package/dist/__tests__/upgrade-runner.test.js.map +1 -0
  43. package/dist/__tests__/vocabulary-leak.test.d.ts +25 -0
  44. package/dist/__tests__/vocabulary-leak.test.d.ts.map +1 -0
  45. package/dist/__tests__/vocabulary-leak.test.js +379 -0
  46. package/dist/__tests__/vocabulary-leak.test.js.map +1 -0
  47. package/dist/commands/__tests__/connect-handoff.test.d.ts +11 -0
  48. package/dist/commands/__tests__/connect-handoff.test.d.ts.map +1 -0
  49. package/dist/commands/__tests__/connect-handoff.test.js +111 -0
  50. package/dist/commands/__tests__/connect-handoff.test.js.map +1 -0
  51. package/dist/commands/__tests__/setup-state.test.d.ts +2 -0
  52. package/dist/commands/__tests__/setup-state.test.d.ts.map +1 -0
  53. package/dist/commands/__tests__/setup-state.test.js +194 -0
  54. package/dist/commands/__tests__/setup-state.test.js.map +1 -0
  55. package/dist/commands/admin/seed.d.ts +32 -2
  56. package/dist/commands/admin/seed.d.ts.map +1 -1
  57. package/dist/commands/admin/seed.js +279 -33
  58. package/dist/commands/admin/seed.js.map +1 -1
  59. package/dist/commands/admin/seed.test.js +7 -0
  60. package/dist/commands/admin/seed.test.js.map +1 -1
  61. package/dist/commands/admin/seedRegistryEntries.generated.d.ts +14 -0
  62. package/dist/commands/admin/seedRegistryEntries.generated.d.ts.map +1 -0
  63. package/dist/commands/admin/seedRegistryEntries.generated.js +117 -0
  64. package/dist/commands/admin/seedRegistryEntries.generated.js.map +1 -0
  65. package/dist/commands/admin/seedRegistryEntries.test.d.ts +11 -0
  66. package/dist/commands/admin/seedRegistryEntries.test.d.ts.map +1 -0
  67. package/dist/commands/admin/seedRegistryEntries.test.js +67 -0
  68. package/dist/commands/admin/seedRegistryEntries.test.js.map +1 -0
  69. package/dist/commands/authority-domains.d.ts +7 -1
  70. package/dist/commands/authority-domains.d.ts.map +1 -1
  71. package/dist/commands/authority-domains.js +17 -3
  72. package/dist/commands/authority-domains.js.map +1 -1
  73. package/dist/commands/capture.d.ts.map +1 -1
  74. package/dist/commands/capture.js +3 -2
  75. package/dist/commands/capture.js.map +1 -1
  76. package/dist/commands/codex-prep.js +6 -6
  77. package/dist/commands/codex-prep.js.map +1 -1
  78. package/dist/commands/connect-handoff.d.ts +51 -0
  79. package/dist/commands/connect-handoff.d.ts.map +1 -0
  80. package/dist/commands/connect-handoff.js +70 -0
  81. package/dist/commands/connect-handoff.js.map +1 -0
  82. package/dist/commands/connect-integration.test.js +13 -12
  83. package/dist/commands/connect-integration.test.js.map +1 -1
  84. package/dist/commands/connect-screens.d.ts +2 -2
  85. package/dist/commands/connect-screens.js +2 -2
  86. package/dist/commands/connect.d.ts +3 -6
  87. package/dist/commands/connect.d.ts.map +1 -1
  88. package/dist/commands/connect.js +10 -36
  89. package/dist/commands/connect.js.map +1 -1
  90. package/dist/commands/doctor.d.ts.map +1 -1
  91. package/dist/commands/doctor.js +67 -2
  92. package/dist/commands/doctor.js.map +1 -1
  93. package/dist/commands/doctor.test.js +131 -0
  94. package/dist/commands/doctor.test.js.map +1 -1
  95. package/dist/commands/handshake.d.ts +25 -0
  96. package/dist/commands/handshake.d.ts.map +1 -1
  97. package/dist/commands/handshake.js +795 -18
  98. package/dist/commands/handshake.js.map +1 -1
  99. package/dist/commands/method.d.ts.map +1 -1
  100. package/dist/commands/method.js +3 -0
  101. package/dist/commands/method.js.map +1 -1
  102. package/dist/commands/orient.d.ts +4 -2
  103. package/dist/commands/orient.d.ts.map +1 -1
  104. package/dist/commands/orient.js +16 -2
  105. package/dist/commands/orient.js.map +1 -1
  106. package/dist/commands/setup-detect-surfaces.d.ts +38 -0
  107. package/dist/commands/setup-detect-surfaces.d.ts.map +1 -0
  108. package/dist/commands/setup-detect-surfaces.js +67 -0
  109. package/dist/commands/setup-detect-surfaces.js.map +1 -0
  110. package/dist/commands/setup-ingest.d.ts.map +1 -1
  111. package/dist/commands/setup-ingest.js +4 -2
  112. package/dist/commands/setup-ingest.js.map +1 -1
  113. package/dist/commands/setup-state.d.ts +42 -0
  114. package/dist/commands/setup-state.d.ts.map +1 -0
  115. package/dist/commands/setup-state.js +93 -0
  116. package/dist/commands/setup-state.js.map +1 -0
  117. package/dist/commands/setup.d.ts +17 -9
  118. package/dist/commands/setup.d.ts.map +1 -1
  119. package/dist/commands/setup.js +52 -131
  120. package/dist/commands/setup.js.map +1 -1
  121. package/dist/commands/upgrade.d.ts +5 -0
  122. package/dist/commands/upgrade.d.ts.map +1 -0
  123. package/dist/commands/upgrade.js +89 -0
  124. package/dist/commands/upgrade.js.map +1 -0
  125. package/dist/formatters/handshake.d.ts +12 -0
  126. package/dist/formatters/handshake.d.ts.map +1 -1
  127. package/dist/formatters/handshake.js +32 -0
  128. package/dist/formatters/handshake.js.map +1 -1
  129. package/dist/formatters/orient.d.ts +10 -4
  130. package/dist/formatters/orient.d.ts.map +1 -1
  131. package/dist/formatters/orient.js +32 -16
  132. package/dist/formatters/orient.js.map +1 -1
  133. package/dist/generators/context-md.js +6 -6
  134. package/dist/generators/context-md.js.map +1 -1
  135. package/dist/generators/manifest.d.ts +76 -0
  136. package/dist/generators/manifest.d.ts.map +1 -1
  137. package/dist/generators/manifest.js +125 -14
  138. package/dist/generators/manifest.js.map +1 -1
  139. package/dist/generators/portable-knowledge.d.ts +2 -2
  140. package/dist/generators/portable-knowledge.d.ts.map +1 -1
  141. package/dist/generators/surface-profiles.d.ts +1 -2
  142. package/dist/generators/surface-profiles.d.ts.map +1 -1
  143. package/dist/generators/surface-profiles.js.map +1 -1
  144. package/dist/index.js +43 -18
  145. package/dist/index.js.map +1 -1
  146. package/dist/lib/activation.js +2 -2
  147. package/dist/lib/activation.js.map +1 -1
  148. package/dist/lib/activation.test.js +3 -3
  149. package/dist/lib/activation.test.js.map +1 -1
  150. package/dist/lib/canonicalRefs.d.ts +72 -0
  151. package/dist/lib/canonicalRefs.d.ts.map +1 -1
  152. package/dist/lib/canonicalRefs.js +67 -0
  153. package/dist/lib/canonicalRefs.js.map +1 -1
  154. package/dist/lib/client.d.ts.map +1 -1
  155. package/dist/lib/client.js +13 -3
  156. package/dist/lib/client.js.map +1 -1
  157. package/dist/lib/constants.d.ts +2 -0
  158. package/dist/lib/constants.d.ts.map +1 -1
  159. package/dist/lib/constants.js +2 -0
  160. package/dist/lib/constants.js.map +1 -1
  161. package/dist/lib/errors.d.ts +1 -0
  162. package/dist/lib/errors.d.ts.map +1 -1
  163. package/dist/lib/errors.js +1 -0
  164. package/dist/lib/errors.js.map +1 -1
  165. package/dist/lib/onboarding-shared.js +1 -1
  166. package/dist/lib/onboarding-shared.js.map +1 -1
  167. package/dist/lib/update-check.d.ts +20 -0
  168. package/dist/lib/update-check.d.ts.map +1 -1
  169. package/dist/lib/update-check.js +122 -21
  170. package/dist/lib/update-check.js.map +1 -1
  171. package/dist/lib/upgrade-runner.d.ts +21 -0
  172. package/dist/lib/upgrade-runner.d.ts.map +1 -0
  173. package/dist/lib/upgrade-runner.js +109 -0
  174. package/dist/lib/upgrade-runner.js.map +1 -0
  175. package/dist/lib/workspaceVocabCache.d.ts +60 -0
  176. package/dist/lib/workspaceVocabCache.d.ts.map +1 -0
  177. package/dist/lib/workspaceVocabCache.js +98 -0
  178. package/dist/lib/workspaceVocabCache.js.map +1 -0
  179. package/dist/setup/__tests__/coach-traces.test.d.ts +2 -0
  180. package/dist/setup/__tests__/coach-traces.test.d.ts.map +1 -0
  181. package/dist/setup/__tests__/coach-traces.test.js +189 -0
  182. package/dist/setup/__tests__/coach-traces.test.js.map +1 -0
  183. package/dist/setup/__tests__/setup-commands.test.d.ts +2 -0
  184. package/dist/setup/__tests__/setup-commands.test.d.ts.map +1 -0
  185. package/dist/setup/__tests__/setup-commands.test.js +177 -0
  186. package/dist/setup/__tests__/setup-commands.test.js.map +1 -0
  187. package/dist/setup/__tests__/state-machine.test.d.ts +2 -0
  188. package/dist/setup/__tests__/state-machine.test.d.ts.map +1 -0
  189. package/dist/setup/__tests__/state-machine.test.js +341 -0
  190. package/dist/setup/__tests__/state-machine.test.js.map +1 -0
  191. package/dist/setup/detect-surfaces.d.ts +21 -0
  192. package/dist/setup/detect-surfaces.d.ts.map +1 -0
  193. package/dist/setup/detect-surfaces.js +39 -0
  194. package/dist/setup/detect-surfaces.js.map +1 -0
  195. package/dist/setup/manifest-writer.d.ts +17 -0
  196. package/dist/setup/manifest-writer.d.ts.map +1 -0
  197. package/dist/setup/manifest-writer.js +153 -0
  198. package/dist/setup/manifest-writer.js.map +1 -0
  199. package/dist/setup/perimeter.d.ts +62 -0
  200. package/dist/setup/perimeter.d.ts.map +1 -0
  201. package/dist/setup/perimeter.js +113 -0
  202. package/dist/setup/perimeter.js.map +1 -0
  203. package/dist/setup/state-machine.d.ts +67 -0
  204. package/dist/setup/state-machine.d.ts.map +1 -0
  205. package/dist/setup/state-machine.js +124 -0
  206. package/dist/setup/state-machine.js.map +1 -0
  207. package/dist/surfaces/__tests__/adapter.test.d.ts +2 -0
  208. package/dist/surfaces/__tests__/adapter.test.d.ts.map +1 -0
  209. package/dist/surfaces/__tests__/adapter.test.js +90 -0
  210. package/dist/surfaces/__tests__/adapter.test.js.map +1 -0
  211. package/dist/surfaces/__tests__/pb-setup-passthrough.test.d.ts +2 -0
  212. package/dist/surfaces/__tests__/pb-setup-passthrough.test.d.ts.map +1 -0
  213. package/dist/surfaces/__tests__/pb-setup-passthrough.test.js +132 -0
  214. package/dist/surfaces/__tests__/pb-setup-passthrough.test.js.map +1 -0
  215. package/dist/surfaces/__tests__/telemetry.test.d.ts +2 -0
  216. package/dist/surfaces/__tests__/telemetry.test.d.ts.map +1 -0
  217. package/dist/surfaces/__tests__/telemetry.test.js +55 -0
  218. package/dist/surfaces/__tests__/telemetry.test.js.map +1 -0
  219. package/dist/surfaces/adapter.d.ts +70 -0
  220. package/dist/surfaces/adapter.d.ts.map +1 -0
  221. package/dist/surfaces/adapter.js +2 -0
  222. package/dist/surfaces/adapter.js.map +1 -0
  223. package/dist/surfaces/adapters/claude.d.ts +3 -0
  224. package/dist/surfaces/adapters/claude.d.ts.map +1 -0
  225. package/dist/surfaces/adapters/claude.js +67 -0
  226. package/dist/surfaces/adapters/claude.js.map +1 -0
  227. package/dist/surfaces/adapters/codex.d.ts +3 -0
  228. package/dist/surfaces/adapters/codex.d.ts.map +1 -0
  229. package/dist/surfaces/adapters/codex.js +61 -0
  230. package/dist/surfaces/adapters/codex.js.map +1 -0
  231. package/dist/surfaces/adapters/copilot.d.ts +3 -0
  232. package/dist/surfaces/adapters/copilot.d.ts.map +1 -0
  233. package/dist/surfaces/adapters/copilot.js +59 -0
  234. package/dist/surfaces/adapters/copilot.js.map +1 -0
  235. package/dist/surfaces/adapters/cursor.d.ts +3 -0
  236. package/dist/surfaces/adapters/cursor.d.ts.map +1 -0
  237. package/dist/surfaces/adapters/cursor.js +78 -0
  238. package/dist/surfaces/adapters/cursor.js.map +1 -0
  239. package/dist/surfaces/registry.d.ts +58 -2
  240. package/dist/surfaces/registry.d.ts.map +1 -1
  241. package/dist/surfaces/registry.js +82 -7
  242. package/dist/surfaces/registry.js.map +1 -1
  243. package/dist/surfaces/telemetry.d.ts +17 -0
  244. package/dist/surfaces/telemetry.d.ts.map +1 -0
  245. package/dist/surfaces/telemetry.js +31 -0
  246. package/dist/surfaces/telemetry.js.map +1 -0
  247. package/package.json +2 -1
@@ -8,7 +8,7 @@ import { homedir } from 'os';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { createHash } from 'crypto';
10
10
  import { getConfigOrGuide } from '../lib/config.js';
11
- import { select as promptSelect } from '../lib/prompts.js';
11
+ import { select as promptSelect, confirm as promptConfirm } from '../lib/prompts.js';
12
12
  import { composeHooksFromIntents, getHookStatusForSurface } from '../lib/hook-intents.js';
13
13
  import { kernelCall, kernelCallWithSession } from '../lib/client.js';
14
14
  import { readSession } from '../lib/session.js';
@@ -21,12 +21,19 @@ import { generateChainRules } from '../generators/chain-rules.js';
21
21
  import { saveHandshakeState, loadPreviousState, diffHandshakeState, formatDiff, buildCurrentState, } from '../generators/handshake-diff.js';
22
22
  import { resolveSurfaceProfile } from '../generators/surface-profiles.js';
23
23
  import { formatHandshakeReport } from '../formatters/handshake.js';
24
- import { readManifest, filterByAdoptionState } from '../generators/manifest.js';
24
+ import { readManifest, readManifestStatus, filterByAdoptionState } from '../generators/manifest.js';
25
25
  import { generateBoundaryManifest, getBoundaryEnforcementMode } from '../generators/boundary-manifest.js';
26
26
  import { loadMethodRegistry } from '../lib/method-registry.js';
27
27
  import { CLIError, ErrorCode } from '../lib/errors.js';
28
28
  import { trackEvent } from '../lib/telemetry.js';
29
+ import { replaceVocabTokens } from '../lib/canonicalRefs.js';
30
+ // WP-436 S3: vocab projector — resolves {{vocab:...}} tokens before writing to disk.
31
+ import { getOrFetchVocabCtx } from '../lib/workspaceVocabCache.js';
29
32
  import { normalizeMaterializedFilename } from '../lib/normalizeMaterializedFilename.js';
33
+ import { assertSetupWritePath } from '../setup/perimeter.js';
34
+ // WP-421 S3: SurfaceAdapter reverse-map for tampered prompts (DEC-952, doneWhen #34).
35
+ import { canonicalPathForAnySurface, SURFACE_GOVERN_NO_SURFACES, SURFACE_REGISTRY, validateSurfacesForMode, } from '../surfaces/registry.js';
36
+ import { getReverseMapFallbackMessage, reportReverseMapMissing } from '../surfaces/telemetry.js';
30
37
  const MAX_HANDSHAKE_WAIT_MS = 10_000; // 10 seconds
31
38
  const POLL_INTERVAL_MS = 500; // 500 ms per poll
32
39
  const MAX_POLLS = MAX_HANDSHAKE_WAIT_MS / POLL_INTERVAL_MS; // 20
@@ -419,6 +426,224 @@ function shouldWriteAdapter(filePath, force) {
419
426
  const content = readFileSync(filePath, 'utf8');
420
427
  return content.includes(MARKER);
421
428
  }
429
+ function normalizeSurfaceName(surface) {
430
+ const stripped = surface.startsWith('.') ? surface.slice(1) : surface;
431
+ const normalized = stripped === 'github' ? 'copilot' : stripped;
432
+ return normalized in SURFACE_REGISTRY ? normalized : null;
433
+ }
434
+ function surfacePerimeterRoots(surface) {
435
+ if (surface === 'codex')
436
+ return ['.codex', SURFACE_REGISTRY.codex.hookFilePath];
437
+ if (surface === 'copilot')
438
+ return ['.github', SURFACE_REGISTRY.copilot.hookFilePath];
439
+ if (surface === 'claude')
440
+ return ['.claude', 'CLAUDE.md'];
441
+ return [`.${surface}`];
442
+ }
443
+ function modeRank(mode) {
444
+ return { off: 0, observe: 1, project: 2, govern: 3 }[mode];
445
+ }
446
+ function normalizeSetupAuthoringBody(body) {
447
+ return body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd();
448
+ }
449
+ function setupAuthoringAssetHash(asset) {
450
+ const canonical = {
451
+ entryId: asset.entryId,
452
+ name: asset.name,
453
+ description: asset.description ?? '',
454
+ assetKind: asset.assetKind,
455
+ triggers: asset.triggers ?? [],
456
+ semanticRefs: asset.semanticRefs ?? [],
457
+ body: normalizeSetupAuthoringBody(asset.body),
458
+ };
459
+ return `sha256:${createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex')}`;
460
+ }
461
+ function parseSetupAuthoringFrontmatter(raw) {
462
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
463
+ if (!fmMatch) {
464
+ const h1 = raw.match(/^# (.+)$/m);
465
+ return { name: h1?.[1] ?? '', description: '', body: raw, triggers: [], semanticRefs: [] };
466
+ }
467
+ const fields = new Map();
468
+ const arrayFields = new Map();
469
+ const lines = fmMatch[1].split('\n');
470
+ let currentArrayKey = null;
471
+ let currentArrayValues = [];
472
+ for (const line of lines) {
473
+ const arrayItemMatch = line.match(/^\s+-\s+(.+)$/);
474
+ const keyValueMatch = line.match(/^(\w+):\s*(.*)$/);
475
+ if (arrayItemMatch && currentArrayKey) {
476
+ currentArrayValues.push(arrayItemMatch[1].trim().replace(/^['"]|['"]$/g, ''));
477
+ }
478
+ else if (keyValueMatch) {
479
+ if (currentArrayKey) {
480
+ arrayFields.set(currentArrayKey, currentArrayValues);
481
+ currentArrayKey = null;
482
+ currentArrayValues = [];
483
+ }
484
+ const [, key, value] = keyValueMatch;
485
+ if (value.trim() === '') {
486
+ currentArrayKey = key;
487
+ }
488
+ else {
489
+ fields.set(key, value.trim().replace(/^['"]|['"]$/g, ''));
490
+ }
491
+ }
492
+ }
493
+ if (currentArrayKey)
494
+ arrayFields.set(currentArrayKey, currentArrayValues);
495
+ const body = fmMatch[2];
496
+ return {
497
+ frontmatterId: fields.get('id'),
498
+ name: fields.get('name') ?? body.match(/^# (.+)$/m)?.[1] ?? '',
499
+ description: fields.get('description') ?? '',
500
+ body,
501
+ triggers: arrayFields.get('triggers') ?? [],
502
+ semanticRefs: arrayFields.get('semanticRefs') ?? [],
503
+ };
504
+ }
505
+ function deriveSetupAuthoringEntryId(filename, kind) {
506
+ const base = basename(filename, '.md');
507
+ const snakeCase = base.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
508
+ return `SETUP-${kind.toUpperCase()}-${snakeCase}`;
509
+ }
510
+ function scanSetupAuthoringFiles(productbrainDir) {
511
+ const dirs = [
512
+ { dir: 'skills', kind: 'skill' },
513
+ { dir: 'rules', kind: 'rule' },
514
+ { dir: 'hooks', kind: 'hook' },
515
+ ];
516
+ const items = [];
517
+ for (const { dir, kind } of dirs) {
518
+ const absDir = join(productbrainDir, dir);
519
+ if (!existsSync(absDir))
520
+ continue;
521
+ for (const file of readdirSync(absDir).filter((f) => f.endsWith('.md'))) {
522
+ const filePath = join(absDir, file);
523
+ const parsed = parseSetupAuthoringFrontmatter(readFileSync(filePath, 'utf8'));
524
+ const fallbackName = basename(file, '.md');
525
+ items.push({
526
+ filePath,
527
+ derivedEntryId: deriveSetupAuthoringEntryId(file, kind),
528
+ frontmatterId: parsed.frontmatterId,
529
+ name: parsed.name || fallbackName,
530
+ description: parsed.description,
531
+ body: parsed.body,
532
+ assetKind: kind,
533
+ triggers: parsed.triggers,
534
+ semanticRefs: parsed.semanticRefs,
535
+ });
536
+ }
537
+ }
538
+ return items.sort((a, b) => a.filePath.localeCompare(b.filePath));
539
+ }
540
+ function setupAuthoringDirForKind(kind) {
541
+ if (kind === 'skill')
542
+ return 'skills';
543
+ if (kind === 'rule')
544
+ return 'rules';
545
+ if (kind === 'hook')
546
+ return 'hooks';
547
+ return null;
548
+ }
549
+ function setupAuthoringFilename(name, entryId) {
550
+ const base = (name || entryId)
551
+ .replace(/[\/\\:*?"<>|]/g, '-')
552
+ .replace(/\s+/g, ' ')
553
+ .trim();
554
+ return `${base || entryId}.md`;
555
+ }
556
+ function setupAuthoringPath(cwd, asset) {
557
+ const dir = setupAuthoringDirForKind(asset.assetKind);
558
+ if (!dir)
559
+ return null;
560
+ return join(cwd, '.productbrain', dir, setupAuthoringFilename(asset.name, asset.entryId));
561
+ }
562
+ function renderSetupAuthoringFile(asset) {
563
+ const lines = [
564
+ '---',
565
+ `id: ${asset.entryId}`,
566
+ `name: ${JSON.stringify(asset.name)}`,
567
+ `description: ${JSON.stringify(asset.description ?? '')}`,
568
+ `assetKind: ${asset.assetKind}`,
569
+ ];
570
+ const pushArray = (key, values) => {
571
+ if (!values || values.length === 0)
572
+ return;
573
+ lines.push(`${key}:`);
574
+ for (const value of values)
575
+ lines.push(` - ${JSON.stringify(value)}`);
576
+ };
577
+ pushArray('triggers', asset.triggers);
578
+ pushArray('semanticRefs', asset.semanticRefs);
579
+ lines.push('---', normalizeSetupAuthoringBody(asset.body), '');
580
+ return lines.join('\n');
581
+ }
582
+ function loadAuthoringSyncState(productbrainDir) {
583
+ const statePath = join(productbrainDir, '.authoring-sync.json');
584
+ if (!existsSync(statePath))
585
+ return { version: 1, assets: {} };
586
+ try {
587
+ const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
588
+ if (parsed.version !== 1 || !parsed.assets || typeof parsed.assets !== 'object') {
589
+ return { version: 1, assets: {} };
590
+ }
591
+ return { version: 1, assets: parsed.assets };
592
+ }
593
+ catch {
594
+ return { version: 1, assets: {} };
595
+ }
596
+ }
597
+ function saveAuthoringSyncState(productbrainDir, state) {
598
+ const statePath = join(productbrainDir, '.authoring-sync.json');
599
+ assertSetupWritePath(statePath, { surfaces: [] });
600
+ mkdirSync(productbrainDir, { recursive: true });
601
+ writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
602
+ }
603
+ const DRIFT_HASH_TRAILER_REGEX = /^<!--\s*pb-hash:\s*sha256:([0-9a-f]+)\s*-->\s*$/m;
604
+ const DRIFT_HASH_TRAILER_STRIP = /^<!--\s*pb-hash:.*-->\s*$/gm;
605
+ const DRIFT_TIMESTAMP_STRIP = /^<!--\s*pb-generated-at:.*-->\s*$/gm;
606
+ /**
607
+ * Classify a single projection-target file into one of the three drift buckets.
608
+ *
609
+ * Returns `null` when `filePath` does not exist (first-run / unprojected) — the
610
+ * write loop treats that as "would-write" and the file is not part of any bucket.
611
+ *
612
+ * @param filePath Absolute path to the projection file on disk.
613
+ * @returns { bucket, expectedHash, actualHash } when the file exists.
614
+ * `expectedHash`/`actualHash` are populated only for the
615
+ * tampered bucket so the headless refusal payload can include
616
+ * them verbatim. For clean / user-owned, both are `''`.
617
+ */
618
+ export function classifyDriftBucket(filePath) {
619
+ if (!existsSync(filePath))
620
+ return null;
621
+ const content = readFileSync(filePath, 'utf8');
622
+ // No auto-gen MARKER → user-owned. Never touch.
623
+ if (!content.includes(MARKER)) {
624
+ return { bucket: 'user-owned', expectedHash: '', actualHash: '' };
625
+ }
626
+ // Marker present but no hash trailer → legacy / pre-S0c projection: treat as
627
+ // clean (the hash trailer was added in WP-345 S0c). The forked-vs-clean
628
+ // semantic falls back to existing shouldWriteAdapter behavior.
629
+ const trailerMatch = content.match(DRIFT_HASH_TRAILER_REGEX);
630
+ if (!trailerMatch) {
631
+ return { bucket: 'pb-managed-clean', expectedHash: '', actualHash: '' };
632
+ }
633
+ const expectedHash = `sha256:${trailerMatch[1]}`;
634
+ // Recompute the actual hash from the body (strip trailer + timestamp, LF, trim).
635
+ const normalized = content
636
+ .replace(DRIFT_HASH_TRAILER_STRIP, '')
637
+ .replace(DRIFT_TIMESTAMP_STRIP, '')
638
+ .replace(/\r\n/g, '\n')
639
+ .replace(/\r/g, '\n')
640
+ .trimEnd();
641
+ const actualHash = `sha256:${createHash('sha256').update(normalized, 'utf8').digest('hex')}`;
642
+ if (actualHash === expectedHash) {
643
+ return { bucket: 'pb-managed-clean', expectedHash, actualHash };
644
+ }
645
+ return { bucket: 'pb-managed-tampered', expectedHash, actualHash };
646
+ }
422
647
  function deduplicateEntries(entries) {
423
648
  const seen = new Set();
424
649
  const result = [];
@@ -672,16 +897,132 @@ export async function runHandshake(options = {}) {
672
897
  // Primary: query setup.listAssetsForUser from DB (workspace SSOT).
673
898
  // Fallback: read from .productbrain/ filesystem (legacy — used when DB is empty or unavailable).
674
899
  const pbDir = join(cwd, '.productbrain');
900
+ const manifestStatus = readManifestStatus(pbDir);
901
+ const manifest = manifestStatus.manifest;
902
+ const surfaceValidation = validateSurfacesForMode(manifestStatus.mode, manifestStatus.surfaces);
903
+ if (applyMode && surfaceValidation.error === SURFACE_GOVERN_NO_SURFACES) {
904
+ throw new CLIError('materialize: govern requires at least one registered manifest surface.', {
905
+ code: ErrorCode.VALIDATION_FAILED,
906
+ category: 'validation',
907
+ guidance: 'Add surfaces such as `.cursor` or `.claude` to .productbrain/manifest.yaml.',
908
+ });
909
+ }
910
+ for (const surface of surfaceValidation.unregisteredSurfaces) {
911
+ logErr(`Warning: manifest surface "${surface}" is not registered; skipping it.`);
912
+ }
913
+ const manifestTargets = new Set(surfaceValidation.registeredSurfaces);
914
+ const cliTargets = new Set();
915
+ const ignoredCliSurfaces = [];
916
+ for (const surface of options.surfaces ?? []) {
917
+ const normalized = normalizeSurfaceName(surface);
918
+ if (normalized && manifestTargets.has(normalized)) {
919
+ cliTargets.add(normalized);
920
+ }
921
+ else {
922
+ ignoredCliSurfaces.push(surface);
923
+ }
924
+ }
925
+ if (ignoredCliSurfaces.length > 0) {
926
+ logErr(`Warning: --surfaces ignored outside manifest.surfaces: ${ignoredCliSurfaces.join(', ')}`);
927
+ }
928
+ const allowedTargets = options.surfaces && options.surfaces.length > 0
929
+ ? cliTargets
930
+ : manifestTargets;
931
+ const perimeterManifest = {
932
+ surfaces: [...manifestTargets].flatMap(surfacePerimeterRoots),
933
+ };
934
+ const authorityCanWrite = manifestStatus.mode === 'project' || manifestStatus.mode === 'govern';
935
+ const authorityPreviewOnly = applyMode && !authorityCanWrite;
936
+ const writeMode = applyMode && authorityCanWrite;
675
937
  let dbSkills = [];
676
938
  let dbRules = [];
677
939
  let usedDbSource = false;
678
940
  let dbAssetRows = [];
679
941
  // WP-379 S4: dormant assets (gate-failed) — their on-disk files get the dormant marker.
680
942
  let dormantDbAssetRows = [];
943
+ // WP-428 S2 (Finding #5): tracks entryIds whose body fetch from storage failed.
944
+ // Hoisted here (same scope as dbAssetRows) so both the skills/rules projection loop
945
+ // AND the authoring-file projection loop can skip failed assets.
946
+ const bodyFetchFailedEntryIds = new Set();
681
947
  // WP-379 S5b: whether any setup_receipt exists for this workspace (first-run UX gate).
682
948
  // undefined when server is pre-S5b (treat as unknown → suppress drift TENs conservatively).
683
949
  let hasAnyReceipt = undefined;
684
950
  const dbProjectionHashes = new Map();
951
+ const syncDriftTensToFire = [];
952
+ const deferredAuthoringBaselineEntryIds = new Set();
953
+ if (writeMode) {
954
+ const authoringItems = scanSetupAuthoringFiles(pbDir);
955
+ if (authoringItems.length > 0) {
956
+ // WP-428 S2 (Critical #1): ingestSetupAssetsBatch is an internalMutation — it cannot
957
+ // call ctx.storage.store(). We now call ingestSetupAssetWithBody (action) per-asset so
958
+ // each asset gets bodyStorageId written. This loses intra-batch collision detection;
959
+ // canonical-id collision check is now client-side before dispatch.
960
+ //
961
+ // Client-side collision detection (replaces server-side DEC-954 check in the batch mutation):
962
+ const idToPaths = new Map();
963
+ for (const item of authoringItems) {
964
+ const canonicalId = item.frontmatterId?.trim() || item.derivedEntryId;
965
+ const paths = idToPaths.get(canonicalId) ?? [];
966
+ paths.push(item.filePath);
967
+ idToPaths.set(canonicalId, paths);
968
+ }
969
+ const conflictingPaths = new Set();
970
+ for (const [, paths] of idToPaths) {
971
+ if (paths.length > 1) {
972
+ for (const p of paths)
973
+ conflictingPaths.add(p);
974
+ }
975
+ }
976
+ if (conflictingPaths.size > 0) {
977
+ logErr(`Setup authoring import partial: ${conflictingPaths.size} file(s) refused for duplicate setup id. ` +
978
+ [...conflictingPaths].join(', '));
979
+ }
980
+ const itemsToIngest = authoringItems.filter((item) => !conflictingPaths.has(item.filePath));
981
+ if (itemsToIngest.length > 0) {
982
+ try {
983
+ // Parallel uploads — concurrency limit 10 to avoid overwhelming the gateway.
984
+ const CONCURRENCY = 10;
985
+ const ingestResults = [];
986
+ for (let i = 0; i < itemsToIngest.length; i += CONCURRENCY) {
987
+ const batch = itemsToIngest.slice(i, i + CONCURRENCY);
988
+ const settled = await Promise.allSettled(batch.map(async (item) => {
989
+ const result = await kernelCall('setup.ingestSetupAssetWithBody', {
990
+ entryId: item.derivedEntryId,
991
+ frontmatterId: item.frontmatterId,
992
+ name: item.name,
993
+ description: item.description,
994
+ body: item.body,
995
+ assetKind: item.assetKind,
996
+ triggers: item.triggers,
997
+ semanticRefs: item.semanticRefs,
998
+ });
999
+ return { filePath: item.filePath, ...result };
1000
+ }));
1001
+ for (const r of settled) {
1002
+ if (r.status === 'fulfilled') {
1003
+ ingestResults.push(r.value);
1004
+ if (r.value.conflict === 'repo-wins') {
1005
+ syncDriftTensToFire.push(`Repo authoring file won setup sync conflict for ${r.value.entryId} at ${r.value.filePath}.`);
1006
+ logErr(`Warning: repo authoring file won sync conflict for ${r.value.entryId}; DB edit was overwritten.`);
1007
+ }
1008
+ }
1009
+ else {
1010
+ trackEvent('setup.authoring_import.item_failed', { error: r.reason instanceof Error ? r.reason.message : String(r.reason) });
1011
+ logErr(`Warning: setup authoring import failed for one asset — ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
1012
+ }
1013
+ }
1014
+ }
1015
+ if (ingestResults.length > 0) {
1016
+ log(`Setup authoring import: ${ingestResults.length} file(s) checked (with bodyStorageId).`);
1017
+ }
1018
+ }
1019
+ catch (err) {
1020
+ trackEvent('setup.authoring_import.failed', { error: err instanceof Error ? err.message : String(err) });
1021
+ logErr(`Warning: setup authoring import failed — ${err instanceof Error ? err.message : String(err)}`);
1022
+ }
1023
+ }
1024
+ }
1025
+ }
685
1026
  try {
686
1027
  // WP-379 S4: listAssetsForUser now returns { activeAssets, dormantAssets }.
687
1028
  // Wire format changed from DbAsset[] to { activeAssets: DbAsset[], dormantAssets: DbAsset[] }.
@@ -705,16 +1046,53 @@ export async function runHandshake(options = {}) {
705
1046
  }
706
1047
  if (dbAssets.length > 0) {
707
1048
  dbAssetRows = dbAssets;
1049
+ // WP-428 S2: body is no longer inline — fetch from storage per-asset when bodyStorageId is set.
1050
+ // Projectable assets: non-disabled skill/rule/hook entries. Fetch bodies in parallel (bounded).
1051
+ const projectableAssets = dbAssets.filter((a) => !a.disabledByOwner && (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
1052
+ const assetsNeedingBodyFetch = projectableAssets.filter((a) => a.bodyStorageId);
1053
+ const bodyFetchMap = new Map(); // entryId → body
1054
+ // WP-428 S2 (Finding #5/#12): track fetch-failed assets so we can skip their projection.
1055
+ // Failed entryIds are excluded from dbSkills/dbRules — no empty body written, no lastProjectedHash update.
1056
+ // bodyFetchFailedEntryIds is declared at outer scope (also used by authoring-file projection loop).
1057
+ if (assetsNeedingBodyFetch.length > 0) {
1058
+ log(`Fetching ${assetsNeedingBodyFetch.length} asset body(s) from storage...`);
1059
+ const bodyFetchResults = await Promise.allSettled(assetsNeedingBodyFetch.map(async (asset) => {
1060
+ const result = await kernelCall('setup.fetchAssetBody', { bodyStorageId: asset.bodyStorageId });
1061
+ return { entryId: asset.entryId, name: asset.name, body: result.body };
1062
+ }));
1063
+ for (let i = 0; i < bodyFetchResults.length; i++) {
1064
+ const result = bodyFetchResults[i];
1065
+ if (result.status === 'fulfilled') {
1066
+ bodyFetchMap.set(result.value.entryId, result.value.body);
1067
+ }
1068
+ else {
1069
+ // WP-428 S2 (Finding #12): include entryId and name in the warning (Finding #5: skip projection).
1070
+ const asset = assetsNeedingBodyFetch[i];
1071
+ logErr(`Warning: failed to fetch body for ${asset.entryId} (${asset.name}) — ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
1072
+ bodyFetchFailedEntryIds.add(asset.entryId);
1073
+ }
1074
+ }
1075
+ if (bodyFetchFailedEntryIds.size > 0) {
1076
+ logErr(`${bodyFetchFailedEntryIds.size} asset(s) skipped due to body fetch failure: ${[...bodyFetchFailedEntryIds].join(', ')}`);
1077
+ }
1078
+ }
708
1079
  // Map DB assets to CanonicalSkill/CanonicalRule shapes
709
1080
  for (const asset of dbAssets) {
710
1081
  if (asset.disabledByOwner)
711
1082
  continue;
1083
+ // WP-428 S2 (Finding #5): skip projection for assets whose body fetch failed.
1084
+ // Do NOT write empty body to disk and do NOT update lastProjectedHash.
1085
+ if (bodyFetchFailedEntryIds.has(asset.entryId))
1086
+ continue;
1087
+ // WP-428 S2: resolve body — prefer storage-fetched body, fall back to inline body (pre-S2 servers).
1088
+ // Fallback: pre-S2 servers (no bodyStorageId) return inline body. Once all servers are S2+, this can be deleted.
1089
+ const resolvedBody = bodyFetchMap.get(asset.entryId) ?? asset.body ?? '';
712
1090
  if (asset.assetKind === 'skill') {
713
1091
  dbSkills.push({
714
1092
  name: asset.name,
715
1093
  description: asset.description,
716
1094
  triggers: asset.triggers ?? [],
717
- body: asset.body,
1095
+ body: resolvedBody,
718
1096
  sourcePath: `db:${asset.entryId}`,
719
1097
  });
720
1098
  }
@@ -723,7 +1101,7 @@ export async function runHandshake(options = {}) {
723
1101
  name: asset.name,
724
1102
  description: asset.description,
725
1103
  autoApply: false,
726
- body: asset.body,
1104
+ body: resolvedBody,
727
1105
  sourcePath: `db:${asset.entryId}`,
728
1106
  });
729
1107
  }
@@ -747,7 +1125,7 @@ export async function runHandshake(options = {}) {
747
1125
  // For each asset with semanticRefs[], resolve them via the Convex resolver and
748
1126
  // replace {{ref:key}} placeholders in the body. Runs in apply mode only (not preview).
749
1127
  // NG11: PostHog events fire from CLI side only (never inside Convex mutations).
750
- if (usedDbSource && applyMode) {
1128
+ if (usedDbSource && writeMode) {
751
1129
  const projectableDbAssets = dbAssetRows.filter((a) => !a.disabledByOwner && (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
752
1130
  const assetsWithRefs = projectableDbAssets.filter((a) => a.semanticRefs && a.semanticRefs.length > 0);
753
1131
  if (assetsWithRefs.length > 0) {
@@ -895,9 +1273,6 @@ export async function runHandshake(options = {}) {
895
1273
  }
896
1274
  allSkills.push(...personalSkills);
897
1275
  }
898
- // 5c. Apply manifest-based adoption filter (WP-310 E1)
899
- // readManifest returns null when manifest.yaml is absent → filterByAdoptionState is a no-op.
900
- const manifest = readManifest(pbDir);
901
1276
  // 5d. Load method registry (WP-310 E4) — only when manifest is present.
902
1277
  let registrySource;
903
1278
  let registryStale;
@@ -964,7 +1339,7 @@ export async function runHandshake(options = {}) {
964
1339
  const agentsWorkspaceContext = workspaceProfile
965
1340
  ? {
966
1341
  stage: workspaceProfile.stage,
967
- focus: orientView?.strategicContext?.currentBet ?? undefined,
1342
+ focus: orientView?.strategicContext?.currentWorkPackage ?? undefined,
968
1343
  governanceMode: workspaceProfile.governanceMode,
969
1344
  totalEntries: workspaceProfile.totalEntries,
970
1345
  }
@@ -1017,10 +1392,117 @@ export async function runHandshake(options = {}) {
1017
1392
  const filesWritten = [];
1018
1393
  const filesSkipped = [];
1019
1394
  const previewPlan = [];
1020
- // Surface filtering: skip adapter writes for targets not in the allowed set
1021
- const allowedTargets = options.surfaces && options.surfaces.length > 0
1022
- ? new Set(options.surfaces)
1023
- : null; // null = write all
1395
+ if (writeMode && usedDbSource) {
1396
+ const authoringSyncState = loadAuthoringSyncState(pbDir);
1397
+ let authoringSyncStateChanged = false;
1398
+ const personalAssets = dbAssetRows.filter((asset) => asset.scope === 'personal' &&
1399
+ !asset.disabledByOwner &&
1400
+ (asset.assetKind === 'skill' || asset.assetKind === 'rule' || asset.assetKind === 'hook'));
1401
+ for (const asset of personalAssets) {
1402
+ // WP-428 S2 (Finding #5): skip projection for assets whose body fetch failed.
1403
+ // Do NOT write the authoring file with empty body, do NOT update lastProjectedHash.
1404
+ if (bodyFetchFailedEntryIds.has(asset.entryId))
1405
+ continue;
1406
+ const authoringPath = setupAuthoringPath(cwd, asset);
1407
+ if (!authoringPath)
1408
+ continue;
1409
+ const relativeAuthoringPath = authoringPath.replace(cwd + '/', '');
1410
+ const bodyHash = setupAuthoringAssetHash(asset);
1411
+ const authoringExists = existsSync(authoringPath);
1412
+ const tracked = authoringSyncState.assets[asset.entryId];
1413
+ const trackedHere = tracked?.path === relativeAuthoringPath;
1414
+ const trackedMatchesServer = Boolean(trackedHere && asset.lastProjectedHash && tracked?.hash === asset.lastProjectedHash);
1415
+ if (!authoringExists && asset.lastProjectedHash && trackedHere && trackedMatchesServer) {
1416
+ try {
1417
+ const dormantResult = await kernelCall('setup.markPersonalSetupAssetDormantFromSync', { entryId: asset.entryId, expectedLastProjectedHash: tracked.hash });
1418
+ if (dormantResult.action === 'conflict') {
1419
+ syncDriftTensToFire.push(`Repo authoring deletion skipped for ${asset.entryId}; server baseline changed during sync, so writeback was deferred until the next DB read.`);
1420
+ logErr(`Warning: authoring deletion for ${asset.entryId} was not applied because the DB baseline changed during sync.`);
1421
+ deferredAuthoringBaselineEntryIds.add(asset.entryId);
1422
+ continue;
1423
+ }
1424
+ else {
1425
+ syncDriftTensToFire.push(`Repo authoring file was deleted for ${asset.entryId}; caller-owned personal setup asset marked dormant.`);
1426
+ log(`Setup authoring deletion: ${asset.entryId} marked dormant.`);
1427
+ delete authoringSyncState.assets[asset.entryId];
1428
+ authoringSyncStateChanged = true;
1429
+ continue;
1430
+ }
1431
+ }
1432
+ catch (err) {
1433
+ logErr(`Warning: could not mark ${asset.entryId} dormant after authoring deletion — ${err instanceof Error ? err.message : String(err)}`);
1434
+ continue;
1435
+ }
1436
+ }
1437
+ else if (!authoringExists && asset.lastProjectedHash && trackedHere) {
1438
+ syncDriftTensToFire.push(`Repo authoring deletion skipped for ${asset.entryId}; local sync baseline is stale, so DB writeback wins.`);
1439
+ logErr(`Warning: authoring deletion for ${asset.entryId} was not applied because the DB baseline changed.`);
1440
+ }
1441
+ const nextAuthoringContent = renderSetupAuthoringFile(asset);
1442
+ if (authoringExists) {
1443
+ const parsed = parseSetupAuthoringFrontmatter(readFileSync(authoringPath, 'utf8'));
1444
+ const fileHash = setupAuthoringAssetHash({
1445
+ entryId: parsed.frontmatterId?.trim() || asset.entryId,
1446
+ name: parsed.name || asset.name,
1447
+ description: parsed.description,
1448
+ body: parsed.body,
1449
+ assetKind: asset.assetKind,
1450
+ triggers: parsed.triggers,
1451
+ semanticRefs: parsed.semanticRefs,
1452
+ });
1453
+ if (fileHash === bodyHash) {
1454
+ if (asset.lastProjectedHash !== bodyHash) {
1455
+ await kernelCall('setup.updateLastProjectedHash', {
1456
+ entryId: asset.entryId,
1457
+ hash: bodyHash,
1458
+ }).catch(() => null);
1459
+ }
1460
+ authoringSyncState.assets[asset.entryId] = { path: relativeAuthoringPath, hash: bodyHash };
1461
+ authoringSyncStateChanged = true;
1462
+ continue;
1463
+ }
1464
+ if (!asset.lastProjectedHash) {
1465
+ syncDriftTensToFire.push(`Repo authoring file differs from DB for ${asset.entryId} at ${authoringPath} with no shared baseline; DB writeback skipped.`);
1466
+ logErr(`Warning: setup authoring drift for ${asset.entryId}; DB writeback skipped because no shared baseline exists.`);
1467
+ continue;
1468
+ }
1469
+ if (fileHash !== asset.lastProjectedHash &&
1470
+ bodyHash !== asset.lastProjectedHash) {
1471
+ syncDriftTensToFire.push(`Repo authoring file won setup sync conflict for ${asset.entryId} at ${authoringPath}.`);
1472
+ logErr(`Warning: repo authoring file won sync conflict for ${asset.entryId}; DB writeback skipped.`);
1473
+ continue;
1474
+ }
1475
+ if (fileHash !== asset.lastProjectedHash) {
1476
+ syncDriftTensToFire.push(`Repo authoring file differs from DB baseline for ${asset.entryId} at ${authoringPath}; DB writeback skipped.`);
1477
+ logErr(`Warning: setup authoring drift for ${asset.entryId}; DB writeback skipped.`);
1478
+ continue;
1479
+ }
1480
+ }
1481
+ try {
1482
+ assertSetupWritePath(authoringPath, perimeterManifest);
1483
+ mkdirSync(dirname(authoringPath), { recursive: true });
1484
+ writeFileSync(authoringPath, nextAuthoringContent);
1485
+ filesWritten.push(relativeAuthoringPath);
1486
+ await kernelCall('setup.updateLastProjectedHash', {
1487
+ entryId: asset.entryId,
1488
+ hash: bodyHash,
1489
+ }).catch(() => null);
1490
+ authoringSyncState.assets[asset.entryId] = { path: relativeAuthoringPath, hash: bodyHash };
1491
+ authoringSyncStateChanged = true;
1492
+ }
1493
+ catch (err) {
1494
+ logErr(`Warning: could not write setup authoring file for ${asset.entryId} — ${err instanceof Error ? err.message : String(err)}`);
1495
+ }
1496
+ }
1497
+ if (authoringSyncStateChanged) {
1498
+ try {
1499
+ saveAuthoringSyncState(pbDir, authoringSyncState);
1500
+ }
1501
+ catch (err) {
1502
+ logErr(`Warning: could not persist setup authoring sync state — ${err instanceof Error ? err.message : String(err)}`);
1503
+ }
1504
+ }
1505
+ }
1024
1506
  const writes = [
1025
1507
  ...(contextContent ? [{ path: join(cwd, '.productbrain', 'context.md'), relative: '.productbrain/context.md', content: contextContent, dirs: join(cwd, '.productbrain'), isAdapter: false }] : []),
1026
1508
  { path: join(cwd, '.productbrain', 'briefing.md'), relative: '.productbrain/briefing.md', content: briefingContent, isAdapter: false },
@@ -1129,25 +1611,49 @@ export async function runHandshake(options = {}) {
1129
1611
  // Runs only when we have a DB asset list (usedDbSource) — without a DB source,
1130
1612
  // we can't determine which files are canonical vs. orphan.
1131
1613
  const collisionTensToFire = [];
1132
- if (applyMode && usedDbSource) {
1614
+ if (writeMode && usedDbSource) {
1133
1615
  const activeAssetNames = dbAssetRows
1134
1616
  .filter((a) => !a.disabledByOwner)
1135
1617
  .map((a) => a.name);
1136
1618
  const { collisionTens } = resolveProjectionCollision(cwd, activeAssetNames, log, logErr);
1137
1619
  collisionTensToFire.push(...collisionTens);
1138
1620
  }
1621
+ // ── WP-436 S3: Vocab projector — fetch workspace vocab context once per handshake ──
1622
+ // Source-side (.productbrain/skills/*.md + rules/*.md) stays tokenized.
1623
+ // The projector resolves {{vocab:...}} tokens before writing adapter output to disk
1624
+ // (.cursor/rules/, .claude/rules/, CLAUDE.md, AGENTS.md, .github/copilot-instructions.md).
1625
+ // Fail-open: if vocab fetch fails, skip resolution and write raw token (no breakage).
1626
+ const handshakeVocabCtx = await getOrFetchVocabCtx(config.apiKey, async () => {
1627
+ try {
1628
+ const vocab = await kernelCall('chain.getVocabulary', {});
1629
+ if (vocab?.collectionLabels || vocab?.collectionDefaults) {
1630
+ return {
1631
+ ...(vocab.collectionLabels ? { collectionLabels: vocab.collectionLabels } : {}),
1632
+ ...(vocab.collectionDefaults ? { collectionDefaults: vocab.collectionDefaults } : {}),
1633
+ };
1634
+ }
1635
+ return null;
1636
+ }
1637
+ catch {
1638
+ return null; // fail-open
1639
+ }
1640
+ });
1139
1641
  const forkedPaths = [];
1140
1642
  const projectedHashUpdates = new Map();
1643
+ const cleanBucketPaths = [];
1644
+ const tamperedBucket = [];
1141
1645
  const recordProjectedHash = (entryId) => {
1142
1646
  if (!applyMode || !entryId)
1143
1647
  return;
1648
+ if (deferredAuthoringBaselineEntryIds.has(entryId))
1649
+ return;
1144
1650
  const projection = dbProjectionHashes.get(entryId);
1145
1651
  if (projection)
1146
1652
  projectedHashUpdates.set(entryId, projection.hash);
1147
1653
  };
1148
1654
  for (const w of writes) {
1149
1655
  // Surface filtering: skip adapter writes for targets not in the allowed set
1150
- if (allowedTargets && w.target && !allowedTargets.has(w.target)) {
1656
+ if (w.target && !allowedTargets.has(w.target)) {
1151
1657
  filesSkipped.push({ path: w.relative, reason: `filtered (surface: ${w.target})` });
1152
1658
  if (preview)
1153
1659
  previewPlan.push({ path: w.relative, status: 'filtered' });
@@ -1163,6 +1669,13 @@ export async function runHandshake(options = {}) {
1163
1669
  }
1164
1670
  continue;
1165
1671
  }
1672
+ if (authorityPreviewOnly) {
1673
+ filesSkipped.push({
1674
+ path: w.relative,
1675
+ reason: `preview-only (materialize: ${manifestStatus.mode})`,
1676
+ });
1677
+ continue;
1678
+ }
1166
1679
  if (preview || dryRun) {
1167
1680
  // In preview/dry-run mode: check content to distinguish new/update/unchanged
1168
1681
  if (existsSync(w.path)) {
@@ -1187,11 +1700,44 @@ export async function runHandshake(options = {}) {
1187
1700
  }
1188
1701
  continue;
1189
1702
  }
1703
+ // ── WP-421 S3: defer tampered adapter writes (doneWhen #17) ──────────────
1704
+ // For adapter projections that already exist on disk, classify into
1705
+ // pb-managed-clean / pb-managed-tampered. Tampered = MARKER present +
1706
+ // hash trailer mismatches body. Defer (do NOT overwrite); post-loop
1707
+ // resolution handles them. --force bypasses the tampered defer (legacy
1708
+ // semantic: explicit user opt-in to overwrite).
1709
+ if (w.isAdapter && !force) {
1710
+ const drift = classifyDriftBucket(w.path);
1711
+ if (drift && drift.bucket === 'pb-managed-tampered') {
1712
+ tamperedBucket.push({
1713
+ path: w.path,
1714
+ relative: w.relative,
1715
+ content: w.content,
1716
+ expectedHash: drift.expectedHash,
1717
+ actualHash: drift.actualHash,
1718
+ dirs: w.dirs,
1719
+ dbAssetEntryId: w.dbAssetEntryId,
1720
+ });
1721
+ // Do NOT write — defer until prompt/refusal resolves the file.
1722
+ continue;
1723
+ }
1724
+ if (drift && drift.bucket === 'pb-managed-clean') {
1725
+ cleanBucketPaths.push(w.relative);
1726
+ }
1727
+ }
1728
+ assertSetupWritePath(w.path, perimeterManifest);
1190
1729
  if (w.dirs)
1191
1730
  mkdirSync(w.dirs, { recursive: true });
1731
+ // WP-436 S3: resolve {{vocab:...}} tokens before writing projected adapter files.
1732
+ // Source-side (.productbrain/skills/*.md, rules/*.md) stays tokenized.
1733
+ // Only adapter projections (cursor/rules, claude/rules, CLAUDE.md, AGENTS.md, etc.) get resolved.
1734
+ // Fail-open: if vocabCtx is undefined, replaceVocabTokens falls back to canonicalKey literals.
1735
+ const resolvedContent = w.isAdapter
1736
+ ? replaceVocabTokens(w.content, handshakeVocabCtx)
1737
+ : w.content;
1192
1738
  if (existsSync(w.path)) {
1193
1739
  const current = readFileSync(w.path, 'utf8');
1194
- const nextNormalized = normalizeHandshakeContentForComparison(w.content);
1740
+ const nextNormalized = normalizeHandshakeContentForComparison(resolvedContent);
1195
1741
  const currentNormalized = normalizeHandshakeContentForComparison(current);
1196
1742
  if (nextNormalized === currentNormalized) {
1197
1743
  filesSkipped.push({ path: w.relative, reason: 'unchanged' });
@@ -1199,7 +1745,7 @@ export async function runHandshake(options = {}) {
1199
1745
  continue;
1200
1746
  }
1201
1747
  }
1202
- writeFileSync(w.path, w.content);
1748
+ writeFileSync(w.path, resolvedContent);
1203
1749
  filesWritten.push(w.relative);
1204
1750
  recordProjectedHash(w.dbAssetEntryId);
1205
1751
  }
@@ -1216,6 +1762,184 @@ export async function runHandshake(options = {}) {
1216
1762
  }
1217
1763
  });
1218
1764
  }
1765
+ // ── WP-421 S3: tampered-bucket resolution (doneWhen #17) ────────────────────
1766
+ // Apply mode only. Tampered files were DEFERRED in the write loop above;
1767
+ // here we either prompt the user (interactive TTY) or refuse (headless).
1768
+ //
1769
+ // Headless = `--no-prompt` flag OR `process.stdout.isTTY === false`. When
1770
+ // headless: enumerate each tampered file to stderr, write a setup_receipt
1771
+ // row with kind='transition' (DEC-962) capturing `refusedTamperedFiles[]`,
1772
+ // then exit non-zero. NEVER auto-resolve. (#17 + exclusions: edits to
1773
+ // projection dirs are detected, NOT silently overwritten.)
1774
+ //
1775
+ // Interactive: for each tampered file, present adopt-or-revert. Adopt =
1776
+ // create a personal-scoped setup_asset draft from the tampered content
1777
+ // (see helper below). Revert = re-project canonical content over the
1778
+ // tampered file (write w.content to w.path).
1779
+ const adoptedTamperedPaths = [];
1780
+ const revertedTamperedPaths = [];
1781
+ if (writeMode && tamperedBucket.length > 0) {
1782
+ const headless = options.noPrompt === true || !process.stdout.isTTY;
1783
+ if (headless) {
1784
+ // ── Headless refusal path (doneWhen #17) ────────────────────────────────
1785
+ logErr('');
1786
+ logErr(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
1787
+ logErr('Headless mode (--no-prompt or no TTY) cannot resolve adopt-or-revert — refusing.');
1788
+ logErr('');
1789
+ const refusedTamperedFiles = tamperedBucket.map((t) => ({
1790
+ path: t.relative,
1791
+ expectedHash: t.expectedHash,
1792
+ actualHash: t.actualHash,
1793
+ }));
1794
+ for (const refused of refusedTamperedFiles) {
1795
+ // Per #17: stderr enumerates each tampered file as
1796
+ // {path, expectedHash, actualHash, bucket}.
1797
+ logErr(` ${JSON.stringify({ ...refused, bucket: 'pb-managed-tampered' })}`);
1798
+ }
1799
+ logErr('');
1800
+ logErr('Re-run interactively to resolve, or use --force to overwrite (data loss).');
1801
+ // Write the kind='transition' setup_receipt row. Fail-open on the
1802
+ // network/auth side: if the row cannot be written, we still exit non-zero
1803
+ // (the audit trail is best-effort; refusal is mandatory).
1804
+ try {
1805
+ const manifestStatus = readManifestStatus(pbDir);
1806
+ await kernelCall('setup.recordTamperRefusal', {
1807
+ mode: manifestStatus.mode,
1808
+ refusedTamperedFiles,
1809
+ });
1810
+ trackEvent('setup.transition.refused', {
1811
+ fileCount: refusedTamperedFiles.length,
1812
+ mode: manifestStatus.mode,
1813
+ });
1814
+ }
1815
+ catch (err) {
1816
+ trackEvent('setup.transition.refused.write_failed', {
1817
+ error: err instanceof Error ? err.message : String(err),
1818
+ });
1819
+ logErr(`Warning: could not record transition receipt — ${err instanceof Error ? err.message : String(err)}`);
1820
+ }
1821
+ // Surface the report counts before exit (visibility for CI logs).
1822
+ if (!quiet) {
1823
+ process.stdout.write('\n');
1824
+ process.stdout.write(formatHandshakeReport({
1825
+ filesWritten,
1826
+ filesSkipped,
1827
+ matchedEntries,
1828
+ searchQueries: uniqueQueries,
1829
+ repo,
1830
+ codexWarnings: codexWarnings.length > 0 ? codexWarnings : undefined,
1831
+ chainRulesStats: chainRulesStats ?? undefined,
1832
+ chainGaps: chainGaps.length > 0 ? chainGaps : undefined,
1833
+ adoptedCount: adoptedRulesCount,
1834
+ rejectedCount: rejectedRulesCount,
1835
+ personalRuleCount: personalRules.length > 0 ? personalRules.length : undefined,
1836
+ personalSkillCount: personalSkills.length > 0 ? personalSkills.length : undefined,
1837
+ registrySource,
1838
+ registryStale,
1839
+ driftConflicts: forkedPaths.length > 0 ? forkedPaths : undefined,
1840
+ managedCleanCount: cleanBucketPaths.length || undefined,
1841
+ tamperedFiles: refusedTamperedFiles,
1842
+ }) + '\n');
1843
+ }
1844
+ // Exit non-zero per #17.
1845
+ process.exit(1);
1846
+ }
1847
+ // ── Interactive path: prompt adopt-or-revert per file ──────────────────────
1848
+ log('');
1849
+ log(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
1850
+ log('You can ADOPT (capture your edits as a personal-scoped draft) or REVERT (overwrite with canonical content).');
1851
+ // Batch yes-to-all / no-to-all when the user has many tampered files.
1852
+ // Threshold: 5 (arbitrary; mirrors the typical handshake projection set).
1853
+ let batchChoice = null;
1854
+ if (tamperedBucket.length >= 5) {
1855
+ const useBatch = await promptConfirm({
1856
+ message: `Apply the same choice to all ${tamperedBucket.length} tampered files?`,
1857
+ initialValue: false,
1858
+ });
1859
+ if (useBatch) {
1860
+ const choice = await promptSelect({
1861
+ message: 'Apply to all:',
1862
+ options: [
1863
+ { value: 'adopt', label: 'Adopt all — capture each as a personal-scoped draft' },
1864
+ { value: 'revert', label: 'Revert all — overwrite with canonical content' },
1865
+ ],
1866
+ });
1867
+ batchChoice = choice === 'adopt' ? 'adopt-all' : 'revert-all';
1868
+ }
1869
+ }
1870
+ for (const tamper of tamperedBucket) {
1871
+ // Reverse-map the projection path back to the canonical authoring path.
1872
+ const reverse = canonicalPathForAnySurface(tamper.relative);
1873
+ const canonicalHint = reverse
1874
+ ? `Canonical authoring path: ${reverse.canonicalPath}`
1875
+ : (() => {
1876
+ // Telemetry + fallback message per surfaces/telemetry.ts.
1877
+ reportReverseMapMissing({ surface: 'unknown', projectionPath: tamper.relative }, logErr);
1878
+ return `Canonical authoring path: ${getReverseMapFallbackMessage()}`;
1879
+ })();
1880
+ log('');
1881
+ log(`Tampered: ${tamper.relative}`);
1882
+ log(` expected: ${tamper.expectedHash}`);
1883
+ log(` actual: ${tamper.actualHash}`);
1884
+ log(` ${canonicalHint}`);
1885
+ let action;
1886
+ if (batchChoice === 'adopt-all')
1887
+ action = 'adopt';
1888
+ else if (batchChoice === 'revert-all')
1889
+ action = 'revert';
1890
+ else {
1891
+ action = await promptSelect({
1892
+ message: 'Adopt or revert?',
1893
+ options: [
1894
+ { value: 'adopt', label: 'Adopt — capture this as a personal-scoped setup_asset draft' },
1895
+ { value: 'revert', label: 'Revert — overwrite with canonical content' },
1896
+ ],
1897
+ });
1898
+ }
1899
+ if (action === 'revert') {
1900
+ // Re-project canonical content over the tampered file.
1901
+ // WP-436 S3: resolve vocab tokens before writing (all tampered files are adapters).
1902
+ if (tamper.dirs)
1903
+ mkdirSync(tamper.dirs, { recursive: true });
1904
+ assertSetupWritePath(tamper.path, perimeterManifest);
1905
+ writeFileSync(tamper.path, replaceVocabTokens(tamper.content, handshakeVocabCtx));
1906
+ revertedTamperedPaths.push(tamper.relative);
1907
+ recordProjectedHash(tamper.dbAssetEntryId);
1908
+ trackEvent('setup.tampered.reverted', { path: tamper.relative });
1909
+ }
1910
+ else {
1911
+ // Adopt: capture the tampered content as a personal-scoped draft.
1912
+ // Per DEC-953 sync rules: personal scope = push (no fork required).
1913
+ // The mutation is best-effort — if the adopt write fails, the file is
1914
+ // kept on disk untouched and a warning is logged. Adopt does NOT
1915
+ // revert; it preserves the user's edits AND records them as a draft.
1916
+ const draftName = basename(tamper.relative).replace(/\.(md|mdc)$/, '') + ' (adopted)';
1917
+ try {
1918
+ const tamperedContent = readFileSync(tamper.path, 'utf8');
1919
+ const session = readSession();
1920
+ const caller = session ? kernelCallWithSession : kernelCall;
1921
+ await caller('setup.ingestSetupAsset', {
1922
+ entryId: `SETUP-ADOPTED-${Date.now()}-${draftName.replace(/\s+/g, '-')}`,
1923
+ name: draftName,
1924
+ description: `Adopted from tampered projection at ${tamper.relative} (WP-421 S3).`,
1925
+ body: tamperedContent,
1926
+ assetKind: tamper.relative.includes('/skills/') ? 'skill' : 'rule',
1927
+ triggers: [],
1928
+ semanticRefs: [],
1929
+ });
1930
+ adoptedTamperedPaths.push(tamper.relative);
1931
+ trackEvent('setup.tampered.adopted', { path: tamper.relative });
1932
+ }
1933
+ catch (err) {
1934
+ logErr(`Warning: could not adopt ${tamper.relative} as draft — ${err instanceof Error ? err.message : String(err)}`);
1935
+ trackEvent('setup.tampered.adopt_failed', {
1936
+ path: tamper.relative,
1937
+ error: err instanceof Error ? err.message : String(err),
1938
+ });
1939
+ }
1940
+ }
1941
+ }
1942
+ }
1219
1943
  // 8a. Dormant marker writes (WP-379 S4) — apply mode only.
1220
1944
  // For each dormant asset (gate-filtered by the server), locate any previously-projected
1221
1945
  // on-disk files and append the DORMANT_MARKER trailer. Files are NOT deleted.
@@ -1230,11 +1954,12 @@ export async function runHandshake(options = {}) {
1230
1954
  //
1231
1955
  // Fail-open: if a dormant marker write fails, log and continue. Never crash the handshake.
1232
1956
  const dormantMarkedPaths = [];
1233
- if (applyMode && dormantDbAssetRows.length > 0) {
1957
+ if (writeMode && dormantDbAssetRows.length > 0) {
1234
1958
  for (const dormantAsset of dormantDbAssetRows) {
1235
1959
  const candidatePaths = deriveDormantFilePaths(dormantAsset, cwd);
1236
1960
  for (const filePath of candidatePaths) {
1237
1961
  try {
1962
+ assertSetupWritePath(filePath, perimeterManifest);
1238
1963
  const markerResult = writeDormantMarkerToFile(filePath);
1239
1964
  if (markerResult === 'written') {
1240
1965
  dormantMarkedPaths.push(filePath);
@@ -1276,6 +2001,8 @@ export async function runHandshake(options = {}) {
1276
2001
  kernelCallWithSession('chain.createEntry', {
1277
2002
  collectionSlug: 'tensions',
1278
2003
  name: `TEN: handshake drift — ${forkedPaths.length} adapter(s) forked, sync blocked`,
2004
+ // Drift audit TENs intentionally stay draft — they need explicit human review,
2005
+ // not auto-commit, even in Open mode (mirrors smart-capture.ts recordCommitFailure).
1279
2006
  status: 'draft',
1280
2007
  data: {
1281
2008
  description: `pb handshake --apply encountered forked adapters that blocked sync. Files: ${names}. Use --force to overwrite or resolve drift manually.`,
@@ -1286,6 +2013,24 @@ export async function runHandshake(options = {}) {
1286
2013
  }
1287
2014
  }
1288
2015
  }
2016
+ if (syncDriftTensToFire.length > 0) {
2017
+ const session = readSession();
2018
+ if (session) {
2019
+ for (const driftDescription of syncDriftTensToFire) {
2020
+ kernelCallWithSession('chain.createEntry', {
2021
+ collectionSlug: 'tensions',
2022
+ name: 'TEN: setup authoring sync drift — repo wins',
2023
+ status: 'draft',
2024
+ data: {
2025
+ kind: 'drift',
2026
+ description: driftDescription,
2027
+ },
2028
+ sessionId: session.sessionId,
2029
+ createdBy: `agent:${session.sessionId}`,
2030
+ }).catch(() => { });
2031
+ }
2032
+ }
2033
+ }
1289
2034
  // 8. Case-collision TENs (WP-379 S5b) — fire after the first-run gate.
1290
2035
  // These are distinct from drift TENs: they record ambiguous filename collisions
1291
2036
  // where the "newest mtime wins" heuristic was applied. They fire regardless of
@@ -1297,6 +2042,8 @@ export async function runHandshake(options = {}) {
1297
2042
  kernelCallWithSession('chain.createEntry', {
1298
2043
  collectionSlug: 'tensions',
1299
2044
  name: `TEN: handshake case-collision — ambiguous filename resolved by mtime`,
2045
+ // Collision audit TENs intentionally stay draft — they need explicit human review,
2046
+ // not auto-commit, even in Open mode (mirrors smart-capture.ts recordCommitFailure).
1300
2047
  status: 'draft',
1301
2048
  data: { description: tenDescription },
1302
2049
  sessionId: session.sessionId,
@@ -1308,6 +2055,30 @@ export async function runHandshake(options = {}) {
1308
2055
  // 8b. Setup receipt — record which assets were materialized (apply mode only)
1309
2056
  // Fail-open: receipt write is advisory, never blocks the handshake.
1310
2057
  if (applyMode) {
2058
+ const session = readSession();
2059
+ const caller = session ? kernelCallWithSession : kernelCall;
2060
+ try {
2061
+ const currentState = await caller('setup.getCurrentSetupState', {});
2062
+ const fromMode = currentState?.effectiveMode ?? 'observe';
2063
+ if (fromMode !== manifestStatus.mode) {
2064
+ await caller('setup.recordTransition', {
2065
+ fromMode,
2066
+ toMode: manifestStatus.mode,
2067
+ parseStatus: manifestStatus.parseStatus,
2068
+ surfaces: manifestStatus.surfaces,
2069
+ lock: manifestStatus.lock,
2070
+ });
2071
+ if (modeRank(manifestStatus.mode) < modeRank(fromMode)) {
2072
+ trackEvent('setup.transition.lowered', { fromMode, toMode: manifestStatus.mode });
2073
+ }
2074
+ }
2075
+ }
2076
+ catch (err) {
2077
+ trackEvent('setup.transition.write_failed', { error: err instanceof Error ? err.message : String(err) });
2078
+ logErr(`Warning: could not record setup transition — ${err instanceof Error ? err.message : String(err)}`);
2079
+ }
2080
+ }
2081
+ if (writeMode) {
1311
2082
  const session = readSession();
1312
2083
  const caller = session ? kernelCallWithSession : kernelCall;
1313
2084
  try {
@@ -1340,6 +2111,12 @@ export async function runHandshake(options = {}) {
1340
2111
  preview: preview ? true : undefined,
1341
2112
  previewPlan: preview && previewPlan.length > 0 ? previewPlan : undefined,
1342
2113
  driftConflicts: forkedPaths.length > 0 ? forkedPaths : undefined,
2114
+ // WP-421 S3: three-bucket drift report (doneWhen #17). PB-managed-clean is
2115
+ // the count of files whose marker + hash matched. Tampered files were
2116
+ // resolved (adopted/reverted) above and are reported separately.
2117
+ managedCleanCount: cleanBucketPaths.length > 0 ? cleanBucketPaths.length : undefined,
2118
+ adoptedTamperedPaths: adoptedTamperedPaths.length > 0 ? adoptedTamperedPaths : undefined,
2119
+ revertedTamperedPaths: revertedTamperedPaths.length > 0 ? revertedTamperedPaths : undefined,
1343
2120
  };
1344
2121
  if (!quiet) {
1345
2122
  process.stdout.write('\n');