@productbrain/cli 0.1.0-beta.100 → 0.1.0-beta.104

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 (41) hide show
  1. package/dist/__tests__/handshake.test.js +361 -2
  2. package/dist/__tests__/handshake.test.js.map +1 -1
  3. package/dist/__tests__/workspace.test.js +25 -5
  4. package/dist/__tests__/workspace.test.js.map +1 -1
  5. package/dist/commands/admin/seed.d.ts +14 -0
  6. package/dist/commands/admin/seed.d.ts.map +1 -1
  7. package/dist/commands/admin/seed.js +196 -0
  8. package/dist/commands/admin/seed.js.map +1 -1
  9. package/dist/commands/admin/seed.test.d.ts +5 -0
  10. package/dist/commands/admin/seed.test.d.ts.map +1 -1
  11. package/dist/commands/admin/seed.test.js +60 -2
  12. package/dist/commands/admin/seed.test.js.map +1 -1
  13. package/dist/commands/connect-screens.d.ts +6 -3
  14. package/dist/commands/connect-screens.d.ts.map +1 -1
  15. package/dist/commands/connect-screens.js +26 -8
  16. package/dist/commands/connect-screens.js.map +1 -1
  17. package/dist/commands/handshake.d.ts +113 -1
  18. package/dist/commands/handshake.d.ts.map +1 -1
  19. package/dist/commands/handshake.js +437 -17
  20. package/dist/commands/handshake.js.map +1 -1
  21. package/dist/commands/workspace.d.ts +22 -1
  22. package/dist/commands/workspace.d.ts.map +1 -1
  23. package/dist/commands/workspace.js +1 -1
  24. package/dist/commands/workspace.js.map +1 -1
  25. package/dist/generators/__tests__/surface-profiles.test.d.ts +2 -0
  26. package/dist/generators/__tests__/surface-profiles.test.d.ts.map +1 -0
  27. package/dist/generators/__tests__/surface-profiles.test.js +89 -0
  28. package/dist/generators/__tests__/surface-profiles.test.js.map +1 -0
  29. package/dist/lib/normalizeMaterializedFilename.d.ts +28 -0
  30. package/dist/lib/normalizeMaterializedFilename.d.ts.map +1 -0
  31. package/dist/lib/normalizeMaterializedFilename.js +56 -0
  32. package/dist/lib/normalizeMaterializedFilename.js.map +1 -0
  33. package/dist/lib/normalizeMaterializedFilename.test.d.ts +16 -0
  34. package/dist/lib/normalizeMaterializedFilename.test.d.ts.map +1 -0
  35. package/dist/lib/normalizeMaterializedFilename.test.js +90 -0
  36. package/dist/lib/normalizeMaterializedFilename.test.js.map +1 -0
  37. package/dist/lib/onboarding-phases.d.ts +9 -0
  38. package/dist/lib/onboarding-phases.d.ts.map +1 -0
  39. package/dist/lib/onboarding-phases.js +120 -0
  40. package/dist/lib/onboarding-phases.js.map +1 -0
  41. package/package.json +1 -1
@@ -1,7 +1,74 @@
1
1
  /**
2
2
  * pb handshake — generate context files for AI developer tools.
3
- * The fourth delivery surface: context export (GLO-63, DEC-161).
3
+ * Context export wiring (read-only filesystem bridge; GLO-63, DEC-161) — not a product surface.
4
4
  */
