@productbrain/cli 0.1.0-beta.11282 → 0.1.0-beta.11295

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 (106) hide show
  1. package/README.md +3 -2
  2. package/dist/__tests__/cli-error-once.test.d.ts +2 -0
  3. package/dist/__tests__/cli-error-once.test.d.ts.map +1 -0
  4. package/dist/__tests__/cli-error-once.test.js +51 -0
  5. package/dist/__tests__/cli-error-once.test.js.map +1 -0
  6. package/dist/__tests__/init.test.d.ts +2 -1
  7. package/dist/__tests__/init.test.d.ts.map +1 -1
  8. package/dist/__tests__/init.test.js +110 -8
  9. package/dist/__tests__/init.test.js.map +1 -1
  10. package/dist/__tests__/removed-commands.test.js +31 -2
  11. package/dist/__tests__/removed-commands.test.js.map +1 -1
  12. package/dist/commands/connect-integration.test.js +1 -5
  13. package/dist/commands/connect-integration.test.js.map +1 -1
  14. package/dist/commands/connect-legacy-migration.test.js +0 -1
  15. package/dist/commands/connect-legacy-migration.test.js.map +1 -1
  16. package/dist/commands/connect-screens.d.ts +9 -13
  17. package/dist/commands/connect-screens.d.ts.map +1 -1
  18. package/dist/commands/connect-screens.js +16 -78
  19. package/dist/commands/connect-screens.js.map +1 -1
  20. package/dist/commands/connect-screens.test.d.ts +2 -0
  21. package/dist/commands/connect-screens.test.d.ts.map +1 -0
  22. package/dist/commands/connect-screens.test.js +18 -0
  23. package/dist/commands/connect-screens.test.js.map +1 -0
  24. package/dist/commands/connect.js +5 -5
  25. package/dist/commands/connect.js.map +1 -1
  26. package/dist/commands/connect.test.js +0 -4
  27. package/dist/commands/connect.test.js.map +1 -1
  28. package/dist/commands/init.d.ts +14 -1
  29. package/dist/commands/init.d.ts.map +1 -1
  30. package/dist/commands/init.js +90 -1
  31. package/dist/commands/init.js.map +1 -1
  32. package/dist/formatters/questions.d.ts +2 -0
  33. package/dist/formatters/questions.d.ts.map +1 -1
  34. package/dist/formatters/questions.js +5 -1
  35. package/dist/formatters/questions.js.map +1 -1
  36. package/dist/formatters/questions.test.js +10 -5
  37. package/dist/formatters/questions.test.js.map +1 -1
  38. package/dist/handleCliError.d.ts +17 -0
  39. package/dist/handleCliError.d.ts.map +1 -1
  40. package/dist/handleCliError.js +14 -3
  41. package/dist/handleCliError.js.map +1 -1
  42. package/dist/index.js +7 -29
  43. package/dist/index.js.map +1 -1
  44. package/dist/lib/client.d.ts.map +1 -1
  45. package/dist/lib/client.js +1 -2
  46. package/dist/lib/client.js.map +1 -1
  47. package/package.json +1 -2
  48. package/dist/__tests__/handshake-augment.test.d.ts +0 -2
  49. package/dist/__tests__/handshake-augment.test.d.ts.map +0 -1
  50. package/dist/__tests__/handshake-augment.test.js +0 -407
  51. package/dist/__tests__/handshake-augment.test.js.map +0 -1
  52. package/dist/__tests__/handshake-dormancy.test.d.ts +0 -2
  53. package/dist/__tests__/handshake-dormancy.test.d.ts.map +0 -1
  54. package/dist/__tests__/handshake-dormancy.test.js +0 -24
  55. package/dist/__tests__/handshake-dormancy.test.js.map +0 -1
  56. package/dist/__tests__/handshake-formatter.test.d.ts +0 -2
  57. package/dist/__tests__/handshake-formatter.test.d.ts.map +0 -1
  58. package/dist/__tests__/handshake-formatter.test.js +0 -111
  59. package/dist/__tests__/handshake-formatter.test.js.map +0 -1
  60. package/dist/__tests__/handshake-preview.test.d.ts +0 -2
  61. package/dist/__tests__/handshake-preview.test.d.ts.map +0 -1
  62. package/dist/__tests__/handshake-preview.test.js +0 -500
  63. package/dist/__tests__/handshake-preview.test.js.map +0 -1
  64. package/dist/__tests__/handshake-session-threading.test.d.ts +0 -2
  65. package/dist/__tests__/handshake-session-threading.test.d.ts.map +0 -1
  66. package/dist/__tests__/handshake-session-threading.test.js +0 -185
  67. package/dist/__tests__/handshake-session-threading.test.js.map +0 -1
  68. package/dist/__tests__/handshake.e2e.test.d.ts +0 -2
  69. package/dist/__tests__/handshake.e2e.test.d.ts.map +0 -1
  70. package/dist/__tests__/handshake.e2e.test.js +0 -226
  71. package/dist/__tests__/handshake.e2e.test.js.map +0 -1
  72. package/dist/__tests__/handshake.test.d.ts +0 -2
  73. package/dist/__tests__/handshake.test.d.ts.map +0 -1
  74. package/dist/__tests__/handshake.test.js +0 -1040
  75. package/dist/__tests__/handshake.test.js.map +0 -1
  76. package/dist/__tests__/method-registry.integration.test.d.ts +0 -6
  77. package/dist/__tests__/method-registry.integration.test.d.ts.map +0 -1
  78. package/dist/__tests__/method-registry.integration.test.js +0 -17
  79. package/dist/__tests__/method-registry.integration.test.js.map +0 -1
  80. package/dist/__tests__/method-registry.test.d.ts +0 -12
  81. package/dist/__tests__/method-registry.test.d.ts.map +0 -1
  82. package/dist/__tests__/method-registry.test.js +0 -86
  83. package/dist/__tests__/method-registry.test.js.map +0 -1
  84. package/dist/commands/connect-context.d.ts +0 -55
  85. package/dist/commands/connect-context.d.ts.map +0 -1
  86. package/dist/commands/connect-context.js +0 -78
  87. package/dist/commands/connect-context.js.map +0 -1
  88. package/dist/commands/connect-context.test.d.ts +0 -2
  89. package/dist/commands/connect-context.test.d.ts.map +0 -1
  90. package/dist/commands/connect-context.test.js +0 -148
  91. package/dist/commands/connect-context.test.js.map +0 -1
  92. package/dist/commands/handshake.d.ts +0 -78
  93. package/dist/commands/handshake.d.ts.map +0 -1
  94. package/dist/commands/handshake.js +0 -1359
  95. package/dist/commands/handshake.js.map +0 -1
  96. package/dist/lib/method-registry.d.ts +0 -37
  97. package/dist/lib/method-registry.d.ts.map +0 -1
  98. package/dist/lib/method-registry.js +0 -41
  99. package/dist/lib/method-registry.js.map +0 -1
  100. package/templates/general/code-integrity.md +0 -11
  101. package/templates/general/getting-started.md +0 -12
  102. package/templates/method-registry.json +0 -16
  103. package/templates/node-ts/code-integrity.md +0 -13
  104. package/templates/node-ts/testing.md +0 -12
  105. package/templates/python/code-integrity.md +0 -13
  106. package/templates/python/testing.md +0 -12