5
+ import type { WorkspaceHealthResult } from './workspace.js';
6
+ /**
7
+ * Wire-safe alias for a single health gap.
8
+ * Derived from the CLI mirror in workspace.ts — no convex/ import needed.
9
+ */
10
+ export type HealthGap = WorkspaceHealthResult['gaps'][number];
11
+ /**
12
+ * Structured result returned by probeStarterSetupSeeded when seeds are still
13
+ * pending or when the health query is unavailable.
14
+ */
15
+ export type SeedsPendingResult = {
16
+ status: 'seeds-pending';
17
+ gaps: ReadonlyArray<HealthGap>;
18
+ } | {
19
+ status: 'seeds-ready';
20
+ } | {
21
+ status: 'probe-failed';
22
+ error: string;
23
+ };
24
+ /**
25
+ * DORMANT_MARKER — appended to previously-projected asset files when the asset's
26
+ * gate deactivates (e.g. workspace readiness exceeds the max threshold).
27
+ *
28
+ * Contract:
29
+ * - The file is NOT deleted. It persists on disk so that history is preserved
30
+ * and the agent surface remains inspectable.
31
+ * - The marker is appended at the end of the file, idempotent — if it already
32
+ * exists, no second append occurs.
33
+ * - The marker does NOT trigger a drift TEN. Dormant files are intentionally
34
+ * deactivated, not accidentally forked.
35
+ * - The marker is never included in active file writes — only dormant writes.
36
+ *
37
+ * Used by: writeDormantMarker() (write) and hasDormantMarker() (idempotency check).
38
+ * Exported for use in tests.
39
+ *
40
+ * Chain: WP-379 S4.
41
+ */
42
+ export declare const DORMANT_MARKER = "<!-- pb-status: dormant -->";
43
+ /**
44
+ * writeDormantMarker — append the dormant marker to a previously-projected file.
45
+ *
46
+ * Idempotent: if DORMANT_MARKER is already present, no-op.
47
+ * Only operates on files that have the auto-gen MARKER — we never touch
48
+ * manually-authored files.
49
+ *
50
+ * @param filePath Absolute path to the file.
51
+ * @returns 'written' | 'already-dormant' | 'skipped' (no auto-gen marker)
52
+ */
53
+ export declare function writeDormantMarkerToFile(filePath: string): 'written' | 'already-dormant' | 'skipped';
54
+ /**
55
+ * Single-shot health probe — calls `workspace.health` and inspects
56
+ * `starterSetupSeeded`. Does NOT poll internally; polling is the caller's
57
+ * responsibility (connect-screens.tsx).
58
+ *
59
+ * Returns:
60
+ * - `seeds-ready` — health query succeeded AND starterSetupSeeded is true
61
+ * - `seeds-pending` — health query succeeded but starterSetupSeeded is false
62
+ * - `probe-failed` — health query threw (network, auth, etc.)
63
+ */
64
+ export declare function probeStarterSetupSeeded(): Promise<SeedsPendingResult>;
65
+ /**
66
+ * Poll `probeStarterSetupSeeded` up to MAX_POLLS times (10s at 500ms intervals).
67
+ * Returns the final probe result — caller decides how to render the outcome.
68
+ *
69
+ * Exported so connect-screens.tsx can use it without re-implementing the loop.
70
+ */
71
+ export declare function pollUntilSeedsReady(): Promise<SeedsPendingResult>;
5
72
  export declare function runHandshakeInit(options?: {
6
73
  level?: string;
7
74
  dryRun?: boolean;
@@ -25,6 +92,51 @@ interface HandshakeOptions {
25
92
  * meaningless rewrites when semantic content is unchanged.
26
93
  */
27
94
  export declare function normalizeHandshakeContentForComparison(content: string): string;
95
+ /**
96
+ * Result of a single unlink decision during resolveProjectionCollision.
97
+ */
98
+ type CollisionUnlinkResult = {
99
+ action: 'kept';
100
+ filePath: string;
101
+ reason: string;
102
+ } | {
103
+ action: 'unlinked';
104
+ filePath: string;
105
+ reason: string;
106
+ } | {
107
+ action: 'collision-ten';
108
+ filePath: string;
109
+ reason: string;
110
+ };
111
+ /**
112
+ * resolveProjectionCollision — WP-379 S5b
113
+ *
114
+ * Marker-scoped orphan unlink: enumerates target dirs (.cursor/rules/,
115
+ * .claude/rules/, .claude/skills/, .codex/skills/); for each file that has
116
+ * the auto-gen MARKER whose lowercase-normalized filename does NOT match any
117
+ * current active-asset materializedFilename, the file is unlinked.
118
+ *
119
+ * User-forked files (no MARKER) are never touched, regardless of name.
120
+ *
121
+ * Linux case-collision disambiguation:
122
+ * 1. Exact match (lowercase name == any canonical name): survives.
123
+ * 2. Case-variant with MARKER (marker file, no exact canonical match): unlinked.
124
+ * 3. Ambiguous (zero exact, multiple case-variants with MARKER):
125
+ * newest mtime wins; all others are unlinked; a collision TEN is
126
+ * appended to the session capture queue (not fired inline).
127
+ *
128
+ * Returns a list of unlink results so the caller can log/report them.
129
+ *
130
+ * @param cwd Project root (absolute path).
131
+ * @param assetNames The current set of canonical asset names from the server
132
+ * (e.g. ["Setup-ProductBrain", "chain-rules"]).
133
+ * @param log Progress log function.
134
+ * @param logErr Error log function.
135
+ */
136
+ export declare function resolveProjectionCollision(cwd: string, assetNames: string[], log: (msg: string) => void, logErr: (msg: string) => void): {
137
+ results: CollisionUnlinkResult[];
138
+ collisionTens: string[];
139
+ };
28
140
  export declare function runHandshake(options?: HandshakeOptions): Promise<void>;
29
141
  export {};
30
142
  //# sourceMappingURL=handshake.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"handshake.d.ts","sourceRoot":"","sources":["../../src/commands/handshake.ts"],"names":[],"mappings":"AAAA;;;GAGG;AA4LH,wBAAsB,gBAAgB,CAAC,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA2HxG;AACD,UAAU,gBAAgB;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,qFAAqF;IACrF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,sCAAsC,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAO9E;AAsBD,wBAAsB,YAAY,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwtBhF"}
1
+ {"version":3,"file":"handshake.d.ts","sourceRoot":"","sources":["../../src/commands/handshake.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAuDH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAK5D;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,qBAAqB,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAE9D;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAC1B;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,aAAa,CAAC,SAAS,CAAC,CAAA;CAAE,GAC3D;IAAE,MAAM,EAAE,aAAa,CAAA;CAAE,GACzB;IAAE,MAAM,EAAE,cAAc,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAQ9C;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,gCAAgC,CAAC;AAU5D;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,iBAAiB,GAAG,SAAS,CAWpG;AAuCD;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAyB3E;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAYvE;AAsID,wBAAsB,gBAAgB,CAAC,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA2HxG;AACD,UAAU,gBAAgB;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,qFAAqF;IACrF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,sCAAsC,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAO9E;AAwBD;;GAEG;AACH,KAAK,qBAAqB,GACtB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACxD;IAAE,MAAM,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAElE;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAAE,EACpB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,EAC1B,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GAC5B;IAAE,OAAO,EAAE,qBAAqB,EAAE,CAAC;IAAC,aAAa,EAAE,MAAM,EAAE,CAAA;CAAE,CAkJ/D;AAED,wBAAsB,YAAY,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA41BhF"}
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * pb handshake — generate context files for AI developer tools.
3
- * The fourth delivery surface: context export (GLO-63, DEC-161).
3
+ * Context export wiring (read-only filesystem bridge; GLO-63, DEC-161) — not a product surface.
4
4
  */
5
- import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync } from 'fs';
6
- import { join, dirname, resolve } from 'path';
5
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync, appendFileSync, unlinkSync, statSync } from 'fs';
6
+ import { join, dirname, resolve, basename } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { createHash } from 'crypto';
@@ -26,6 +26,155 @@ import { generateBoundaryManifest, getBoundaryEnforcementMode } from '../generat
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 { normalizeMaterializedFilename } from '../lib/normalizeMaterializedFilename.js';
30
+ const MAX_HANDSHAKE_WAIT_MS = 10_000; // 10 seconds
31
+ const POLL_INTERVAL_MS = 500; // 500 ms per poll
32
+ const MAX_POLLS = MAX_HANDSHAKE_WAIT_MS / POLL_INTERVAL_MS; // 20
33
+ // ── WP-379 S4: Dormant marker ─────────────────────────────────────────────────
34
+ /**
35
+ * DORMANT_MARKER — appended to previously-projected asset files when the asset's
36
+ * gate deactivates (e.g. workspace readiness exceeds the max threshold).
37
+ *
38
+ * Contract:
39
+ * - The file is NOT deleted. It persists on disk so that history is preserved
40
+ * and the agent surface remains inspectable.
41
+ * - The marker is appended at the end of the file, idempotent — if it already
42
+ * exists, no second append occurs.
43
+ * - The marker does NOT trigger a drift TEN. Dormant files are intentionally
44
+ * deactivated, not accidentally forked.
45
+ * - The marker is never included in active file writes — only dormant writes.
46
+ *
47
+ * Used by: writeDormantMarker() (write) and hasDormantMarker() (idempotency check).
48
+ * Exported for use in tests.
49
+ *
50
+ * Chain: WP-379 S4.
51
+ */
52
+ export const DORMANT_MARKER = '<!-- pb-status: dormant -->';
53
+ /**
54
+ * hasDormantMarker — check whether a file on disk already has the dormant marker.
55
+ * Used for idempotency: if the marker is already present, skip the append.
56
+ */
57
+ function hasDormantMarker(content) {
58
+ return content.includes(DORMANT_MARKER);
59
+ }
60
+ /**
61
+ * writeDormantMarker — append the dormant marker to a previously-projected file.
62
+ *
63
+ * Idempotent: if DORMANT_MARKER is already present, no-op.
64
+ * Only operates on files that have the auto-gen MARKER — we never touch
65
+ * manually-authored files.
66
+ *
67
+ * @param filePath Absolute path to the file.
68
+ * @returns 'written' | 'already-dormant' | 'skipped' (no auto-gen marker)
69
+ */
70
+ export function writeDormantMarkerToFile(filePath) {
71
+ if (!existsSync(filePath))
72
+ return 'skipped';
73
+ const content = readFileSync(filePath, 'utf8');
74
+ // Only mark files that were originally projected by pb handshake.
75
+ // Files without the auto-gen MARKER are manually authored — leave them alone.
76
+ if (!content.includes(MARKER))
77
+ return 'skipped';
78
+ if (hasDormantMarker(content))
79
+ return 'already-dormant';
80
+ // Append the marker on its own line. No trailing newline assumption —
81
+ // appendFileSync adds to whatever is already there.
82
+ appendFileSync(filePath, `\n${DORMANT_MARKER}\n`);
83
+ return 'written';
84
+ }
85
+ /**
86
+ * deriveDormantFilePaths — compute the set of on-disk file paths that would have
87
+ * been projected for a given dormant asset (by name and assetKind).
88
+ *
89
+ * Assets are projected to one or more surfaces (cursor/claude/codex) depending
90
+ * on shouldEmitToTarget. Since we don't re-run shouldEmitToTarget here, we
91
+ * speculatively probe all known surface paths and let writeDormantMarkerToFile
92
+ * decide whether each exists and has the auto-gen MARKER.
93
+ *
94
+ * @param asset The dormant asset from the server.
95
+ * @param cwd Current working directory (project root).
96
+ * @returns Array of absolute file paths to probe.
97
+ */
98
+ function deriveDormantFilePaths(asset, cwd) {
99
+ // Defense-in-depth: even though `name` originates from platform-seeded DB
100
+ // entries (not user input), validate it against a strict charset before
101
+ // interpolating into a filesystem path. Reject anything that could traverse
102
+ // out of the expected directories. WP-379 S4 review finding.
103
+ if (!/^[A-Za-z0-9 ._-]+$/.test(asset.name)) {
104
+ return [];
105
+ }
106
+ const paths = [];
107
+ const { name, assetKind } = asset;
108
+ if (assetKind === 'skill') {
109
+ // Cursor skill
110
+ paths.push(join(cwd, '.cursor', 'skills', name, 'SKILL.md'));
111
+ // Codex skill
112
+ paths.push(join(cwd, '.codex', 'skills', `${name}.md`));
113
+ }
114
+ else if (assetKind === 'rule' || assetKind === 'hook') {
115
+ // Cursor rule
116
+ paths.push(join(cwd, '.cursor', 'rules', `${name}.mdc`));
117
+ // Claude rule
118
+ paths.push(join(cwd, '.claude', 'rules', `${name}.md`));
119
+ }
120
+ return paths;
121
+ }
122
+ /**
123
+ * Single-shot health probe — calls `workspace.health` and inspects
124
+ * `starterSetupSeeded`. Does NOT poll internally; polling is the caller's
125
+ * responsibility (connect-screens.tsx).
126
+ *
127
+ * Returns:
128
+ * - `seeds-ready` — health query succeeded AND starterSetupSeeded is true
129
+ * - `seeds-pending` — health query succeeded but starterSetupSeeded is false
130
+ * - `probe-failed` — health query threw (network, auth, etc.)
131
+ */
132
+ export async function probeStarterSetupSeeded() {
133
+ try {
134
+ const health = await kernelCall('workspace.health', {});
135
+ if (health.starterSetupSeeded) {
136
+ return { status: 'seeds-ready' };
137
+ }
138
+ const starterGaps = (health.gaps ?? []).filter((g) => g.kind === 'starter-setup-missing' || g.kind === 'platform-domains-missing');
139
+ return {
140
+ status: 'seeds-pending',
141
+ gaps: starterGaps.length > 0 ? starterGaps : [
142
+ {
143
+ kind: 'starter-setup-missing',
144
+ severity: 'warn',
145
+ message: 'Starter setup seeds are still running.',
146
+ },
147
+ ],
148
+ };
149
+ }
150
+ catch (err) {
151
+ return {
152
+ status: 'probe-failed',
153
+ error: err instanceof Error ? err.message : String(err),
154
+ };
155
+ }
156
+ }
157
+ /**
158
+ * Poll `probeStarterSetupSeeded` up to MAX_POLLS times (10s at 500ms intervals).
159
+ * Returns the final probe result — caller decides how to render the outcome.
160
+ *
161
+ * Exported so connect-screens.tsx can use it without re-implementing the loop.
162
+ */
163
+ export async function pollUntilSeedsReady() {
164
+ for (let poll = 0; poll < MAX_POLLS; poll++) {
165
+ const result = await probeStarterSetupSeeded();
166
+ if (result.status === 'seeds-ready')
167
+ return result;
168
+ if (result.status === 'probe-failed')
169
+ return result; // don't retry on auth/network errors
170
+ // seeds-pending — wait before next poll
171
+ if (poll < MAX_POLLS - 1) {
172
+ await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
173
+ }
174
+ }
175
+ // Final probe after exhausting waits — return whatever state we have
176
+ return probeStarterSetupSeeded();
177
+ }
29
178
  const LEVELS = {
30
179
  guide: {
31
180
  label: 'Guide me',
@@ -282,6 +431,164 @@ function deduplicateEntries(entries) {
282
431
  }
283
432
  return result;
284
433
  }
434
+ /**
435
+ * resolveProjectionCollision — WP-379 S5b
436
+ *
437
+ * Marker-scoped orphan unlink: enumerates target dirs (.cursor/rules/,
438
+ * .claude/rules/, .claude/skills/, .codex/skills/); for each file that has
439
+ * the auto-gen MARKER whose lowercase-normalized filename does NOT match any
440
+ * current active-asset materializedFilename, the file is unlinked.
441
+ *
442
+ * User-forked files (no MARKER) are never touched, regardless of name.
443
+ *
444
+ * Linux case-collision disambiguation:
445
+ * 1. Exact match (lowercase name == any canonical name): survives.
446
+ * 2. Case-variant with MARKER (marker file, no exact canonical match): unlinked.
447
+ * 3. Ambiguous (zero exact, multiple case-variants with MARKER):
448
+ * newest mtime wins; all others are unlinked; a collision TEN is
449
+ * appended to the session capture queue (not fired inline).
450
+ *
451
+ * Returns a list of unlink results so the caller can log/report them.
452
+ *
453
+ * @param cwd Project root (absolute path).
454
+ * @param assetNames The current set of canonical asset names from the server
455
+ * (e.g. ["Setup-ProductBrain", "chain-rules"]).
456
+ * @param log Progress log function.
457
+ * @param logErr Error log function.
458
+ */
459
+ export function resolveProjectionCollision(cwd, assetNames, log, logErr) {
460
+ // Target directories by extension suffix.
461
+ const TARGET_DIRS_BY_EXT = [
462
+ { dir: join(cwd, '.cursor', 'rules'), ext: '.mdc' },
463
+ { dir: join(cwd, '.claude', 'rules'), ext: '.md' },
464
+ { dir: join(cwd, '.claude', 'skills'), ext: '.md' },
465
+ { dir: join(cwd, '.codex', 'skills'), ext: '.md' },
466
+ ];
467
+ // Build a set of normalized canonical names (without extension) for fast lookup.
468
+ // We normalize all asset names to detect case-variant collisions.
469
+ // For each asset name we derive the normalized basename (the part before the ext).
470
+ // canonicalNormalizedNames: Set<normalized-stem> (lowercase + slug).
471
+ const canonicalNormalizedStems = new Set(assetNames.map((n) => normalizeMaterializedFilename(n)));
472
+ const results = [];
473
+ const collisionTens = [];
474
+ for (const { dir, ext } of TARGET_DIRS_BY_EXT) {
475
+ if (!existsSync(dir))
476
+ continue;
477
+ let files;
478
+ try {
479
+ files = readdirSync(dir);
480
+ }
481
+ catch {
482
+ continue; // unreadable dir — skip
483
+ }
484
+ // Group files by their normalized stem.
485
+ // normalizedStem → [ { filename, fullPath } ]
486
+ const groups = new Map();
487
+ for (const filename of files) {
488
+ if (!filename.endsWith(ext))
489
+ continue;
490
+ const fullPath = join(dir, filename);
491
+ // Only operate on files that have the auto-gen MARKER.
492
+ let content;
493
+ try {
494
+ content = readFileSync(fullPath, 'utf8');
495
+ }
496
+ catch {
497
+ continue; // unreadable file — skip
498
+ }
499
+ if (!content.includes(MARKER))
500
+ continue; // user-forked — never touch
501
+ const stem = basename(filename, ext);
502
+ const normalizedStem = normalizeMaterializedFilename(stem);
503
+ const group = groups.get(normalizedStem) ?? [];
504
+ group.push({ filename, fullPath });
505
+ groups.set(normalizedStem, group);
506
+ }
507
+ // Evaluate each normalized stem group.
508
+ for (const [normalizedStem, members] of groups) {
509
+ const isKnownCanonical = canonicalNormalizedStems.has(normalizedStem);
510
+ if (!isKnownCanonical) {
511
+ // All members of this group are orphans (no canonical asset with this stem).
512
+ // Unlink them all — they're stale projections of an asset no longer in the server.
513
+ for (const { filename, fullPath } of members) {
514
+ try {
515
+ unlinkSync(fullPath);
516
+ log(`Orphan unlinked: ${fullPath}`);
517
+ results.push({ action: 'unlinked', filePath: fullPath, reason: 'orphan-no-canonical-match' });
518
+ }
519
+ catch (err) {
520
+ logErr(`Warning: could not unlink orphan ${fullPath} — ${err instanceof Error ? err.message : String(err)}`);
521
+ results.push({ action: 'kept', filePath: fullPath, reason: 'unlink-failed' });
522
+ }
523
+ }
524
+ continue;
525
+ }
526
+ // The stem IS known canonical. Check for case-collision.
527
+ if (members.length === 1) {
528
+ // Single file — no collision.
529
+ results.push({ action: 'kept', filePath: members[0].fullPath, reason: 'canonical-exact' });
530
+ continue;
531
+ }
532
+ // Multiple files with the same normalized stem → case-collision.
533
+ // Rule: exact match (filename stem === normalized stem, i.e. already lowercase) wins.
534
+ const exactMatches = members.filter(({ filename }) => {
535
+ const stem = basename(filename, ext);
536
+ return stem === normalizedStem; // lowercase-equal means already normalized
537
+ });
538
+ if (exactMatches.length === 1) {
539
+ // Rule 1: exactly one exact match → keep it, unlink all case-variants.
540
+ const keeper = exactMatches[0];
541
+ results.push({ action: 'kept', filePath: keeper.fullPath, reason: 'case-exact-match-wins' });
542
+ for (const member of members) {
543
+ if (member.fullPath === keeper.fullPath)
544
+ continue;
545
+ try {
546
+ unlinkSync(member.fullPath);
547
+ log(`Case-variant unlinked: ${member.fullPath} (kept: ${keeper.filename})`);
548
+ results.push({ action: 'unlinked', filePath: member.fullPath, reason: 'case-variant-unlinked' });
549
+ }
550
+ catch (err) {
551
+ logErr(`Warning: could not unlink case-variant ${member.fullPath} — ${err instanceof Error ? err.message : String(err)}`);
552
+ results.push({ action: 'kept', filePath: member.fullPath, reason: 'unlink-failed' });
553
+ }
554
+ }
555
+ continue;
556
+ }
557
+ // Rule 3: ambiguous — zero exact matches (or multiple exact matches, which
558
+ // can't happen on a case-sensitive FS). Newest mtime wins.
559
+ // Sort by mtime descending: highest mtime = newest = winner.
560
+ const withStats = members.map(({ filename, fullPath }) => {
561
+ try {
562
+ const { mtimeMs } = statSync(fullPath);
563
+ return { filename, fullPath, mtimeMs };
564
+ }
565
+ catch {
566
+ return { filename, fullPath, mtimeMs: 0 };
567
+ }
568
+ });
569
+ withStats.sort((a, b) => b.mtimeMs - a.mtimeMs);
570
+ const winner = withStats[0];
571
+ log(`Case-collision ambiguous for ${normalizedStem}${ext}: newest mtime wins (${winner.filename})`);
572
+ results.push({ action: 'collision-ten', filePath: winner.fullPath, reason: 'ambiguous-newest-mtime-wins' });
573
+ const tenMsg = `Handshake case-collision: ambiguous filename for stem "${normalizedStem}${ext}" ` +
574
+ `(${members.map((m) => m.filename).join(', ')}). ` +
575
+ `Kept newest: ${winner.filename}. Consider renaming to ${normalizedStem}${ext}.`;
576
+ collisionTens.push(tenMsg);
577
+ for (const member of withStats.slice(1)) {
578
+ try {
579
+ unlinkSync(member.fullPath);
580
+ log(`Case-variant (ambiguous) unlinked: ${member.fullPath}`);
581
+ results.push({ action: 'unlinked', filePath: member.fullPath, reason: 'ambiguous-case-variant-unlinked' });
582
+ }
583
+ catch (err) {
584
+ logErr(`Warning: could not unlink ambiguous case-variant ${member.fullPath} — ${err instanceof Error ? err.message : String(err)}`);
585
+ results.push({ action: 'kept', filePath: member.fullPath, reason: 'unlink-failed' });
586
+ }
587
+ }
588
+ }
589
+ }
590
+ return { results, collisionTens };
591
+ }
285
592
  export async function runHandshake(options = {}) {
286
593
  const config = await getConfigOrGuide(() => runHandshake(options));
287
594
  if (!config)
@@ -369,10 +676,34 @@ export async function runHandshake(options = {}) {
369
676
  let dbRules = [];
370
677
  let usedDbSource = false;
371
678
  let dbAssetRows = [];
679
+ // WP-379 S4: dormant assets (gate-failed) — their on-disk files get the dormant marker.
680
+ let dormantDbAssetRows = [];
681
+ // WP-379 S5b: whether any setup_receipt exists for this workspace (first-run UX gate).
682
+ // undefined when server is pre-S5b (treat as unknown → suppress drift TENs conservatively).
683
+ let hasAnyReceipt = undefined;
372
684
  const dbProjectionHashes = new Map();
373
685
  try {
374
- const dbAssets = await kernelCall('setup.listAssetsForUser', {}).catch(() => null);
375
- if (dbAssets && dbAssets.length > 0) {
686
+ // WP-379 S4: listAssetsForUser now returns { activeAssets, dormantAssets }.
687
+ // Wire format changed from DbAsset[] to { activeAssets: DbAsset[], dormantAssets: DbAsset[] }.
688
+ // Fall back to empty arrays if the server returns the old flat-array shape (graceful degradation).
689
+ const rawResponse = await kernelCall('setup.listAssetsForUser', {}).catch(() => null);
690
+ let dbAssets = [];
691
+ if (rawResponse !== null) {
692
+ if (Array.isArray(rawResponse)) {
693
+ // Pre-S4 server — treat entire response as active assets with no dormant list.
694
+ dbAssets = rawResponse;
695
+ dormantDbAssetRows = [];
696
+ hasAnyReceipt = undefined; // unknown on legacy servers
697
+ }
698
+ else {
699
+ dbAssets = rawResponse.activeAssets ?? [];
700
+ dormantDbAssetRows = rawResponse.dormantAssets ?? [];
701
+ // WP-379 S5b: extract hasAnyReceipt when provided by the server.
702
+ // undefined means pre-S5b server — we treat as unknown (no receipts assumed).
703
+ hasAnyReceipt = rawResponse.hasAnyReceipt;
704
+ }
705
+ }
706
+ if (dbAssets.length > 0) {
376
707
  dbAssetRows = dbAssets;
377
708
  // Map DB assets to CanonicalSkill/CanonicalRule shapes
378
709
  for (const asset of dbAssets) {
@@ -399,6 +730,9 @@ export async function runHandshake(options = {}) {
399
730
  }
400
731
  usedDbSource = true;
401
732
  log(`Setup assets: ${dbSkills.length} skills, ${dbRules.length} rules/hooks from DB (WP-345 DB-first path)`);
733
+ if (dormantDbAssetRows.length > 0) {
734
+ log(`Setup assets: ${dormantDbAssetRows.length} dormant (gate-filtered) asset(s) will be marked on disk`);
735
+ }
402
736
  }
403
737
  }
404
738
  catch {
@@ -788,6 +1122,20 @@ export async function runHandshake(options = {}) {
788
1122
  target: 'claude',
789
1123
  });
790
1124
  }
1125
+ // 7a. WP-379 S5b: Resolve projection collisions before writing.
1126
+ // In apply mode, enumerate target dirs and unlink any auto-generated files
1127
+ // whose normalized name no longer matches any active asset from the server.
1128
+ // This prevents case-variant orphans from accumulating across handshakes.
1129
+ // Runs only when we have a DB asset list (usedDbSource) — without a DB source,
1130
+ // we can't determine which files are canonical vs. orphan.
1131
+ const collisionTensToFire = [];
1132
+ if (applyMode && usedDbSource) {
1133
+ const activeAssetNames = dbAssetRows
1134
+ .filter((a) => !a.disabledByOwner)
1135
+ .map((a) => a.name);
1136
+ const { collisionTens } = resolveProjectionCollision(cwd, activeAssetNames, log, logErr);
1137
+ collisionTensToFire.push(...collisionTens);
1138
+ }
791
1139
  const forkedPaths = [];
792
1140
  const projectedHashUpdates = new Map();
793
1141
  const recordProjectedHash = (entryId) => {
@@ -868,21 +1216,93 @@ export async function runHandshake(options = {}) {
868
1216
  }
869
1217
  });
870
1218
  }
871
- // 8. Drift loggingif apply mode encountered forked adapters and a session is active, log a draft TEN
1219
+ // 8a. Dormant marker writes (WP-379 S4) — apply mode only.
1220
+ // For each dormant asset (gate-filtered by the server), locate any previously-projected
1221
+ // on-disk files and append the DORMANT_MARKER trailer. Files are NOT deleted.
1222
+ //
1223
+ // Drift TEN exclusion: dormant-marked files have the auto-gen MARKER, so
1224
+ // shouldWriteAdapter() returns true for them. However, because the asset is dormant,
1225
+ // it is NOT in the `writes` array — it was never queued for a fresh write. Therefore,
1226
+ // dormant files will never appear in forkedPaths (forkedPaths only catches files that
1227
+ // ARE in the writes array but fail shouldWriteAdapter). The dormant marker write is a
1228
+ // separate, independent pass that runs BEFORE the drift TEN check — intentionally
1229
+ // after the main write loop to avoid interfering with active asset writes.
1230
+ //
1231
+ // Fail-open: if a dormant marker write fails, log and continue. Never crash the handshake.
1232
+ const dormantMarkedPaths = [];
1233
+ if (applyMode && dormantDbAssetRows.length > 0) {
1234
+ for (const dormantAsset of dormantDbAssetRows) {
1235
+ const candidatePaths = deriveDormantFilePaths(dormantAsset, cwd);
1236
+ for (const filePath of candidatePaths) {
1237
+ try {
1238
+ const markerResult = writeDormantMarkerToFile(filePath);
1239
+ if (markerResult === 'written') {
1240
+ dormantMarkedPaths.push(filePath);
1241
+ log(`Dormant marker written: ${filePath}`);
1242
+ }
1243
+ // 'already-dormant' and 'skipped' are silent no-ops — idempotent.
1244
+ }
1245
+ catch (err) {
1246
+ // Fail-open: dormant marker write is advisory. Log, never throw.
1247
+ logErr(`Warning: could not write dormant marker to ${filePath} — ${err instanceof Error ? err.message : String(err)}`);
1248
+ }
1249
+ }
1250
+ }
1251
+ }
1252
+ // 8. Drift logging — if apply mode encountered forked adapters and a session is active, log a draft TEN.
1253
+ // Dormant-marked files are NOT forked — they were intentionally deactivated and have the auto-gen MARKER.
1254
+ // They will never appear in forkedPaths because they are excluded from the `writes` array entirely.
1255
+ //
1256
+ // WP-379 S5b — First-run UX rule:
1257
+ // When `hasAnyReceipt` is false OR undefined (unknown / pre-S5b server), drift TENs are suppressed.
1258
+ // Rationale: on the first handshake, users may have pre-existing non-marked files from manual
1259
+ // setup; we must not flood them with TENs before they've even had one successful materialization.
1260
+ // A TEN fires only after setup_receipt.count >= 1 for this workspace.
1261
+ //
1262
+ // Conservative unknown-treatment: if the server does not provide hasAnyReceipt (old server),
1263
+ // we treat it as "no receipt exists" and suppress the TEN. The day-1 experience is more important
1264
+ // than catching every early drift case; the TEN will fire on the next run once the server is updated.
1265
+ const isFirstRun = hasAnyReceipt !== true; // true when no receipts or unknown
872
1266
  if (forkedPaths.length > 0) {
1267
+ if (isFirstRun) {
1268
+ log(`Info: ${forkedPaths.length} adapter(s) skipped (user files without auto-gen marker). ` +
1269
+ 'Drift TEN suppressed — first run (no setup receipt yet). Files: ' +
1270
+ forkedPaths.join(', '));
1271
+ }
1272
+ else {
1273
+ const session = readSession();
1274
+ if (session) {
1275
+ const names = forkedPaths.join(', ');
1276
+ kernelCallWithSession('chain.createEntry', {
1277
+ collectionSlug: 'tensions',
1278
+ name: `TEN: handshake drift — ${forkedPaths.length} adapter(s) forked, sync blocked`,
1279
+ status: 'draft',
1280
+ data: {
1281
+ description: `pb handshake --apply encountered forked adapters that blocked sync. Files: ${names}. Use --force to overwrite or resolve drift manually.`,
1282
+ },
1283
+ sessionId: session.sessionId,
1284
+ createdBy: `agent:${session.sessionId}`,
1285
+ }).catch(() => { });
1286
+ }
1287
+ }
1288
+ }
1289
+ // 8. Case-collision TENs (WP-379 S5b) — fire after the first-run gate.
1290
+ // These are distinct from drift TENs: they record ambiguous filename collisions
1291
+ // where the "newest mtime wins" heuristic was applied. They fire regardless of
1292
+ // first-run status (collision is a data quality issue, not a drift issue).
1293
+ if (collisionTensToFire.length > 0) {
873
1294
  const session = readSession();
874
1295
  if (session) {
875
- const names = forkedPaths.join(', ');
876
- kernelCallWithSession('chain.createEntry', {
877
- collectionSlug: 'tensions',
878
- name: `TEN: handshake drift${forkedPaths.length} adapter(s) forked, sync blocked`,
879
- status: 'draft',
880
- data: {
881
- description: `pb handshake --apply encountered forked adapters that blocked sync. Files: ${names}. Use --force to overwrite or resolve drift manually.`,
882
- },
883
- sessionId: session.sessionId,
884
- createdBy: `agent:${session.sessionId}`,
885
- }).catch(() => { });
1296
+ for (const tenDescription of collisionTensToFire) {
1297
+ kernelCallWithSession('chain.createEntry', {
1298
+ collectionSlug: 'tensions',
1299
+ name: `TEN: handshake case-collisionambiguous filename resolved by mtime`,
1300
+ status: 'draft',
1301
+ data: { description: tenDescription },
1302
+ sessionId: session.sessionId,
1303
+ createdBy: `agent:${session.sessionId}`,
1304
+ }).catch(() => { });
1305
+ }
886
1306
  }
887
1307
  }
888
1308
  // 8b. Setup receipt — record which assets were materialized (apply mode only)