@@ -1,1359 +0,0 @@
1
- /**
2
- * pb handshake — generate context files for AI developer tools.
3
- * Context export wiring (read-only filesystem bridge; GLO-63, DEC-161) — not a product surface.
4
- */
5
- import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync, realpathSync } from 'fs';
6
- import { join, dirname, resolve, basename, relative, sep } from 'path';
7
- import { homedir } from 'os';
8
- import { fileURLToPath } from 'url';
9
- import { execSync } from 'child_process';
10
- import { getConfigOrGuide } from '../lib/config.js';
11
- import { select as promptSelect, confirm as promptConfirm } from '../lib/prompts.js';
12
- import { HOOK_INTENT_CONFIGS, composeHooksFromIntents, getHookStatusForSurface } from '../lib/hook-intents.js';
13
- import { kernelCall } from '../lib/client.js';
14
- import { readSession } from '../lib/session.js';
15
- import { detectRepo, extractWorkspaceProfile } from '../lib/repo-detect.js';
16
- import { generateContextMd } from '../generators/context-md.js';
17
- import { generateBriefingMd } from '../generators/briefing-md.js';
18
- import { MARKER, generateAgentsMd, generateClaudeMd, generateCursorMdc, generateCopilotMd, generateCopilotInstructionFiles } from '../generators/adapters.js';
19
- import { readCanonicalSkills, readCanonicalRules, readPersonalLayer, readPersonalSkillsLayer, generateCursorSkill, generateCursorRule, generateCodexSkill, generateCodexSkillIndex, shouldEmitToTarget, filterByLevel, validateCodexSkills, evaluateConditions, } from '../generators/portable-knowledge.js';
20
- import { findNoProjectionAssets } from '../generators/noProjectionAssets.js'; // WP-660 S2 combine
21
- import { resolveSurfaceProfile } from '../generators/surface-profiles.js';
22
- import { generateEveryoneRulesMd } from '../generators/everyone-rules-md.js';
23
- import { removeStaleBriefingProjections, retireGeneratedProjections } from '../generators/retired-projections.js';
24
- import { formatWorkspaceBriefingLines } from '../lib/ruleListRender.js';
25
- import { formatHandshakeReport } from '../formatters/handshake.js';
26
- import { classifyAdapterFile, detectEol, spliceAppend, spliceReplace } from '../generators/region.js';
27
- import { REGION_PROJECTIONS } from '../generators/region-projections.js';
28
- import { readManifest, readManifestStatus, filterByAdoptionState } from '../generators/manifest.js';
29
- import { generateBoundaryManifest, getBoundaryEnforcementMode } from '../generators/boundary-manifest.js';
30
- import { loadMethodRegistry } from '../lib/method-registry.js';
31
- import { CLIError, ErrorCode } from '../lib/errors.js';
32
- import { isJsonMode } from '../lib/runner.js';
33
- import { stripConvexInternals } from '../lib/strip.js';
34
- import { replaceVocabTokens } from '../lib/canonicalRefs.js';
35
- // WP-436 S3: vocab projector — resolves {{vocab:...}} tokens before writing to disk.
36
- import { getOrFetchVocabCtx } from '../lib/workspaceVocabCache.js';
37
- import { normalizeMaterializedFilename } from '../lib/normalizeMaterializedFilename.js';
38
- import { findLeftoverCursorSkills, LEFTOVER_FILES_MAX } from './handshakeCursorSkillCleanup.js';
39
- import { classifyDriftBucket } from './handshakeDriftClassification.js';
40
- import { sanitizeStartupResolutionSignals } from '../lib/startup-resolution-signals.js';
41
- import { assertSetupWritePath } from '../lib/writePerimeter.js';
42
- import { SURFACE_GOVERN_NO_SURFACES, SURFACE_REGISTRY, validateSurfacesForMode, } from '../surfaces/registry.js';
43
- const LEVELS = {
44
- guide: {
45
- label: 'Guide me',
46
- description: "Explain what you're doing, ask before anything unfamiliar",
47
- defaultMode: 'auto',
48
- allow: ['Bash(pb:*)', 'Bash(git:*)'],
49
- },
50
- work: {
51
- label: 'Just work',
52
- description: 'Ask only when something could be risky or irreversible',
53
- defaultMode: 'auto',
54
- allow: ['Bash(pb:*)', 'Bash(git:*)', 'Bash(npm run:*)'],
55
- },
56
- silent: {
57
- label: 'Silent',
58
- description: "I'll review the diff; don't ask unless it's destructive",
59
- defaultMode: 'acceptEdits',
60
- allow: ['Bash(pb:*)', 'Bash(git:*)', 'Bash(npm run:*)', 'Bash(npx:*)'],
61
- },
62
- 'full-trust': {
63
- label: 'Full trust',
64
- description: 'Never ask (your machine, your call)',
65
- defaultMode: 'bypassPermissions',
66
- allow: [],
67
- },
68
- };
69
- // Explicit ordering — determines menu numbering in promptLevel(). Do not reorder.
70
- const LEVEL_KEYS = ['guide', 'work', 'silent', 'full-trust'];
71
- // Hook failure contract (TEN-712): every hook command MUST end with '|| true' so Claude Code always starts even if pb is unavailable.
72
- const INIT_PERMISSION = 'Bash(pb:*)';
73
- function readSettings(filePath) {
74
- if (!existsSync(filePath))
75
- return {};
76
- try {
77
- return JSON.parse(readFileSync(filePath, 'utf8'));
78
- }
79
- catch {
80
- console.error(`Warning: ${filePath} has invalid JSON — starting fresh.`);
81
- return {};
82
- }
83
- }
84
- // Team write: hooks + Bash(pb:*) → .claude/settings.json (safe to commit)
85
- function writeTeamSettings(cwd, dryRun) {
86
- const claudeDir = join(cwd, '.claude');
87
- const settingsPath = join(claudeDir, 'settings.json');
88
- const settings = readSettings(settingsPath);
89
- const added = [];
90
- const permissions = (settings.permissions ?? {});
91
- const allow = permissions.allow ?? [];
92
- if (!allow.includes(INIT_PERMISSION)) {
93
- allow.push(INIT_PERMISSION);
94
- permissions.allow = allow;
95
- added.push('Bash(pb:*) permission');
96
- }
97
- settings.permissions = permissions;
98
- const hooks = (settings.hooks ?? {});
99
- const hookAdditions = composeHooksFromIntents(['session-start', 'session-close'], hooks);
100
- const close = HOOK_INTENT_CONFIGS['session-close'].command; // TASK-763 (DEC-2038): an older init closed on Stop, every turn
101
- const stop = (hooks.Stop ?? []).map((g) => ({ ...g, hooks: g.hooks.filter((h) => h.command !== close) })).filter((g) => g.hooks.length > 0);
102
- if (JSON.stringify(stop) !== JSON.stringify(hooks.Stop ?? [])) {
103
- if (stop.length)
104
- hooks.Stop = stop;
105
- else
106
- delete hooks.Stop;
107
- added.push('Stop → pb session close removed (closed every turn)');
108
- }
109
- for (const addition of hookAdditions) {
110
- hooks[addition.event] = [...(hooks[addition.event] ?? []), addition.entry];
111
- added.push(addition.label);
112
- }
113
- settings.hooks = hooks;
114
- if (!dryRun) {
115
- mkdirSync(claudeDir, { recursive: true });
116
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
117
- }
118
- return added;
119
- }
120
- // Personal write: trust level → ~/.claude/settings.json (never committed)
121
- function writePersonalSettings(levelKey, dryRun) {
122
- const level = LEVELS[levelKey];
123
- const globalDir = join(homedir(), '.claude');
124
- const globalPath = join(globalDir, 'settings.json');
125
- const settings = readSettings(globalPath);
126
- const added = [];
127
- const permissions = (settings.permissions ?? {});
128
- if (permissions.defaultMode !== level.defaultMode) {
129
- permissions.defaultMode = level.defaultMode;
130
- added.push(`defaultMode → ${level.defaultMode}`);
131
- }
132
- const allow = permissions.allow ?? [];
133
- for (const entry of level.allow) {
134
- if (!allow.includes(entry)) {
135
- allow.push(entry);
136
- added.push(`allow: ${entry}`);
137
- }
138
- }
139
- permissions.allow = allow;
140
- settings.permissions = permissions;
141
- if (!dryRun) {
142
- mkdirSync(globalDir, { recursive: true });
143
- writeFileSync(globalPath, JSON.stringify(settings, null, 2) + '\n');
144
- }
145
- return added;
146
- }
147
- async function promptLevel() {
148
- const result = await promptSelect({
149
- message: 'How much should Claude explain before acting?',
150
- options: LEVEL_KEYS.map((key) => ({
151
- value: key,
152
- label: LEVELS[key].label,
153
- hint: LEVELS[key].description,
154
- })),
155
- });
156
- return result;
157
- }
158
- export async function runHandshakeInit(options = {}) {
159
- const cwd = process.cwd();
160
- const dryRun = options.dryRun ?? false;
161
- const suffix = dryRun ? ' (dry run)' : '';
162
- if (options.level && !LEVEL_KEYS.includes(options.level)) {
163
- throw new CLIError(`Unknown level "${options.level}".`, {
164
- code: ErrorCode.VALIDATION_FAILED,
165
- category: 'validation',
166
- guidance: `Valid levels: ${LEVEL_KEYS.join(', ')}`,
167
- });
168
- }
169
- console.log('Setting up Claude Code integration...\n');
170
- // Step 1: Team config — always, no prompt
171
- const teamAdded = writeTeamSettings(cwd, dryRun);
172
- const teamPath = join(cwd, '.claude', 'settings.json');
173
- if (teamAdded.length === 0) {
174
- console.log(`✓ ${teamPath} — already up to date${suffix}`);
175
- }
176
- else {
177
- console.log(`✓ ${teamPath}${suffix}`);
178
- for (const item of teamAdded)
179
- console.log(` + ${item}`);
180
- }
181
- // Step 2: Personal config — wizard or --level flag. Cast is safe: LEVEL_KEYS.includes() validated above.
182
- const levelKey = options.level ? options.level : await promptLevel();
183
- const level = LEVELS[levelKey];
184
- const personalAdded = writePersonalSettings(levelKey, dryRun);
185
- const personalPath = join(homedir(), '.claude', 'settings.json');
186
- if (personalAdded.length === 0) {
187
- console.log(`✓ ${personalPath} — already at "${level.label}"${suffix}`);
188
- }
189
- else {
190
- console.log(`✓ ${personalPath} — set to "${level.label}"${suffix}`);
191
- for (const item of personalAdded)
192
- console.log(` + ${item}`);
193
- }
194
- console.log('');
195
- console.log('Team config → commit .claude/settings.json to share hooks with your team');
196
- console.log('Personal config → ~/.claude/settings.json is private to this machine, never commit');
197
- console.log('');
198
- console.log('Hook failure: if pb is unavailable, hooks fail silently — Claude Code always starts.');
199
- console.log('');
200
- if (!dryRun)
201
- console.log('Reload /hooks in Claude Code (or restart) to activate.');
202
- console.log('Run `pb handshake --init --level <guide|work|silent|full-trust>` to change level.');
203
- // Step 2b: Report multi-surface hook opt-in status (WP-310 E3b) — manifest.hooks.{cursor,copilot}, silence = no manifest/flags (DEC-536).
204
- const pbDirForManifest = join(cwd, '.productbrain');
205
- const initManifest = readManifest(pbDirForManifest);
206
- const multiSurfaceOptIns = [];
207
- if (initManifest?.hooks?.cursor)
208
- multiSurfaceOptIns.push('cursor');
209
- if (initManifest?.hooks?.copilot)
210
- multiSurfaceOptIns.push('copilot');
211
- if (multiSurfaceOptIns.length > 0) {
212
- console.log('');
213
- for (const surface of multiSurfaceOptIns) {
214
- const status = getHookStatusForSurface(surface);
215
- if (status.writable) {
216
- // Future-proofing path: surface has hook events, would write files.
217
- console.log(`ℹ ${surface} hooks: opted in — hooks will be written`);
218
- }
219
- else {
220
- console.log(`ℹ ${surface} hooks: opted in — no auto-hooks (${surface} has no session events; run \`pb session start\` manually)`);
221
- }
222
- }
223
- }
224
- // Step 3: Scaffold starter templates if .productbrain/rules/ is empty
225
- const rulesDir = join(cwd, '.productbrain', 'rules');
226
- const hasExistingRules = existsSync(rulesDir) && readdirSync(rulesDir).filter((f) => f.endsWith('.md')).length > 0;
227
- if (!hasExistingRules) {
228
- // Detect stack from repo to pick template set
229
- const repo = detectRepo(cwd);
230
- const stack = repo.detectedStack.map((s) => s.toLowerCase());
231
- let templateSet;
232
- if (stack.some((s) => ['typescript', 'sveltekit', 'nextjs', 'react'].includes(s))) {
233
- templateSet = 'node-ts';
234
- }
235
- else if (stack.includes('python')) {
236
- templateSet = 'python';
237
- }
238
- else {
239
- templateSet = 'general';
240
- }
241
- // Resolve templates directory relative to this file
242
- const __filename = fileURLToPath(import.meta.url);
243
- const __dirname = dirname(__filename);
244
- // From dist/commands/handshake.js → ../../templates/
245
- const templatesRoot = resolve(__dirname, '..', '..', 'templates');
246
- const templateDir = join(templatesRoot, templateSet);
247
- if (existsSync(templateDir)) {
248
- const templateFiles = readdirSync(templateDir).filter((f) => f.endsWith('.md'));
249
- if (templateFiles.length > 0) {
250
- console.log('');
251
- console.log(`Scaffolding starter rules from ${templateSet} template...`);
252
- if (!dryRun) {
253
- mkdirSync(rulesDir, { recursive: true });
254
- for (const file of templateFiles) {
255
- const src = join(templateDir, file);
256
- const dest = join(rulesDir, file);
257
- copyFileSync(src, dest);
258
- console.log(` + .productbrain/rules/${file}`);
259
- }
260
- console.log('');
261
- console.log('Run `pb handshake` to sync the scaffolded rules to your AI tools.');
262
- }
263
- else {
264
- for (const file of templateFiles) {
265
- console.log(` + .productbrain/rules/${file} (dry run)`);
266
- }
267
- }
268
- }
269
- }
270
- }
271
- }
272
- const LOCAL_REF_REGEX = /\b(?:WP|TEN|DEC|BET|FEAT|INS|STD|PRI|ARCH)[-\s#]*\d+\b|\bPR\s*#?\s*\d+\b/gi;
273
- const STARTUP_RESOLUTION_RESOLVER_VERSION = 'startup-resolution-v1';
274
- // Global cap on collected refs (spec "Contract: StartupResolutionSignals" — arrays are capped).
275
- const LOCAL_REF_GLOBAL_CAP = 25;
276
- // Per-source cap so a broad source (context.md) can't starve briefing.md's task-specific refs (Codex@677).
277
- const LOCAL_REF_PER_SOURCE_CAP = 18;
278
- // Time-boxed so an unauthed/slow `gh` can't stall session-start (F4).
279
- const PR_DIFF_TIMEOUT_MS = 4_000;
280
- // Bounds worst-case `gh` subprocess invocations per handshake.
281
- const PR_DIFF_MAX_REFS = 5;
282
- function runGitText(cwd, command) {
283
- try {
284
- const value = execSync(command, {
285
- cwd,
286
- encoding: 'utf8',
287
- stdio: ['ignore', 'pipe', 'ignore'],
288
- }).trim();
289
- return value.length > 0 ? value : undefined;
290
- }
291
- catch {
292
- return undefined;
293
- }
294
- }
295
- /** Dereference a "PR #N" ref to its changed paths via `gh pr diff <N> --name-only` (F4: server
296
- * resolver has no git/gh access). Guarded/time-boxed; `run` is injectable for tests. */
297
- export function dereferencePrRefToPaths(cwd, prNumber, run = runGitTextTimeboxed) {
298
- if (!Number.isInteger(prNumber) || prNumber <= 0)
299
- return [];
300
- const raw = run(cwd, `gh pr diff ${prNumber} --name-only`, PR_DIFF_TIMEOUT_MS);
301
- if (!raw)
302
- return [];
303
- return raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
304
- }
305
- /** Time-boxed variant of runGitText for the PR-diff dereference (F4). */
306
- function runGitTextTimeboxed(cwd, command, timeoutMs) {
307
- try {
308
- const value = execSync(command, {
309
- cwd,
310
- encoding: 'utf8',
311
- stdio: ['ignore', 'pipe', 'ignore'],
312
- timeout: timeoutMs,
313
- }).trim();
314
- return value.length > 0 ? value : undefined;
315
- }
316
- catch {
317
- return undefined;
318
- }
319
- }
320
- function readOptionalLocalText(cwd, path) {
321
- const fullPath = join(cwd, path);
322
- if (!existsSync(fullPath))
323
- return undefined;
324
- try {
325
- return readFileSync(fullPath, 'utf8');
326
- }
327
- catch {
328
- return undefined;
329
- }
330
- }
331
- function normalizeLocalRef(ref) {
332
- const trimmed = ref.trim().replace(/\s+/g, ' ');
333
- const pr = trimmed.match(/^PR\s*#?\s*(\d+)$/i);
334
- if (pr)
335
- return `PR #${pr[1]}`;
336
- const entry = trimmed.match(/^([A-Z]+)[-\s#]*(\d+)$/i);
337
- if (entry)
338
- return `${entry[1].toUpperCase()}-${entry[2]}`;
339
- return trimmed;
340
- }
341
- /** Collect normalized local refs across several text sources, deduped. Codex@677: a per-source
342
- * cap alongside the global one, so a broad source (context.md) can't starve a later,
343
- * task-specific source (briefing.md); already-collected refs dedupe without spending later quota. */
344
- function collectLocalRefs(texts) {
345
- const refs = [];
346
- const seen = new Set();
347
- for (const text of texts) {
348
- if (!text)
349
- continue;
350
- let fromThisSource = 0;
351
- for (const match of text.matchAll(LOCAL_REF_REGEX)) {
352
- const ref = normalizeLocalRef(match[0]);
353
- const key = ref.toLowerCase();
354
- if (seen.has(key))
355
- continue;
356
- if (fromThisSource >= LOCAL_REF_PER_SOURCE_CAP)
357
- break;
358
- seen.add(key);
359
- refs.push(ref);
360
- fromThisSource++;
361
- if (refs.length >= LOCAL_REF_GLOBAL_CAP)
362
- return refs;
363
- }
364
- }
365
- return refs;
366
- }
367
- function deriveHandshakeStartupTask(refs, changedPaths, callerTask) {
368
- if (callerTask && callerTask.trim())
369
- return callerTask.trim();
370
- const parts = [];
371
- if (refs.length > 0)
372
- parts.push(refs.join(' '));
373
- if (changedPaths.length > 0)
374
- parts.push(`changed paths ${changedPaths.slice(0, 5).join(' ')}`);
375
- return parts.length > 0 ? parts.join(' ') : undefined;
376
- }
377
- const DEFAULT_HANDSHAKE_SIGNAL_DEPS = {
378
- runGit: runGitText,
379
- prDiff: (cwd, prNumber) => dereferencePrRefToPaths(cwd, prNumber),
380
- };
381
- /** Extract the PR number from a normalized "PR #N" ref, else null. */
382
- function prNumberFromRef(ref) {
383
- const m = ref.match(/^PR\s*#?\s*(\d+)$/i);
384
- if (!m)
385
- return null;
386
- const n = Number(m[1]);
387
- return Number.isInteger(n) && n > 0 ? n : null;
388
- }
389
- export function deriveHandshakeStartupSignals(cwd, callerSignals = {}, deps = DEFAULT_HANDSHAKE_SIGNAL_DEPS) {
390
- const { runGit, prDiff } = deps;
391
- const branchName = runGit(cwd, 'git branch --show-current');
392
- const commitSha = runGit(cwd, 'git rev-parse --short HEAD');
393
- // Changed paths, unioned + deduped: (1) dirty worktree; (2) committed branch delta vs origin/main's
394
- // merge-base (Codex@665, invisible once the tree is clean); (3) PR-ref dereference (F4), appended later.
395
- const dirtyChangedPaths = runGit(cwd, 'git diff --name-only HEAD')?.split(/\r?\n/).filter(Boolean) ?? [];
396
- const mergeBase = runGit(cwd, 'git merge-base HEAD origin/main');
397
- const branchChangedPaths = mergeBase
398
- ? (runGit(cwd, `git diff --name-only ${mergeBase} HEAD`)?.split(/\r?\n/).filter(Boolean) ?? [])
399
- : [];
400
- const worktreeName = basename(cwd);
401
- const contextText = readOptionalLocalText(cwd, '.productbrain/context.md');
402
- const briefingText = readOptionalLocalText(cwd, '.productbrain/briefing.md');
403
- const callerRefs = Array.isArray(callerSignals.reviewedArtifactRefs) ? callerSignals.reviewedArtifactRefs.join('\n') : undefined;
404
- // Codex@677: briefing.md (task-specific) reads BEFORE context.md (broad) so its refs collect first — with the per-source cap, context.md can't starve briefing.
405
- const refs = collectLocalRefs([
406
- callerSignals.taskText ?? undefined,
407
- callerSignals.explicitScope ?? undefined,
408
- callerRefs,
409
- branchName,
410
- worktreeName,
411
- briefingText,
412
- contextText,
413
- ]);
414
- // F4: dereference "PR #N" refs to diff paths, folded into changedPaths — the only place this can happen (server records dereferenced:false, no git access). Time-boxed; absent/unauthed gh no-ops.
415
- const prDiffPaths = [];
416
- if (callerSignals.changedPaths === undefined) {
417
- let derefed = 0;
418
- for (const ref of refs) {
419
- const prNumber = prNumberFromRef(ref);
420
- if (prNumber === null)
421
- continue;
422
- if (derefed >= PR_DIFF_MAX_REFS)
423
- break;
424
- derefed++;
425
- try {
426
- for (const p of prDiff(cwd, prNumber))
427
- prDiffPaths.push(p);
428
- }
429
- catch {
430
- // gh absent / unauthed / PR missing — skip gracefully, leave existing behavior.
431
- }
432
- }
433
- }
434
- // Caller-supplied changedPaths win (backward compat); otherwise union the
435
- // three local sources, deduped, order-stable.
436
- const unionedChangedPaths = callerSignals.changedPaths ??
437
- Array.from(new Set([...dirtyChangedPaths, ...branchChangedPaths, ...prDiffPaths]));
438
- const baseSignals = sanitizeStartupResolutionSignals({
439
- ...callerSignals,
440
- sourceSurface: callerSignals.sourceSurface ?? 'cli',
441
- resolverVersion: callerSignals.resolverVersion ?? STARTUP_RESOLUTION_RESOLVER_VERSION,
442
- branchName: callerSignals.branchName ?? branchName,
443
- worktreeName: callerSignals.worktreeName ?? worktreeName,
444
- changedPaths: unionedChangedPaths,
445
- reviewedArtifactRefs: callerSignals.reviewedArtifactRefs ?? refs,
446
- commitSha: callerSignals.commitSha ?? commitSha,
447
- });
448
- const taskText = deriveHandshakeStartupTask(baseSignals.reviewedArtifactRefs ?? [], baseSignals.changedPaths ?? [], baseSignals.taskText);
449
- const sanitized = sanitizeStartupResolutionSignals({
450
- ...baseSignals,
451
- taskText,
452
- });
453
- const hasMeaningfulEvidence = Boolean(sanitized.taskText) ||
454
- (sanitized.changedPaths?.length ?? 0) > 0 ||
455
- (sanitized.reviewedArtifactRefs?.length ?? 0) > 0 ||
456
- Boolean(sanitized.explicitScope);
457
- if (hasMeaningfulEvidence)
458
- return sanitized;
459
- // Codex@704: even with no evidence, emit a minimal envelope carrying attribution — {} would
460
- // drop the whole startup block, losing the loud domain-unresolved signal (spec "Loud Unresolved State", AC #4).
461
- return sanitizeStartupResolutionSignals({
462
- sourceSurface: sanitized.sourceSurface ?? 'cli',
463
- resolverVersion: sanitized.resolverVersion ?? STARTUP_RESOLUTION_RESOLVER_VERSION,
464
- branchName: sanitized.branchName,
465
- worktreeName: sanitized.worktreeName,
466
- commitSha: sanitized.commitSha,
467
- changeId: sanitized.changeId,
468
- });
469
- }
470
- /** Normalize volatile handshake-only timestamps before comparing generated files — keeps visible
471
- * timestamps in artifacts while avoiding meaningless rewrites when content is unchanged. */
472
- export function normalizeHandshakeContentForComparison(content) {
473
- return content
474
- .replace(/<!-- auto-generated by pb handshake — [0-9]{4}-[0-9]{2}-[0-9]{2}T[^>]+ -->/g, '<!-- auto-generated by pb handshake — <TIMESTAMP> -->')
475
- .replace(/^_Generated: [0-9]{4}-[0-9]{2}-[0-9]{2}T.*_$/gm, '_Generated: <TIMESTAMP>_');
476
- }
477
- function shouldWriteAdapter(filePath, force) {
478
- // An absent file at an adapter path is a projection target → materialize it.
479
- if (!existsSync(filePath))
480
- return true;
481
- const content = readFileSync(filePath, 'utf8');
482
- // No MARKER → user-owned/committed-static (CLAUDE.md, AGENTS.md, Copilot instructions) — never overwritten, not even under --force.
483
- void force;
484
- return content.includes(MARKER);
485
- }
486
- function normalizeSurfaceName(surface) {
487
- const stripped = surface.startsWith('.') ? surface.slice(1) : surface;
488
- const normalized = stripped === 'github' ? 'copilot' : stripped;
489
- return normalized in SURFACE_REGISTRY ? normalized : null;
490
- }
491
- function surfacePerimeterRoots(surface) {
492
- if (surface === 'codex')
493
- return ['.codex', SURFACE_REGISTRY.codex.hookFilePath];
494
- if (surface === 'copilot')
495
- return ['.github', SURFACE_REGISTRY.copilot.hookFilePath];
496
- if (surface === 'claude')
497
- return ['.claude', 'CLAUDE.md'];
498
- return [`.${surface}`];
499
- }
500
- // ── WP-421 S3: Three-bucket drift classification (doneWhen #17) — now its own module (STD-2/DEC-1504
501
- // shrink-only offset). Re-exported so existing imports `from '../commands/handshake.js'` keep working.
502
- export { classifyDriftBucket };
503
- function deduplicateEntries(entries) {
504
- const seen = new Set();
505
- const result = [];
506
- for (const e of entries) {
507
- const key = e.entryId ?? e.name;
508
- if (!seen.has(key)) {
509
- seen.add(key);
510
- result.push(e);
511
- }
512
- }
513
- return result;
514
- }
515
- /**
516
- * findLeftoverProjections — WP-379 S5b
517
- *
518
- * Lists marker-bearing files under .cursor/rules/, .claude/rules/, .claude/skills/, .codex/skills/
519
- * (plus the nested .cursor/skills/<name>/SKILL.md layout) that look stale: orphan (no canonical
520
- * asset), stale case/punctuation variant, or a leftover `.dormant` copy. User-owned (no MARKER)
521
- * files and unselected `allowedTargets` are never reported.
522
- *
523
- * Round 12: stops once `LEFTOVER_FILES_MAX + 1` matches are found across the flat + merged
524
- * nested-Cursor scan combined — bounds read/enumerate I/O, not just the reported list.
525
- * `truncated` says more exist beyond the capped, returned `files`.
526
- *
527
- * @param cursorSkillNames Cursor-specific, SKILL-kind-only canon (falls back to `assetNames`).
528
- * @param exactRelativePaths Round 11: exact write-plan paths (forward-slash), exempted from both
529
- * checks — covers asset-less generated files and a canonical raw name not already normalized.
530
- */
531
- export function findLeftoverProjections(cwd, assetNames, allowedTargets, cursorSkillNames, exactRelativePaths) {
532
- const TARGET_DIRS_BY_EXT = [
533
- { dir: join(cwd, '.cursor', 'rules'), ext: '.mdc', surface: 'cursor' },
534
- { dir: join(cwd, '.claude', 'rules'), ext: '.md', surface: 'claude' },
535
- { dir: join(cwd, '.claude', 'skills'), ext: '.md', surface: 'claude' },
536
- { dir: join(cwd, '.codex', 'skills'), ext: '.md', surface: 'codex' },
537
- ];
538
- const canonicalNormalizedStems = new Set(assetNames.map((n) => normalizeMaterializedFilename(n)));
539
- const exactPaths = exactRelativePaths ?? new Set();
540
- const leftovers = [];
541
- let truncated = false;
542
- scanLoop: for (const { dir, ext, surface } of TARGET_DIRS_BY_EXT) {
543
- if (!allowedTargets.has(surface))
544
- continue;
545
- if (!existsSync(dir))
546
- continue;
547
- let files;
548
- try {
549
- files = readdirSync(dir);
550
- }
551
- catch {
552
- continue; // unreadable dir — skip
553
- }
554
- for (const filename of files) {
555
- if (!filename.endsWith(ext) && !filename.endsWith(`${ext}.dormant`))
556
- continue;
557
- const fullPath = join(dir, filename);
558
- let content;
559
- try {
560
- content = readFileSync(fullPath, 'utf8');
561
- }
562
- catch {
563
- continue; // unreadable file — skip
564
- }
565
- if (!content.includes(MARKER))
566
- continue; // user-owned — never reported
567
- const isDormant = filename.endsWith('.dormant');
568
- // A live file the write plan targets by this exact path is canonical by construction.
569
- if (!isDormant) {
570
- const relativePath = relative(cwd, fullPath).split(sep).join('/');
571
- if (exactPaths.has(relativePath))
572
- continue;
573
- }
574
- const stem = basename(isDormant ? filename.slice(0, -'.dormant'.length) : filename, ext);
575
- const normalizedStem = normalizeMaterializedFilename(stem);
576
- let entry = null;
577
- if (!canonicalNormalizedStems.has(normalizedStem)) {
578
- entry = { path: fullPath, reason: 'orphan — no canonical asset matches this name' };
579
- }
580
- else if (isDormant) {
581
- entry = { path: fullPath, reason: 'leftover .dormant file from an older binary version' };
582
- }
583
- else if (stem !== normalizedStem) {
584
- entry = { path: fullPath, reason: `stale case/punctuation variant — canonical form is "${normalizedStem}${ext}"` };
585
- }
586
- if (!entry)
587
- continue;
588
- leftovers.push(entry);
589
- if (leftovers.length > LEFTOVER_FILES_MAX) {
590
- truncated = true;
591
- leftovers.pop();
592
- break scanLoop;
593
- }
594
- }
595
- }
596
- // Nested Cursor skills — extracted to its own module (STD-2/DEC-1504 shrink-only offset).
597
- // Skipped once the flat scan already hit the cap; the remaining budget keeps the combined
598
- // total across both scans stopping at LEFTOVER_FILES_MAX + 1.
599
- if (!truncated && allowedTargets.has('cursor')) {
600
- const nested = findLeftoverCursorSkills(cwd, cursorSkillNames ?? assetNames, LEFTOVER_FILES_MAX - leftovers.length);
601
- leftovers.push(...nested.files);
602
- truncated = nested.truncated;
603
- }
604
- return { files: leftovers, truncated };
605
- }
606
- /** Round 13 (Codex P1, security): strips a `user:token@` userinfo segment from an https remote
607
- * so a credential never reaches JSON output. SSH remotes (`git@host:...`) are untouched — that
608
- * `git@` is a fixed public username, not a secret. */
609
- function redactGitRemote(remote) {
610
- return remote ? remote.replace(/^(https?:\/\/)[^@/]+@/i, '$1') : remote;
611
- }
612
- /** Round 13 (Codex P1, security): `pb --json handshake` serialized the report directly — leaking
613
- * an HTTPS git-remote credential and raw Convex `_id`/`_creationTime` on `matchedEntries` (never
614
- * went through the shared runner's `stripConvexInternals`). One public shape, both JSON paths. */
615
- function toPublicJsonReport(report) {
616
- return stripConvexInternals({ ...report, repo: { ...report.repo, gitRemote: redactGitRemote(report.repo.gitRemote) } });
617
- }
618
- /** TASK-554 (RULE-659): getOrientView always serves the briefing's facts; a view without them is a broken contract. */
619
- function servedBriefing(view) {
620
- if (view.briefing)
621
- return view.briefing;
622
- throw new CLIError('The orient view carried no briefing; getOrientView always serves one.', { code: ErrorCode.INTERNAL, category: 'internal' });
623
- }
624
- export async function runHandshake(options = {}) {
625
- const config = await getConfigOrGuide(async () => { await runHandshake(options); });
626
- if (!config)
627
- return undefined;
628
- const cwd = process.cwd();
629
- const force = options.force ?? false;
630
- const dryRun = options.dryRun ?? false;
631
- // Preview mode: default when neither --apply nor --dry-run is passed (--dry-run is a backward-compat alias).
632
- const preview = !options.apply && !dryRun;
633
- const applyMode = options.apply === true && !dryRun;
634
- const level = options.level;
635
- // `quiet` gates the final report write (`--json` alone still prints). `suppressProgress`
636
- // also silences stdout log() prose in JSON mode. logErr() stays on `quiet` alone (stderr).
637
- const quiet = options.quiet ?? false;
638
- const suppressProgress = quiet || isJsonMode();
639
- const timestamp = new Date().toISOString();
640
- // Helper: emit progress line only when not suppressed. logErr is gated on `quiet` alone.
641
- const log = (msg) => { if (!suppressProgress)
642
- process.stdout.write(msg + '\n'); };
643
- const logErr = (msg) => { if (!quiet)
644
- process.stderr.write(msg + '\n'); };
645
- // Validate --level if provided (for handshake content filtering, not --init trust level)
646
- const VALID_HANDSHAKE_LEVELS = ['beginner', 'intermediate', 'expert'];
647
- if (level && !VALID_HANDSHAKE_LEVELS.includes(level)) {
648
- throw new CLIError(`Unknown level "${level}".`, {
649
- code: ErrorCode.VALIDATION_FAILED,
650
- category: 'validation',
651
- guidance: `Valid levels: ${VALID_HANDSHAKE_LEVELS.join(', ')}`,
652
- });
653
- }
654
- // 1. Detect repo
655
- const repo = detectRepo(cwd);
656
- log(`Detecting repo: ${repo.name ?? 'unknown'}${repo.repoSlug ? ` (${repo.repoSlug})` : ''}`);
657
- if (repo.detectedStack.length > 0) {
658
- log(`Stack: ${repo.detectedStack.join(', ')}`);
659
- }
660
- const startupSignals = deriveHandshakeStartupSignals(cwd, options.startupSignals ?? {});
661
- const orientArgs = { tier: 'standard' };
662
- if (options.invocationPath)
663
- orientArgs.invocationPath = options.invocationPath;
664
- if (Object.keys(startupSignals).length > 0) {
665
- orientArgs.startupSignals = startupSignals;
666
- if (startupSignals.taskText)
667
- orientArgs.task = startupSignals.taskText;
668
- }
669
- // Scopes the role rule lists this orient call returns to THIS session, not a sibling's.
670
- const activeSession = readSession();
671
- if (activeSession?.sessionId)
672
- orientArgs.sessionId = activeSession.sessionId;
673
- if (activeSession?.conversationId)
674
- orientArgs.conversationId = activeSession.conversationId;
675
- // 2. Fetch orient view + workspace readiness in parallel (budget max +200ms added latency)
676
- log('Fetching workspace context...');
677
- let orientView = null;
678
- let workspaceProfile = null;
679
- try {
680
- const workspaceReadinessPromise = kernelCall('chain.workspaceReadiness', {}).catch(() => null);
681
- const [orientResult, readinessRaw] = await Promise.all([
682
- kernelCall('chain.getOrientView', orientArgs).catch((err) => {
683
- logErr(`Warning: could not fetch workspace context — ${err instanceof Error ? err.message : err}`);
684
- logErr('Continuing with limited context (Chain search + portable knowledge only).');
685
- return null;
686
- }),
687
- workspaceReadinessPromise,
688
- ]);
689
- orientView = orientResult;
690
- workspaceProfile = extractWorkspaceProfile(readinessRaw);
691
- }
692
- catch (err) {
693
- logErr(`Warning: could not fetch workspace context — ${err instanceof Error ? err.message : err}`);
694
- logErr('Continuing with limited context (Chain search + portable knowledge only).');
695
- }
696
- if (workspaceProfile) {
697
- log(`Workspace profile: entries=${workspaceProfile.totalEntries}`);
698
- }
699
- // 3. Build search queries from repo context
700
- const searchQueries = [];
701
- if (repo.name && repo.name.length >= 2)
702
- searchQueries.push(repo.name);
703
- // Add repo slug parts (e.g. "Product-OS" -> "Product-OS")
704
- if (repo.repoSlug) {
705
- const repoPart = repo.repoSlug.split('/').pop();
706
- if (repoPart && repoPart.length >= 2 && repoPart !== repo.name) {
707
- searchQueries.push(repoPart);
708
- }
709
- }
710
- // Limit to 5 unique queries
711
- const uniqueQueries = [...new Set(searchQueries.filter((q) => q.length >= 2))].slice(0, 5);
712
- // 4. Search the Chain
713
- let matchedEntries = [];
714
- if (uniqueQueries.length > 0) {
715
- log(`Searching Chain for: ${uniqueQueries.join(', ')}...`);
716
- const searchResults = await Promise.all(uniqueQueries.map((q) => kernelCall('chain.searchEntries', { query: q }).catch(() => [])));
717
- matchedEntries = deduplicateEntries(searchResults.flat());
718
- }
719
- // 5. Read canonical skills & rules — DB-first (WP-345 S0c, TEN-1459): query setup.listAssetsForUser, FS fallback.
720
- const pbDir = join(cwd, '.productbrain');
721
- const manifestStatus = readManifestStatus(pbDir);
722
- const manifest = manifestStatus.manifest;
723
- const surfaceValidation = validateSurfacesForMode(manifestStatus.mode, manifestStatus.surfaces);
724
- if (applyMode && surfaceValidation.error === SURFACE_GOVERN_NO_SURFACES) {
725
- throw new CLIError('materialize: govern requires at least one registered manifest surface.', {
726
- code: ErrorCode.VALIDATION_FAILED,
727
- category: 'validation',
728
- guidance: 'Add surfaces such as `.cursor` or `.claude` to .productbrain/manifest.yaml.',
729
- });
730
- }
731
- for (const surface of surfaceValidation.unregisteredSurfaces) {
732
- logErr(`Warning: manifest surface "${surface}" is not registered; skipping it.`);
733
- }
734
- const manifestTargets = new Set(surfaceValidation.registeredSurfaces);
735
- const cliTargets = new Set();
736
- const ignoredCliSurfaces = [];
737
- for (const surface of options.surfaces ?? []) {
738
- const normalized = normalizeSurfaceName(surface);
739
- if (normalized && manifestTargets.has(normalized)) {
740
- cliTargets.add(normalized);
741
- }
742
- else {
743
- ignoredCliSurfaces.push(surface);
744
- }
745
- }
746
- if (ignoredCliSurfaces.length > 0) {
747
- logErr(`Warning: --surfaces ignored outside manifest.surfaces: ${ignoredCliSurfaces.join(', ')}`);
748
- }
749
- const allowedTargets = options.surfaces && options.surfaces.length > 0
750
- ? cliTargets
751
- : manifestTargets;
752
- const perimeterManifest = {
753
- surfaces: [...manifestTargets].flatMap(surfacePerimeterRoots),
754
- };
755
- const authorityCanWrite = manifestStatus.mode === 'project' || manifestStatus.mode === 'govern';
756
- const authorityPreviewOnly = applyMode && !authorityCanWrite;
757
- const writeMode = applyMode && authorityCanWrite;
758
- // TASK-554 (TEN-4036): materialize governs the coding-tool surfaces (DEC-951). PB's own files
759
- // under .productbrain/ (context.md, briefing.md, generated/) refresh on every --apply wherever
760
- // that folder exists; mode `off` means it does not (DEC-955), and then nothing is created.
761
- const pbOwnedWriteMode = applyMode && manifestStatus.mode !== 'off';
762
- // Skills and rules are read from the filesystem only (WP-776 S3f, DEC-1771).
763
- const allSkills = readCanonicalSkills(pbDir);
764
- const manualRules = readCanonicalRules(pbDir);
765
- // The --generate flag and its chain-rules generator are gone: the everyone/team/role rule lists
766
- // (DEC-1774) are the rule-delivery path (context.md, chain.mdc, everyone-rules.md).
767
- let allRules = manualRules;
768
- // 5b. Read personal layer (WP-310 E2) — machine-local rules/skills from .productbrain/.local/, [] if absent, all persist: 'local'.
769
- const personalRules = readPersonalLayer(pbDir);
770
- if (personalRules.length > 0) {
771
- // Name collision detection — warn when a personal rule overrides a team rule
772
- const teamRuleNames = new Set(allRules.map((r) => r.name));
773
- for (const pr of personalRules) {
774
- if (teamRuleNames.has(pr.name)) {
775
- logErr(`Personal rule "${pr.name}" overrides team rule (local takes precedence).`);
776
- }
777
- }
778
- // Merge: team rules first, personal rules appended (personal takes precedence via name collision above)
779
- allRules = [...allRules, ...personalRules];
780
- }
781
- const personalSkills = readPersonalSkillsLayer(pbDir);
782
- if (personalSkills.length > 0) {
783
- const teamSkillNames = new Set(allSkills.map((s) => s.name));
784
- for (const ps of personalSkills) {
785
- if (teamSkillNames.has(ps.name)) {
786
- logErr(`Personal skill "${ps.name}" overrides team skill (local takes precedence).`);
787
- }
788
- }
789
- allSkills.push(...personalSkills);
790
- }
791
- // 5d. Load method registry (WP-310 E4) — only when manifest is present. Unrelated to the
792
- // setup-assets system (WP-329's own "methods" Chain collection, no setup.* coupling) — kept.
793
- let registrySource;
794
- let registryStale;
795
- if (manifest) {
796
- const registryResult = await loadMethodRegistry().catch(() => null);
797
- if (registryResult) {
798
- registrySource = registryResult.source;
799
- registryStale = registryResult.stale;
800
- }
801
- }
802
- const adoptionFilteredSkills = filterByAdoptionState(allSkills, manifest);
803
- const adoptionFilteredRules = filterByAdoptionState(allRules, manifest);
804
- // Compute adoption counts for report (only meaningful when manifest is present)
805
- const adoptedRulesCount = manifest ? adoptionFilteredRules.length : undefined;
806
- const rejectedRulesCount = manifest ? allRules.length - adoptionFilteredRules.length : undefined;
807
- // Level filtering: requested level only (WP-762 C4e-2, DEC-1800 — no more stage cap).
808
- const levelFilteredSkills = filterByLevel(adoptionFilteredSkills, level);
809
- const levelFilteredRules = filterByLevel(adoptionFilteredRules, level);
810
- // Apply when-condition filtering (workspace profile + repo context)
811
- const canonicalSkills = levelFilteredSkills.filter((skill) => {
812
- const result = evaluateConditions(skill.conditions ?? {}, workspaceProfile, repo);
813
- if (dryRun && !result.included) {
814
- log(` EXCLUDED skill ${skill.name}: ${result.reasons.join(', ')}`);
815
- }
816
- return result.included;
817
- });
818
- const canonicalRules = levelFilteredRules.filter((rule) => {
819
- const result = evaluateConditions(rule.conditions ?? {}, workspaceProfile, repo);
820
- if (dryRun && !result.included) {
821
- log(` EXCLUDED rule ${rule.name}: ${result.reasons.join(', ')}`);
822
- }
823
- return result.included;
824
- });
825
- if (dryRun && canonicalSkills.length > 0) {
826
- log(` INCLUDED skills: ${canonicalSkills.map((s) => s.name).join(', ')}`);
827
- }
828
- if (dryRun && canonicalRules.length > 0) {
829
- log(` INCLUDED rules: ${canonicalRules.map((r) => r.name).join(', ')}`);
830
- }
831
- const noProjectionAssets = findNoProjectionAssets(canonicalSkills, canonicalRules);
832
- if (canonicalSkills.length > 0 || canonicalRules.length > 0) {
833
- const levelSuffix = level ? ` (level: ${level})` : '';
834
- const stackSuffix = repo.detectedStack.length > 0 ? `, stack: [${repo.detectedStack.join(', ')}]` : '';
835
- const totalSkills = allSkills.length;
836
- const totalRules = allRules.length;
837
- log(`Portable knowledge: ${canonicalSkills.length}/${totalSkills} skills, ${canonicalRules.length}/${totalRules} rules${levelSuffix}${stackSuffix}`);
838
- }
839
- // 6. Generate file contents
840
- // Build workspace context for AGENTS.md enrichment (focus, entry count) — TASK-435 (C4c) deleted `stage` from it.
841
- const agentsWorkspaceContext = workspaceProfile
842
- ? {
843
- focus: orientView?.strategicContext?.currentWorkPackage ?? undefined,
844
- totalEntries: workspaceProfile.totalEntries,
845
- }
846
- : undefined;
847
- // Collect codex-targeted skills for AGENTS.md skill directory — persist: 'local' entries included (all projections are local-only now).
848
- const agentsCodexSkills = canonicalSkills
849
- .filter((s) => shouldEmitToTarget(s, 'codex'))
850
- .map((s) => ({
851
- name: s.name,
852
- description: s.description,
853
- triggers: s.triggers,
854
- }));
855
- // Collect copilot-targeted skills for copilot-instructions.md skill summaries
856
- const copilotSkills = canonicalSkills
857
- .filter((s) => shouldEmitToTarget(s, 'copilot'))
858
- .map((s) => ({
859
- name: s.name,
860
- description: s.description,
861
- triggers: s.triggers,
862
- }));
863
- // Collect copilot-targeted rules for copilot-instructions.md rule summaries
864
- const copilotRules = canonicalRules
865
- .filter((r) => shouldEmitToTarget(r, 'copilot'))
866
- .map((r) => ({
867
- name: r.name,
868
- description: r.description,
869
- }));
870
- const copilotProfile = resolveSurfaceProfile('copilot');
871
- const copilotOptions = {
872
- profile: copilotProfile,
873
- workspaceContext: agentsWorkspaceContext,
874
- skills: copilotSkills.length > 0 ? copilotSkills : undefined,
875
- rules: copilotRules.length > 0 ? copilotRules : undefined,
876
- };
877
- // TASK-554 (WP-777, DEC-1888): one workspace briefing (spine and the Everyone rule list, from
878
- // the kernel builder) feeds context.md, everyone-rules.md and chain.mdc. `null` means the orient
879
- // fetch failed: the two briefing files are not written (and a stale copy is removed in apply
880
- // mode below), and chain.mdc says the briefing is unavailable.
881
- const workspaceBriefingLines = orientView ? formatWorkspaceBriefingLines(servedBriefing(orientView), orientView.ruleLists) : null;
882
- const contextContent = orientView && workspaceBriefingLines ? generateContextMd(orientView, workspaceBriefingLines, repo, timestamp) : null;
883
- const everyoneRulesContent = workspaceBriefingLines ? generateEveryoneRulesMd(workspaceBriefingLines, timestamp) : null;
884
- const briefingContent = generateBriefingMd(matchedEntries, repo, uniqueQueries, timestamp);
885
- const agentsContent = generateAgentsMd(timestamp, {
886
- workspaceContext: agentsWorkspaceContext,
887
- skills: agentsCodexSkills.length > 0 ? agentsCodexSkills : undefined,
888
- });
889
- const claudeContent = generateClaudeMd(timestamp);
890
- const cursorContent = generateCursorMdc(timestamp, workspaceBriefingLines);
891
- const copilotContent = generateCopilotMd(timestamp, copilotOptions);
892
- // DEC-1343: one committed .github/instructions/<slug>.instructions.md per copilot rule with applyTo; repo-wide rules stay above.
893
- const copilotInstructionFiles = generateCopilotInstructionFiles(canonicalRules
894
- .filter((r) => shouldEmitToTarget(r, 'copilot'))
895
- // `.github/instructions/` is COMMITTED — never project a machine-local (persist: 'local') rule into it.
896
- .filter((r) => r.persist !== 'local')
897
- .map((r) => ({ name: r.name, body: r.body, applyTo: r.applyTo, sourcePath: r.sourcePath })), timestamp);
898
- const boundaryEnforcementMode = getBoundaryEnforcementMode(manifest);
899
- const boundaryManifestContent = boundaryEnforcementMode === 'advisory'
900
- ? null
901
- : generateBoundaryManifest(pbDir);
902
- // 7. Write files
903
- const filesWritten = [];
904
- const filesSkipped = [];
905
- const previewPlan = [];
906
- const writes = [
907
- ...(contextContent ? [{ path: join(cwd, '.productbrain', 'context.md'), relative: '.productbrain/context.md', content: contextContent, dirs: join(cwd, '.productbrain'), isAdapter: false }] : []),
908
- ...(everyoneRulesContent
909
- ? [{
910
- path: join(cwd, '.productbrain', 'generated', 'everyone-rules.md'),
911
- relative: '.productbrain/generated/everyone-rules.md',
912
- content: everyoneRulesContent,
913
- dirs: join(cwd, '.productbrain', 'generated'),
914
- isAdapter: false,
915
- }]
916
- : []),
917
- { path: join(cwd, '.productbrain', 'briefing.md'), relative: '.productbrain/briefing.md', content: briefingContent, isAdapter: false },
918
- { path: join(cwd, 'AGENTS.md'), relative: 'AGENTS.md', content: agentsContent, isAdapter: true, target: 'codex', augmentTarget: true },
919
- { path: join(cwd, 'CLAUDE.md'), relative: 'CLAUDE.md', content: claudeContent, isAdapter: true, target: 'claude', augmentTarget: true },
920
- { path: join(cwd, '.cursor', 'rules', 'chain.mdc'), relative: '.cursor/rules/chain.mdc', content: cursorContent, dirs: join(cwd, '.cursor', 'rules'), isAdapter: true, target: 'cursor' },
921
- { path: join(cwd, '.github', 'copilot-instructions.md'), relative: '.github/copilot-instructions.md', content: copilotContent, dirs: join(cwd, '.github'), isAdapter: true, target: 'copilot' },
922
- ...copilotInstructionFiles.map((f) => ({
923
- path: join(cwd, f.relativePath),
924
- relative: f.relativePath,
925
- content: f.content,
926
- dirs: join(cwd, '.github', 'instructions'),
927
- isAdapter: true,
928
- target: 'copilot',
929
- })),
930
- ...(boundaryManifestContent ? [{
931
- path: join(cwd, '.productbrain', 'generated', 'boundaries.json'),
932
- relative: '.productbrain/generated/boundaries.json',
933
- content: boundaryManifestContent,
934
- dirs: join(cwd, '.productbrain', 'generated'),
935
- isAdapter: false,
936
- }] : []),
937
- ];
938
- // Add Cursor skill copies (filtered by target)
939
- const cursorProfile = resolveSurfaceProfile('cursor');
940
- for (const skill of canonicalSkills) {
941
- if (!shouldEmitToTarget(skill, 'cursor'))
942
- continue;
943
- const dbAssetEntryId = skill.sourcePath.startsWith('db:') ? skill.sourcePath.slice(3) : undefined;
944
- const skillDir = join(cwd, '.cursor', 'skills', skill.name);
945
- writes.push({
946
- path: join(skillDir, 'SKILL.md'),
947
- relative: `.cursor/skills/${skill.name}/SKILL.md`,
948
- content: generateCursorSkill(skill, cursorProfile),
949
- dirs: skillDir,
950
- isAdapter: true,
951
- target: 'cursor',
952
- dbAssetEntryId,
953
- });
954
- }
955
- // Add Codex skill copies (projected markdown + index)
956
- const codexProfile = resolveSurfaceProfile('codex');
957
- const codexSkills = canonicalSkills.filter((s) => shouldEmitToTarget(s, 'codex'));
958
- for (const skill of codexSkills) {
959
- const dbAssetEntryId = skill.sourcePath.startsWith('db:') ? skill.sourcePath.slice(3) : undefined;
960
- writes.push({
961
- path: join(cwd, '.codex', 'skills', `${skill.name}.md`),
962
- relative: `.codex/skills/${skill.name}.md`,
963
- content: generateCodexSkill(skill, codexProfile),
964
- dirs: join(cwd, '.codex', 'skills'),
965
- isAdapter: true,
966
- target: 'codex',
967
- dbAssetEntryId,
968
- });
969
- }
970
- writes.push({
971
- path: join(cwd, '.codex', 'skills', 'README.md'),
972
- relative: '.codex/skills/README.md',
973
- content: generateCodexSkillIndex(codexSkills),
974
- dirs: join(cwd, '.codex', 'skills'),
975
- isAdapter: true,
976
- target: 'codex',
977
- });
978
- // Validate Codex-projected skills for dead references
979
- const codexWarnings = validateCodexSkills(codexSkills);
980
- // Add Cursor rule copies (filtered by target)
981
- for (const rule of canonicalRules) {
982
- if (!shouldEmitToTarget(rule, 'cursor'))
983
- continue;
984
- const dbAssetEntryId = rule.sourcePath.startsWith('db:') ? rule.sourcePath.slice(3) : undefined;
985
- writes.push({
986
- path: join(cwd, '.cursor', 'rules', `${rule.name}.mdc`),
987
- relative: `.cursor/rules/${rule.name}.mdc`,
988
- content: generateCursorRule(rule, cursorProfile),
989
- dirs: join(cwd, '.cursor', 'rules'),
990
- isAdapter: true,
991
- target: 'cursor',
992
- dbAssetEntryId,
993
- });
994
- }
995
- // 7a. WP-379 S5b: find leftover projections (apply mode) — REPORT-ONLY (coordinator ruling, PR
996
- // #912 round 10), see findLeftoverProjections's own header. WP-776 S3f: gated on writeMode
997
- // alone now — the DB source (dbQuerySucceeded) is gone, so activeAssetNames comes from the
998
- // filesystem-sourced allSkills/allRules (which already include the personal layer, merged in
999
- // above) instead of dbAssetRows.
1000
- let leftoverFiles = [];
1001
- let leftoverFilesTruncated = false;
1002
- // TASK-554 (RULE-659): a failed orient fetch is named in the report, and apply removes stale briefing copies.
1003
- if (orientView === null) {
1004
- for (const path of ['.productbrain/context.md', '.productbrain/generated/everyone-rules.md'])
1005
- filesSkipped.push({ path, reason: 'briefing unavailable — the orient fetch failed' });
1006
- const stale = pbOwnedWriteMode ? removeStaleBriefingProjections(cwd) : [];
1007
- if (stale.length > 0)
1008
- logErr(`Warning: the workspace briefing could not be fetched; removed the stale ${stale.join(', ')}.`);
1009
- }
1010
- if (writeMode) {
1011
- retireGeneratedProjections(cwd, log);
1012
- const activeAssetNames = [
1013
- ...allSkills.map((s) => s.name),
1014
- ...allRules.map((r) => r.name),
1015
- ];
1016
- // Nested .cursor/skills scan gets its OWN canon: canonicalSkills is SKILL-kind only and
1017
- // shouldEmitToTarget(_, 'cursor') respects per-skill target routing.
1018
- const cursorSkillNames = canonicalSkills.filter((s) => shouldEmitToTarget(s, 'cursor')).map((s) => s.name);
1019
- // Round 11: exempt exact write-plan paths from the leftover scan (see findLeftoverProjections's own doc).
1020
- const exactRelativePaths = new Set(writes.map((w) => w.relative));
1021
- const scanResult = findLeftoverProjections(cwd, activeAssetNames, allowedTargets, cursorSkillNames, exactRelativePaths);
1022
- leftoverFiles = scanResult.files;
1023
- leftoverFilesTruncated = scanResult.truncated;
1024
- }
1025
- // ── WP-436 S3: Vocab projector — fetch workspace vocab context once per handshake. Source-side
1026
- // stays tokenized; resolves {{vocab:...}} before writing. Fail-open: a failed fetch writes the raw token.
1027
- const handshakeVocabCtx = await getOrFetchVocabCtx(config.apiKey, async () => {
1028
- try {
1029
- const vocab = await kernelCall('chain.getVocabulary', {});
1030
- if (vocab?.collectionLabels || vocab?.collectionDefaults) {
1031
- return {
1032
- ...(vocab.collectionLabels ? { collectionLabels: vocab.collectionLabels } : {}),
1033
- ...(vocab.collectionDefaults ? { collectionDefaults: vocab.collectionDefaults } : {}),
1034
- };
1035
- }
1036
- return null;
1037
- }
1038
- catch {
1039
- return null; // fail-open
1040
- }
1041
- });
1042
- const userOwnedSkipped = [];
1043
- const cleanBucketPaths = [];
1044
- const tamperedBucket = [];
1045
- // TEN-2155: region augmentation context + symlink dedupe — codexActive gates the AGENTS.md skills-index pointer.
1046
- const regionCtx = { codexActive: allowedTargets.has('codex') };
1047
- const malformedRegionPaths = [];
1048
- // Same inode for two augment targets (e.g. CLAUDE.md symlinked to AGENTS.md): augment only first-seen.
1049
- const seenAugmentRealpaths = new Set();
1050
- const augmentTargetSkip = new Set();
1051
- for (const w of writes) {
1052
- if (!w.augmentTarget || !w.target || !allowedTargets.has(w.target) || !existsSync(w.path))
1053
- continue;
1054
- let real;
1055
- try {
1056
- real = realpathSync(w.path);
1057
- }
1058
- catch {
1059
- real = w.path;
1060
- }
1061
- if (seenAugmentRealpaths.has(real))
1062
- augmentTargetSkip.add(w.path);
1063
- else
1064
- seenAugmentRealpaths.add(real);
1065
- }
1066
- for (const w of writes) {
1067
- // Surface filtering: skip adapter writes for targets not in the allowed set
1068
- if (w.target && !allowedTargets.has(w.target)) {
1069
- filesSkipped.push({ path: w.relative, reason: `filtered (surface: ${w.target})` });
1070
- if (preview)
1071
- previewPlan.push({ path: w.relative, status: 'filtered' });
1072
- continue;
1073
- }
1074
- // ── TEN-2155: augment user-owned CLAUDE.md / AGENTS.md with a marked PB region — runs ahead
1075
- // of the legacy MARKER gates; absent/legacy-pb-managed fall through to the legacy path.
1076
- if (w.augmentTarget) {
1077
- const projection = REGION_PROJECTIONS[w.target];
1078
- if (projection && existsSync(w.path)) {
1079
- if (augmentTargetSkip.has(w.path)) {
1080
- filesSkipped.push({ path: w.relative, reason: 'symlinked to another augment target — augmented once' });
1081
- continue;
1082
- }
1083
- const disk = readFileSync(w.path, 'utf8');
1084
- const cls = classifyAdapterFile(disk);
1085
- if (cls === 'augmentable' || cls === 'region-present') {
1086
- const eol = detectEol(disk);
1087
- const region = replaceVocabTokens(projection.build(regionCtx, eol), handshakeVocabCtx);
1088
- // Defense-in-depth: the composed region (post vocab-token resolution) must itself be a
1089
- // single well-formed region. v1 content has no vocab tokens so this is a no-op, but it
1090
- // guards the documented future override/vocab seam from injecting a stray sentinel/MARKER.
1091
- if (classifyAdapterFile(region) !== 'region-present') {
1092
- filesSkipped.push({ path: w.relative, reason: 'internal: composed PB region malformed — skipped to protect your file' });
1093
- if (preview)
1094
- previewPlan.push({ path: w.relative, status: 'needs-attention' });
1095
- continue;
1096
- }
1097
- const candidate = cls === 'augmentable' ? spliceAppend(disk, region, eol) : spliceReplace(disk, region);
1098
- if (candidate === disk) {
1099
- filesSkipped.push({ path: w.relative, reason: 'unchanged' });
1100
- if (preview)
1101
- previewPlan.push({ path: w.relative, status: 'unchanged' });
1102
- continue;
1103
- }
1104
- if (preview || dryRun) {
1105
- filesWritten.push(w.relative + (dryRun ? ' (dry run)' : ''));
1106
- if (preview)
1107
- previewPlan.push({ path: w.relative, status: 'would-augment' });
1108
- continue;
1109
- }
1110
- if (authorityPreviewOnly) {
1111
- // apply + materialize observe/off: honor authority — do NOT write; mirror the legacy
1112
- // preview-only skip so the report does not falsely list it as written.
1113
- filesSkipped.push({ path: w.relative, reason: `preview-only (materialize: ${manifestStatus.mode})` });
1114
- continue;
1115
- }
1116
- assertSetupWritePath(w.path, perimeterManifest);
1117
- writeFileSync(w.path, candidate);
1118
- filesWritten.push(w.relative);
1119
- continue;
1120
- }
1121
- if (cls === 'malformed') {
1122
- filesSkipped.push({ path: w.relative, reason: 'malformed PB region — left untouched; fix the pb:region sentinels' });
1123
- if (preview)
1124
- previewPlan.push({ path: w.relative, status: 'needs-attention' });
1125
- malformedRegionPaths.push(w.relative);
1126
- continue;
1127
- }
1128
- // cls 'pb-managed' falls through to legacy re-projection; 'opt-out' (pb:no-augment) falls through to the user-owned skip.
1129
- }
1130
- // absent file → fall through to legacy whole-file CREATE.
1131
- }
1132
- if (w.isAdapter && !shouldWriteAdapter(w.path, force)) {
1133
- // User-owned: a file at an adapter path without our auto-gen MARKER is the
1134
- // user's own file. Leave it untouched — never overwrite, never treat as drift
1135
- // or log a TEN (TEN-2150). Relayed to the connect screen via userOwnedSkipped.
1136
- filesSkipped.push({ path: w.relative, reason: 'user-owned — left untouched (pb won\'t overwrite your file)' });
1137
- if (preview) {
1138
- previewPlan.push({ path: w.relative, status: 'user-owned' });
1139
- }
1140
- else {
1141
- userOwnedSkipped.push(w.relative);
1142
- }
1143
- continue;
1144
- }
1145
- if (w.isAdapter ? authorityPreviewOnly : applyMode && !pbOwnedWriteMode) {
1146
- filesSkipped.push({
1147
- path: w.relative,
1148
- reason: `preview-only (materialize: ${manifestStatus.mode})`,
1149
- });
1150
- continue;
1151
- }
1152
- if (preview || dryRun) {
1153
- // In preview/dry-run mode: check content to distinguish new/update/unchanged
1154
- if (existsSync(w.path)) {
1155
- const current = readFileSync(w.path, 'utf8');
1156
- const nextNormalized = normalizeHandshakeContentForComparison(w.content);
1157
- const currentNormalized = normalizeHandshakeContentForComparison(current);
1158
- if (nextNormalized === currentNormalized) {
1159
- filesSkipped.push({ path: w.relative, reason: 'unchanged' });
1160
- if (preview)
1161
- previewPlan.push({ path: w.relative, status: 'unchanged' });
1162
- }
1163
- else {
1164
- filesWritten.push(w.relative + (dryRun ? ' (dry run)' : ''));
1165
- if (preview)
1166
- previewPlan.push({ path: w.relative, status: 'would-update' });
1167
- }
1168
- }
1169
- else {
1170
- filesWritten.push(w.relative + (dryRun ? ' (dry run)' : ''));
1171
- if (preview)
1172
- previewPlan.push({ path: w.relative, status: 'would-write' });
1173
- }
1174
- continue;
1175
- }
1176
- // ── WP-421 S3: defer tampered adapter writes (doneWhen #17) — classify pb-managed-clean vs
1177
- // pb-managed-tampered, deferred not overwritten; post-loop resolves. --force bypasses the defer.
1178
- if (w.isAdapter && !force) {
1179
- const drift = classifyDriftBucket(w.path);
1180
- if (drift && drift.bucket === 'pb-managed-tampered') {
1181
- tamperedBucket.push({
1182
- path: w.path,
1183
- relative: w.relative,
1184
- content: w.content,
1185
- expectedHash: drift.expectedHash,
1186
- actualHash: drift.actualHash,
1187
- dirs: w.dirs,
1188
- });
1189
- // Do NOT write — defer until prompt/refusal resolves the file.
1190
- continue;
1191
- }
1192
- if (drift && drift.bucket === 'pb-managed-clean') {
1193
- cleanBucketPaths.push(w.relative);
1194
- }
1195
- }
1196
- assertSetupWritePath(w.path, perimeterManifest);
1197
- if (w.dirs)
1198
- mkdirSync(w.dirs, { recursive: true });
1199
- // WP-436 S3: resolve {{vocab:...}} tokens before writing projected adapter files only
1200
- // (source-side stays tokenized). Fail-open: undefined vocabCtx falls back to canonicalKey literals.
1201
- const resolvedContent = w.isAdapter
1202
- ? replaceVocabTokens(w.content, handshakeVocabCtx)
1203
- : w.content;
1204
- if (existsSync(w.path)) {
1205
- const current = readFileSync(w.path, 'utf8');
1206
- const nextNormalized = normalizeHandshakeContentForComparison(resolvedContent);
1207
- const currentNormalized = normalizeHandshakeContentForComparison(current);
1208
- if (nextNormalized === currentNormalized) {
1209
- filesSkipped.push({ path: w.relative, reason: 'unchanged' });
1210
- continue;
1211
- }
1212
- }
1213
- writeFileSync(w.path, resolvedContent);
1214
- filesWritten.push(w.relative);
1215
- }
1216
- // Runs AFTER the write loop (malformed files were never written, nothing to record). TEN-2155:
1217
- // non-interactive apply refuses on a malformed region — gated on applyMode per STD-263 vi.
1218
- if (applyMode && malformedRegionPaths.length > 0 && (options.noPrompt || !process.stdout.isTTY)) {
1219
- throw new CLIError(`Malformed PB region in: ${malformedRegionPaths.join(', ')}`, {
1220
- code: ErrorCode.VALIDATION_FAILED,
1221
- category: 'validation',
1222
- guidance: 'Fix the `<!-- pb:region:start -->` / `<!-- pb:region:end -->` sentinels (one balanced pair), then re-run `pb handshake`.',
1223
- });
1224
- }
1225
- // ── WP-421 S3: tampered-bucket resolution (doneWhen #17), apply mode only — headless (--no-prompt
1226
- // / non-TTY) refuses, exit non-zero. WP-776 S3d/S3f: the setup-assets system this resolution used
1227
- // to offer as an "adopt" alternative (capture tampered edits as a personal-scoped draft via
1228
- // setup.ingestSetupAsset) is deleted, and with it the only OTHER option besides overwriting —
1229
- // per DEC-1771 (delete, never hide) there is no shim standing in for that capability. Revert
1230
- // (re-project canonical content) is the only resolution left; this is a real capability loss
1231
- // for anyone who was relying on "adopt" to preserve hand-edits, named here rather than silently
1232
- // dropped. The refusal receipt (`setup.recordTamperRefusal`) is deleted along with the rest of
1233
- // the setup-assets gateway routes — headless mode still refuses and reports, it just no longer
1234
- // writes an audit row for the refusal.
1235
- const revertedTamperedPaths = [];
1236
- if (writeMode && tamperedBucket.length > 0) {
1237
- const headless = options.noPrompt === true || !process.stdout.isTTY;
1238
- if (headless) {
1239
- // ── Headless refusal path (doneWhen #17) ────────────────────────────────
1240
- logErr('');
1241
- logErr(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
1242
- logErr('Headless mode (--no-prompt or no TTY) cannot confirm an overwrite — refusing.');
1243
- logErr('');
1244
- const refusedTamperedFiles = tamperedBucket.map((t) => ({
1245
- path: t.relative,
1246
- expectedHash: t.expectedHash,
1247
- actualHash: t.actualHash,
1248
- }));
1249
- for (const refused of refusedTamperedFiles) {
1250
- // Per #17: stderr enumerates each tampered file as
1251
- // {path, expectedHash, actualHash, bucket}.
1252
- logErr(` ${JSON.stringify({ ...refused, bucket: 'pb-managed-tampered' })}`);
1253
- }
1254
- logErr('');
1255
- logErr('Re-run interactively to resolve, or use --force to overwrite (data loss).');
1256
- // Surface the report counts before exit (visibility for CI logs).
1257
- if (!quiet) {
1258
- const refusalReport = {
1259
- filesWritten,
1260
- filesSkipped,
1261
- matchedEntries,
1262
- searchQueries: uniqueQueries,
1263
- repo,
1264
- codexWarnings: codexWarnings.length > 0 ? codexWarnings : undefined,
1265
- adoptedCount: adoptedRulesCount,
1266
- rejectedCount: rejectedRulesCount,
1267
- personalRuleCount: personalRules.length > 0 ? personalRules.length : undefined,
1268
- personalSkillCount: personalSkills.length > 0 ? personalSkills.length : undefined,
1269
- registrySource,
1270
- registryStale,
1271
- userOwnedSkipped: userOwnedSkipped.length > 0 ? userOwnedSkipped : undefined,
1272
- managedCleanCount: cleanBucketPaths.length || undefined,
1273
- tamperedFiles: refusedTamperedFiles,
1274
- leftoverFiles: leftoverFiles.length > 0 ? leftoverFiles : undefined,
1275
- leftoverFilesTruncated: leftoverFilesTruncated || undefined, noProjectionAssets: noProjectionAssets.length > 0 ? noProjectionAssets : undefined, // same-line combine, shrink-only budget
1276
- };
1277
- process.stdout.write('\n');
1278
- process.stdout.write(isJsonMode() ? JSON.stringify(toPublicJsonReport(refusalReport)) + '\n' : formatHandshakeReport(refusalReport) + '\n');
1279
- }
1280
- // Exit non-zero per #17.
1281
- process.exit(1);
1282
- }
1283
- // ── Interactive path: confirm, then revert every tampered file ─────────────
1284
- log('');
1285
- log(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
1286
- log('These will be REVERTED (overwritten with canonical content) — there is no adopt/draft path any more.');
1287
- const proceed = await promptConfirm({
1288
- message: `Overwrite ${tamperedBucket.length} tampered file(s) with canonical content?`,
1289
- initialValue: false,
1290
- });
1291
- if (proceed) {
1292
- for (const tamper of tamperedBucket) {
1293
- log('');
1294
- log(`Reverting: ${tamper.relative}`);
1295
- log(` expected: ${tamper.expectedHash}`);
1296
- log(` actual: ${tamper.actualHash}`);
1297
- // Re-project canonical content over the tampered file.
1298
- // WP-436 S3: resolve vocab tokens before writing (all tampered files are adapters).
1299
- if (tamper.dirs)
1300
- mkdirSync(tamper.dirs, { recursive: true });
1301
- assertSetupWritePath(tamper.path, perimeterManifest);
1302
- writeFileSync(tamper.path, replaceVocabTokens(tamper.content, handshakeVocabCtx));
1303
- revertedTamperedPaths.push(tamper.relative);
1304
- }
1305
- }
1306
- else {
1307
- logErr('Skipped — tampered files left as-is; re-run to resolve.');
1308
- process.exit(1); // doneWhen #17: never exit 0 with a tampered file unresolved (codex review round 3)
1309
- }
1310
- }
1311
- // DEC-1690: a dormant (gate-failed) asset is simply not projected — dbAssets already comes
1312
- // from the server's activeAssets list. The WP-379 S4 / WP-426 E4 on-disk marker/rename/restore
1313
- // machinery (and its .authoring-sync.json registries) is retired: nothing to reconcile.
1314
- // 8. User-owned files left untouched are NOT drift (TEN-2150) — a marker-less adapter-path
1315
- // file is the user's own; handshake never wrote it, so no "sync", no draft TEN, just a benign
1316
- // "Skipped:" surfaced via report.userOwnedSkipped.
1317
- // 8. Case-collision and dormant-drift TENs are gone (coordinator ruling, PR #912 round 10) —
1318
- // they existed only to audit automated deletions that no longer happen; `report.leftoverFiles`
1319
- // below is the single reporting surface now.
1320
- // 9. Report
1321
- const report = {
1322
- filesWritten,
1323
- filesSkipped,
1324
- matchedEntries,
1325
- searchQueries: uniqueQueries,
1326
- repo,
1327
- codexWarnings: codexWarnings.length > 0 ? codexWarnings : undefined,
1328
- adoptedCount: adoptedRulesCount,
1329
- rejectedCount: rejectedRulesCount,
1330
- personalRuleCount: personalRules.length > 0 ? personalRules.length : undefined,
1331
- personalSkillCount: personalSkills.length > 0 ? personalSkills.length : undefined,
1332
- registrySource,
1333
- registryStale,
1334
- preview: preview ? true : undefined,
1335
- previewPlan: preview && previewPlan.length > 0 ? previewPlan : undefined,
1336
- userOwnedSkipped: userOwnedSkipped.length > 0 ? userOwnedSkipped : undefined,
1337
- // WP-421 S3: three-bucket drift report (doneWhen #17). PB-managed-clean is
1338
- // the count of files whose marker + hash matched. Tampered files were
1339
- // resolved (reverted) above and are reported separately. WP-776 S3d/S3f:
1340
- // adoptedTamperedPaths is gone — "adopt" is no longer an option (see the
1341
- // tampered-bucket resolution comment above).
1342
- managedCleanCount: cleanBucketPaths.length > 0 ? cleanBucketPaths.length : undefined,
1343
- revertedTamperedPaths: revertedTamperedPaths.length > 0 ? revertedTamperedPaths : undefined,
1344
- // REPORT-ONLY leftover-file listing (coordinator ruling, PR #912 round 10) — the human removes these.
1345
- leftoverFiles: leftoverFiles.length > 0 ? leftoverFiles : undefined,
1346
- leftoverFilesTruncated: leftoverFilesTruncated || undefined, noProjectionAssets: noProjectionAssets.length > 0 ? noProjectionAssets : undefined, // WP-660 S2 combine
1347
- };
1348
- if (!quiet) {
1349
- process.stdout.write('\n');
1350
- process.stdout.write(isJsonMode() ? JSON.stringify(toPublicJsonReport(report)) + '\n' : formatHandshakeReport(report) + '\n');
1351
- }
1352
- // Return the report so non-UI callers (e.g. prepareConnectContext) can surface
1353
- // skipped/user-owned files without re-deriving the skip logic (TEN-2107).
1354
- return report;
1355
- }
1356
- // WP-426 E3/E4: exported test-only surface (not part of the public CLI API). setupAuthoringPath
1357
- // and loadAuthoringSyncState are gone with the setup-authoring sync machinery (WP-776 S3d/S3f).
1358
- export const __test = { MARKER };
1359
- //# sourceMappingURL=handshake.js.map