@productbrain/cli 0.1.0-beta.5984 → 0.1.0-beta.5993

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.
@@ -2,7 +2,7 @@
2
2
  * pb handshake — generate context files for AI developer tools.
3
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, appendFileSync, unlinkSync, statSync, renameSync, rmSync, realpathSync } from 'fs';
5
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync, unlinkSync, statSync, realpathSync } from 'fs';
6
6
  import { join, dirname, resolve, basename, relative, sep, isAbsolute } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { fileURLToPath } from 'url';
@@ -42,157 +42,6 @@ import { getReverseMapFallbackMessage, reportReverseMapMissing } from '../surfac
42
42
  const MAX_HANDSHAKE_WAIT_MS = 10_000; // 10 seconds
43
43
  const POLL_INTERVAL_MS = 500; // 500 ms per poll
44
44
  const MAX_POLLS = MAX_HANDSHAKE_WAIT_MS / POLL_INTERVAL_MS; // 20
45
- // ── WP-379 S4: Dormant marker ─────────────────────────────────────────────────
46
- /**
47
- * DORMANT_MARKER — appended to previously-projected asset files when the asset's
48
- * gate deactivates (e.g. workspace readiness exceeds the max threshold).
49
- *
50
- * Contract:
51
- * - The file is NOT deleted. It persists on disk so that history is preserved
52
- * and the agent surface remains inspectable.
53
- * - The marker is appended at the end of the file, idempotent — if it already
54
- * exists, no second append occurs.
55
- * - The marker does NOT trigger a drift TEN. Dormant files are intentionally
56
- * deactivated, not accidentally forked.
57
- * - The marker is never included in active file writes — only dormant writes.
58
- *
59
- * Used by: writeDormantMarker() (write) and hasDormantMarker() (idempotency check).
60
- * Exported for use in tests.
61
- *
62
- * Chain: WP-379 S4.
63
- */
64
- export const DORMANT_MARKER = '<!-- pb-status: dormant -->';
65
- /**
66
- * hasDormantMarker — check whether a file on disk already has the dormant marker.
67
- * Used for idempotency: if the marker is already present, skip the append.
68
- */
69
- function hasDormantMarker(content) {
70
- return content.includes(DORMANT_MARKER);
71
- }
72
- /**
73
- * writeDormantMarker — append the dormant marker to a previously-projected file.
74
- *
75
- * Idempotent: if DORMANT_MARKER is already present, no-op.
76
- * Only operates on files that have the auto-gen MARKER — we never touch
77
- * manually-authored files.
78
- *
79
- * @param filePath Absolute path to the file.
80
- * @returns 'written' | 'already-dormant' | 'skipped' (no auto-gen marker)
81
- */
82
- export function writeDormantMarkerToFile(filePath) {
83
- if (!existsSync(filePath))
84
- return 'skipped';
85
- const content = readFileSync(filePath, 'utf8');
86
- // Only mark files that were originally projected by pb handshake.
87
- // Files without the auto-gen MARKER are manually authored — leave them alone.
88
- if (!content.includes(MARKER))
89
- return 'skipped';
90
- if (hasDormantMarker(content))
91
- return 'already-dormant';
92
- // Append the marker on its own line. No trailing newline assumption —
93
- // appendFileSync adds to whatever is already there.
94
- appendFileSync(filePath, `\n${DORMANT_MARKER}\n`);
95
- return 'written';
96
- }
97
- /**
98
- * renameSurfaceForDormancy — rename a projected surface file to `<path>.dormant`
99
- * so external scanners (which glob *.md / *.mdc) stop loading it. WP-426 E4.
100
- * The HTML-comment marker is invisible to scanners; the extension change removes
101
- * the file from their glob set. Only touches files carrying the auto-gen MARKER.
102
- * Runs on an INDEPENDENT existence check (not gated on marker-write result) so
103
- * legacy WP-379 marker-only files still rename. Idempotent.
104
- * Codex P1: when a .dormant already exists, only replace it if its body matches the
105
- * live file. A divergent .dormant (user edited it) is preserved and reported as 'drift'
106
- * rather than force-deleted — no silent data loss.
107
- * @returns 'renamed' | 'replaced' | 'already-dormant' | 'skipped' | 'drift'
108
- */
109
- export function renameSurfaceForDormancy(filePath) {
110
- const dormantPath = `${filePath}.dormant`;
111
- if (!existsSync(filePath))
112
- return existsSync(dormantPath) ? 'already-dormant' : 'skipped';
113
- if (!readFileSync(filePath, 'utf8').includes(MARKER))
114
- return 'skipped';
115
- const replacing = existsSync(dormantPath);
116
- if (replacing) {
117
- // Codex P1: the existing .dormant may carry user edits (e.g. the user reactivated to
118
- // the live path but kept an edited dormant sibling). Compare normalized bodies
119
- // (DORMANT_MARKER + volatile auto-gen timestamp stripped from both); if they diverge,
120
- // preserve the .dormant and signal drift instead of force-replacing it.
121
- const strip = (s) => normalizeHandshakeContentForComparison(s.split(DORMANT_MARKER).join('')).trimEnd();
122
- if (strip(readFileSync(filePath, 'utf8')) !== strip(readFileSync(dormantPath, 'utf8'))) {
123
- return 'drift';
124
- }
125
- rmSync(dormantPath, { force: true });
126
- }
127
- renameSync(filePath, dormantPath);
128
- return replacing ? 'replaced' : 'renamed';
129
- }
130
- /**
131
- * restoreSurfaceFromDormant — clean the orphan `<path>.dormant` sibling after a
132
- * raised (observe→project) asset has been re-projected fresh from the DB body by
133
- * the normal write loop. WP-426 E4.
134
- * Normalizes the volatile auto-gen timestamp (via normalizeHandshakeContentForComparison)
135
- * before comparing, else the lowering-time timestamp would always force a false
136
- * 'orphan-drift'. Identical body → remove. Differs (user edited) → preserve + drift.
137
- * @returns 'restored' | 'orphan-drift' | 'skipped'
138
- */
139
- export function restoreSurfaceFromDormant(filePath) {
140
- const dormantPath = `${filePath}.dormant`;
141
- if (!existsSync(dormantPath))
142
- return 'skipped';
143
- // WP-426 E4: no fresh live projection this run (surface filtered / user-owned / not written) →
144
- // nothing to reconcile; absence of fresh is NOT evidence of a user edit. Leave the .dormant.
145
- if (!existsSync(filePath))
146
- return 'skipped';
147
- const fresh = readFileSync(filePath, 'utf8');
148
- // Codex P2: only reconcile against a PB-managed projection. A live file WITHOUT the auto-gen
149
- // MARKER is a user-owned file (shouldWriteAdapter would have skipped reprojecting it),
150
- // so it was NOT raised this run — comparing the .dormant against it would wrongly delete it or
151
- // re-fire "edited while dormant" drift. Leave the .dormant untouched.
152
- if (!fresh.includes(MARKER))
153
- return 'skipped';
154
- const dormant = readFileSync(dormantPath, 'utf8').split(DORMANT_MARKER).join('');
155
- const norm = (s) => normalizeHandshakeContentForComparison(s).trimEnd();
156
- if (norm(dormant) === norm(fresh)) {
157
- rmSync(dormantPath, { force: true });
158
- return 'restored';
159
- }
160
- return 'orphan-drift';
161
- }
162
- /**
163
- * deriveDormantFilePaths — compute the set of on-disk file paths that would have
164
- * been projected for a given dormant asset (by name and assetKind).
165
- *
166
- * Assets are projected to one or more surfaces (cursor/claude/codex) depending
167
- * on shouldEmitToTarget. Since we don't re-run shouldEmitToTarget here, we
168
- * speculatively probe all known surface paths and let writeDormantMarkerToFile
169
- * decide whether each exists and has the auto-gen MARKER.
170
- *
171
- * @param asset The dormant asset from the server.
172
- * @param cwd Current working directory (project root).
173
- * @returns Each candidate path paired with its owning surface, so callers can
174
- * filter by the run's allowedTargets (--surfaces) and the perimeter.
175
- */
176
- function deriveDormantFilePaths(asset, cwd) {
177
- // Defense-in-depth: even though `name` originates from platform-seeded DB
178
- // entries (not user input), validate it against a strict charset before
179
- // interpolating into a filesystem path. Reject anything that could traverse
180
- // out of the expected directories. WP-379 S4 review finding.
181
- if (!/^[A-Za-z0-9 ._-]+$/.test(asset.name)) {
182
- return [];
183
- }
184
- const out = [];
185
- const { name, assetKind } = asset;
186
- if (assetKind === 'skill') {
187
- out.push({ path: join(cwd, '.cursor', 'skills', name, 'SKILL.md'), surface: 'cursor' });
188
- out.push({ path: join(cwd, '.codex', 'skills', `${name}.md`), surface: 'codex' });
189
- }
190
- else if (assetKind === 'rule' || assetKind === 'hook') {
191
- out.push({ path: join(cwd, '.cursor', 'rules', `${name}.mdc`), surface: 'cursor' });
192
- out.push({ path: join(cwd, '.claude', 'rules', `${name}.md`), surface: 'claude' });
193
- }
194
- return out;
195
- }
196
45
  /**
197
46
  * Single-shot health probe — calls `workspace.health` and inspects
198
47
  * `starterSetupSeeded`. Does NOT poll internally; polling is the caller's
@@ -614,12 +463,10 @@ export function deriveHandshakeStartupSignals(cwd, callerSignals = {}, deps = DE
614
463
  const { runGit, prDiff } = deps;
615
464
  const branchName = runGit(cwd, 'git branch --show-current');
616
465
  const commitSha = runGit(cwd, 'git rev-parse --short HEAD');
617
- // Changed paths come from THREE sources, unioned + deduped:
618
- // 1. Dirty worktree (`git diff --name-only HEAD`) — existing behavior.
619
- // 2. Committed branch delta vs the merge-base with origin/main (Codex@665):
620
- // after the branch is committed and the worktree is clean, source #1 is
621
- // empty and the branch delta would be invisible without this.
622
- // 3. PR-ref dereference (F4) — appended later, once refs are collected.
466
+ // Changed paths come from THREE sources, unioned + deduped: (1) dirty worktree (`git diff
467
+ // --name-only HEAD`); (2) committed branch delta vs the merge-base with origin/main
468
+ // (Codex@665) — once the worktree is clean, source #1 is empty and the branch delta would
469
+ // be invisible without this; (3) PR-ref dereference (F4), appended later once refs collect.
623
470
  const dirtyChangedPaths = runGit(cwd, 'git diff --name-only HEAD')?.split(/\r?\n/).filter(Boolean) ?? [];
624
471
  const mergeBase = runGit(cwd, 'git merge-base HEAD origin/main');
625
472
  const branchChangedPaths = mergeBase
@@ -690,13 +537,10 @@ export function deriveHandshakeStartupSignals(cwd, callerSignals = {}, deps = DE
690
537
  Boolean(sanitized.explicitScope);
691
538
  if (hasMeaningfulEvidence)
692
539
  return sanitized;
693
- // Codex@704: even with no task/path/ref/scope evidence, still emit a minimal
694
- // envelope carrying the attribution fields (resolverVersion, commitSha,
695
- // branchName). Returning {} here would drop the whole startup block at the
696
- // caller, losing attribution AND the loud domain-unresolved signal exactly in
697
- // the low-evidence case the resolver is meant to make queryable
698
- // (spec "Loud Unresolved State"; AC #4). The envelope has no evidence fields,
699
- // so the resolver sees no domain and emits domain-unresolved.
540
+ // Codex@704: even with no task/path/ref/scope evidence, still emit a minimal envelope
541
+ // carrying attribution (resolverVersion, commitSha, branchName) — returning {} would drop
542
+ // the whole startup block, losing attribution AND the loud domain-unresolved signal in the
543
+ // low-evidence case the resolver is meant to make queryable (spec "Loud Unresolved State", AC #4).
700
544
  return sanitizeStartupResolutionSignals({
701
545
  sourceSurface: sanitized.sourceSurface ?? 'cli',
702
546
  resolverVersion: sanitized.resolverVersion ?? STARTUP_RESOLUTION_RESOLVER_VERSION,
@@ -722,12 +566,9 @@ function shouldWriteAdapter(filePath, force) {
722
566
  return true;
723
567
  const content = readFileSync(filePath, 'utf8');
724
568
  // A file WITHOUT the auto-gen MARKER is user-owned / committed-static — the hand-maintained
725
- // constitution (CLAUDE.md / AGENTS.md) and the committed Copilot instructions
726
- // (.github/copilot-instructions.md + .github/instructions/*.instructions.md) all live here.
727
- // PB NEVER overwrites these — not even under --force. (--force only re-forces PB-managed files
728
- // and bypasses the tampered-defer downstream; it must never clobber committed static content.
729
- // Returning false here is what stops `pb handshake --force` from clobbering them; force is
730
- // therefore not consulted for the user-owned class by design.)
569
+ // constitution (CLAUDE.md / AGENTS.md) and the committed Copilot instructions all live here.
570
+ // PB NEVER overwrites these, not even under --force (force only re-forces PB-managed files and
571
+ // bypasses the tampered-defer downstream) — force is therefore not consulted here by design.
731
572
  void force;
732
573
  return content.includes(MARKER);
733
574
  }
@@ -836,14 +677,11 @@ function setupAuthoringPath(cwd, asset) {
836
677
  // WP-426 E3: recorded authoring source path wins so reprojection lands where the
837
678
  // user authored — no duplicate (TEN-1920). Stored relative to .productbrain/.
838
679
  if (asset.authoringPath && asset.authoringPath.trim()) {
839
- // Codex P1/P2: authoringPath comes from the DB and is untrusted at projection
840
- // time. It is probed via existsSync/readFileSync (and later writeFileSync) in the
841
- // writeback loop BEFORE any assertSetupWritePath guard runs. Containment alone is
842
- // not enough — a bad DB row could still point at a directory (e.g. "skills") or an
843
- // internal PB file (".authoring-sync.json", "manifest.yaml"), which the loop would
844
- // mis-handle as markdown (throw on a directory read, or overwrite PB state).
845
- // Constrain to a kind-appropriate markdown file (<dir>/**/*.md); otherwise fall
846
- // back to the safe name-derived path.
680
+ // Codex P1/P2: authoringPath is untrusted DB data, probed via existsSync/readFileSync
681
+ // (and later writeFileSync) BEFORE any assertSetupWritePath guard runs. Containment
682
+ // alone isn't enough — a bad row could point at a directory or an internal PB file
683
+ // ("skills", ".authoring-sync.json", "manifest.yaml"); constrain to a kind-appropriate
684
+ // markdown file (<dir>/**/*.md), else fall back to the safe name-derived path.
847
685
  const pbDir = join(cwd, '.productbrain');
848
686
  const candidate = resolve(pbDir, asset.authoringPath);
849
687
  const within = relative(pbDir, candidate);
@@ -886,16 +724,9 @@ function loadAuthoringSyncState(productbrainDir) {
886
724
  if (parsed.version !== 1 || !parsed.assets || typeof parsed.assets !== 'object') {
887
725
  return { version: 1, assets: {} };
888
726
  }
889
- // Codex P2: the dormant registries are consumed via .includes() in the dormant loop
890
- // BEFORE its per-file try/catch, so a malformed .authoring-sync.json (a field set to
891
- // an object/number instead of an array) would throw a TypeError and abort the whole
892
- // handshake. Coerce to a string[] (mirrors the assets validation above) to fail open.
893
- const toStringArray = (v) => Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
894
727
  return {
895
728
  version: 1,
896
729
  assets: parsed.assets,
897
- dormantRenamed: toStringArray(parsed.dormantRenamed),
898
- dormantReactivated: toStringArray(parsed.dormantReactivated),
899
730
  };
900
731
  }
901
732
  catch {
@@ -967,27 +798,23 @@ function deduplicateEntries(entries) {
967
798
  /**
968
799
  * resolveProjectionCollision — WP-379 S5b
969
800
  *
970
- * Marker-scoped orphan unlink: enumerates target dirs (.cursor/rules/,
971
- * .claude/rules/, .claude/skills/, .codex/skills/); for each file that has
972
- * the auto-gen MARKER whose lowercase-normalized filename does NOT match any
973
- * current active-asset materializedFilename, the file is unlinked.
974
- *
975
- * User-owned files (no MARKER) are never touched, regardless of name.
976
- *
977
- * Linux case-collision disambiguation:
978
- * 1. Exact match (lowercase name == any canonical name): survives.
979
- * 2. Case-variant with MARKER (marker file, no exact canonical match): unlinked.
980
- * 3. Ambiguous (zero exact, multiple case-variants with MARKER):
981
- * newest mtime wins; all others are unlinked; a collision TEN is
982
- * appended to the session capture queue (not fired inline).
801
+ * Marker-scoped orphan unlink over .cursor/rules/, .claude/rules/, .claude/skills/,
802
+ * .codex/skills/: any marker-bearing file whose lowercase-normalized name matches no
803
+ * current active-asset materializedFilename is unlinked. User-owned files (no MARKER)
804
+ * are never touched, regardless of name.
983
805
  *
984
- * Returns a list of unlink results so the caller can log/report them.
806
+ * Case-collision disambiguation: (1) exact match (lowercase name == canonical) survives;
807
+ * (2) case-variant with MARKER, no exact match, unlinked; (3) ambiguous (zero exact,
808
+ * multiple case-variant) — newest mtime wins, others unlinked, a collision TEN queued
809
+ * (not fired inline); (4) a live file plus a leftover `.dormant` sibling is reconciled by
810
+ * CONTENT, not mtime (PR #911 review, Codex P1) — a matching sibling is unlinked, a
811
+ * diverged one is preserved and reported as drift.
985
812
  *
986
813
  * @param cwd Project root (absolute path).
987
- * @param assetNames The current set of canonical asset names from the server
988
- * (e.g. ["Setup-ProductBrain", "chain-rules"]).
814
+ * @param assetNames Canonical asset names from the server (e.g. ["Setup-ProductBrain"]).
989
815
  * @param log Progress log function.
990
816
  * @param logErr Error log function.
817
+ * @returns Unlink results, for the caller to log/report.
991
818
  */
992
819
  export function resolveProjectionCollision(cwd, assetNames, log, logErr) {
993
820
  // Target directories by extension suffix.
@@ -1018,7 +845,7 @@ export function resolveProjectionCollision(cwd, assetNames, log, logErr) {
1018
845
  // normalizedStem → [ { filename, fullPath } ]
1019
846
  const groups = new Map();
1020
847
  for (const filename of files) {
1021
- if (!filename.endsWith(ext))
848
+ if (!filename.endsWith(ext) && !filename.endsWith(`${ext}.dormant`))
1022
849
  continue;
1023
850
  const fullPath = join(dir, filename);
1024
851
  // Only operate on files that have the auto-gen MARKER.
@@ -1031,7 +858,10 @@ export function resolveProjectionCollision(cwd, assetNames, log, logErr) {
1031
858
  }
1032
859
  if (!content.includes(MARKER))
1033
860
  continue; // user-owned — never touch
1034
- const stem = basename(filename, ext);
861
+ // A leftover renamed-dormant sibling (from a prior binary version — this one no
862
+ // longer creates them) groups under its live file's stem so the rules below can
863
+ // reconcile or unlink it like any other stray file.
864
+ const stem = basename(filename.endsWith('.dormant') ? filename.slice(0, -'.dormant'.length) : filename, ext);
1035
865
  const normalizedStem = normalizeMaterializedFilename(stem);
1036
866
  const group = groups.get(normalizedStem) ?? [];
1037
867
  group.push({ filename, fullPath });
@@ -1062,6 +892,46 @@ export function resolveProjectionCollision(cwd, assetNames, log, logErr) {
1062
892
  results.push({ action: 'kept', filePath: members[0].fullPath, reason: 'canonical-exact' });
1063
893
  continue;
1064
894
  }
895
+ // A live file plus leftover .dormant sibling(s) (from an older binary) is reconciled
896
+ // by CONTENT, never mtime — the generic ambiguous arm below could otherwise delete the
897
+ // live file or a user-edited dormant copy (PR #911 review, Codex P1).
898
+ const liveMembers = members.filter((m) => !m.filename.endsWith('.dormant'));
899
+ const dormantMembers = members.filter((m) => m.filename.endsWith('.dormant'));
900
+ if (liveMembers.length === 1 && dormantMembers.length > 0 && dormantMembers.length === members.length - 1) {
901
+ const live = liveMembers[0];
902
+ results.push({ action: 'kept', filePath: live.fullPath, reason: 'canonical-exact' });
903
+ let liveNorm = '';
904
+ try {
905
+ liveNorm = normalizeHandshakeContentForComparison(readFileSync(live.fullPath, 'utf8')).trimEnd();
906
+ }
907
+ catch { /* never matches below */ }
908
+ for (const dormant of dormantMembers) {
909
+ let dormantNorm = null;
910
+ // The retired marker is appended, not substituted — strip it before comparing.
911
+ try {
912
+ const raw = readFileSync(dormant.fullPath, 'utf8').split('<!-- pb-status: dormant -->').join('');
913
+ dormantNorm = normalizeHandshakeContentForComparison(raw).trimEnd();
914
+ }
915
+ catch { /* preserve as drift below */ }
916
+ if (dormantNorm !== null && dormantNorm === liveNorm) {
917
+ try {
918
+ unlinkSync(dormant.fullPath);
919
+ log(`Dormant sibling unlinked (matches live): ${dormant.fullPath}`);
920
+ results.push({ action: 'unlinked', filePath: dormant.fullPath, reason: 'dormant-sibling-matches-live' });
921
+ }
922
+ catch (err) {
923
+ logErr(`Warning: could not unlink dormant sibling ${dormant.fullPath} — ${err instanceof Error ? err.message : String(err)}`);
924
+ results.push({ action: 'kept', filePath: dormant.fullPath, reason: 'unlink-failed' });
925
+ }
926
+ }
927
+ else {
928
+ collisionTens.push(`Leftover dormant copy ${dormant.fullPath} diverges from the live projection; left both in place. Resolve manually.`);
929
+ logErr(`Warning: ${dormant.fullPath} diverges from the live file; not replacing (possible manual edit).`);
930
+ results.push({ action: 'kept', filePath: dormant.fullPath, reason: 'dormant-sibling-diverged' });
931
+ }
932
+ }
933
+ continue;
934
+ }
1065
935
  // Multiple files with the same normalized stem → case-collision.
1066
936
  // Rule: exact match (filename stem === normalized stem, i.e. already lowercase) wins.
1067
937
  const exactMatches = members.filter(({ filename }) => {
@@ -1255,8 +1125,6 @@ export async function runHandshake(options = {}) {
1255
1125
  let dbRules = [];
1256
1126
  let usedDbSource = false;
1257
1127
  let dbAssetRows = [];
1258
- // WP-379 S4: dormant assets (gate-failed) — their on-disk files get the dormant marker.
1259
- let dormantDbAssetRows = [];
1260
1128
  // WP-428 S2 (Finding #5): tracks entryIds whose body fetch from storage failed.
1261
1129
  // Hoisted here (same scope as dbAssetRows) so both the skills/rules projection loop
1262
1130
  // AND the authoring-file projection loop can skip failed assets.
@@ -1339,8 +1207,9 @@ export async function runHandshake(options = {}) {
1339
1207
  }
1340
1208
  }
1341
1209
  try {
1342
- // WP-379 S4: listAssetsForUser now returns { activeAssets, dormantAssets }.
1343
- // Wire format changed from DbAsset[] to { activeAssets: DbAsset[], dormantAssets: DbAsset[] }.
1210
+ // WP-379 S4: listAssetsForUser returns { activeAssets, dormantAssets }. Only
1211
+ // activeAssets is projected (DEC-1690: a dormant/gate-failed asset is simply not
1212
+ // projected — nothing further to reconcile on disk for it).
1344
1213
  // Fall back to empty arrays if the server returns the old flat-array shape (graceful degradation).
1345
1214
  const rawResponse = await kernelCall('setup.listAssetsForUser', {}).catch(() => null);
1346
1215
  let dbAssets = [];
@@ -1348,11 +1217,9 @@ export async function runHandshake(options = {}) {
1348
1217
  if (Array.isArray(rawResponse)) {
1349
1218
  // Pre-S4 server — treat entire response as active assets with no dormant list.
1350
1219
  dbAssets = rawResponse;
1351
- dormantDbAssetRows = [];
1352
1220
  }
1353
1221
  else {
1354
1222
  dbAssets = rawResponse.activeAssets ?? [];
1355
- dormantDbAssetRows = rawResponse.dormantAssets ?? [];
1356
1223
  }
1357
1224
  }
1358
1225
  if (dbAssets.length > 0) {
@@ -1431,9 +1298,6 @@ export async function runHandshake(options = {}) {
1431
1298
  }
1432
1299
  usedDbSource = true;
1433
1300
  log(`Setup assets: ${dbSkills.length} skills, ${dbRules.length} rules/hooks from DB (WP-345 DB-first path)`);
1434
- if (dormantDbAssetRows.length > 0) {
1435
- log(`Setup assets: ${dormantDbAssetRows.length} dormant (gate-filtered) asset(s) will be marked on disk`);
1436
- }
1437
1301
  }
1438
1302
  }
1439
1303
  catch {
@@ -1964,10 +1828,9 @@ export async function runHandshake(options = {}) {
1964
1828
  collisionTensToFire.push(...collisionTens);
1965
1829
  }
1966
1830
  // ── WP-436 S3: Vocab projector — fetch workspace vocab context once per handshake ──
1967
- // Source-side (.productbrain/skills/*.md + rules/*.md) stays tokenized.
1968
- // The projector resolves {{vocab:...}} tokens before writing adapter output to disk
1969
- // (.cursor/rules/, .claude/rules/, CLAUDE.md, AGENTS.md, .github/copilot-instructions.md).
1970
- // Fail-open: if vocab fetch fails, skip resolution and write raw token (no breakage).
1831
+ // Source-side (.productbrain/skills|rules/*.md) stays tokenized; the projector resolves
1832
+ // {{vocab:...}} tokens before writing adapter output to disk. Fail-open: if vocab fetch
1833
+ // fails, skip resolution and write the raw token (no breakage).
1971
1834
  const handshakeVocabCtx = await getOrFetchVocabCtx(config.apiKey, async () => {
1972
1835
  try {
1973
1836
  const vocab = await kernelCall('chain.getVocabulary', {});
@@ -2195,13 +2058,10 @@ export async function runHandshake(options = {}) {
2195
2058
  }
2196
2059
  });
2197
2060
  }
2198
- // Ordering note: this refusal runs AFTER the projected-hash flush so that files legitimately
2199
- // written THIS run still record their hashes; malformed files were never written (no hash to record).
2200
- // TEN-2155: in non-interactive apply, a malformed PB region is a refusal, not a silent skip.
2201
- // Gated on applyMode (NOT writeMode): a malformed region is a user-file INTEGRITY fault, so the
2202
- // refusal fires on any headless `--apply` regardless of materialize authority (STD-263 invariant vi —
2203
- // "headless → non-zero refusal", unqualified). writeMode would suppress it under observe/off, where
2204
- // the malformed file still gets enumerated but the corruption signal would be silently dropped.
2061
+ // Ordering note: runs AFTER the projected-hash flush so files legitimately written THIS run
2062
+ // still record their hashes (malformed files were never written). TEN-2155: in non-interactive
2063
+ // apply, a malformed PB region is a refusal, not a silent skip — gated on applyMode (not
2064
+ // writeMode, which would suppress it under observe/off) per STD-263 invariant vi (unqualified).
2205
2065
  if (applyMode && malformedRegionPaths.length > 0 && (options.noPrompt || !process.stdout.isTTY)) {
2206
2066
  throw new CLIError(`Malformed PB region in: ${malformedRegionPaths.join(', ')}`, {
2207
2067
  code: ErrorCode.VALIDATION_FAILED,
@@ -2210,19 +2070,12 @@ export async function runHandshake(options = {}) {
2210
2070
  });
2211
2071
  }
2212
2072
  // ── WP-421 S3: tampered-bucket resolution (doneWhen #17) ────────────────────
2213
- // Apply mode only. Tampered files were DEFERRED in the write loop above;
2214
- // here we either prompt the user (interactive TTY) or refuse (headless).
2215
- //
2216
- // Headless = `--no-prompt` flag OR `process.stdout.isTTY === false`. When
2217
- // headless: enumerate each tampered file to stderr, write a setup_receipt
2218
- // row with kind='transition' (DEC-962) capturing `refusedTamperedFiles[]`,
2219
- // then exit non-zero. NEVER auto-resolve. (#17 + exclusions: edits to
2220
- // projection dirs are detected, NOT silently overwritten.)
2221
- //
2222
- // Interactive: for each tampered file, present adopt-or-revert. Adopt =
2223
- // create a personal-scoped setup_asset draft from the tampered content
2224
- // (see helper below). Revert = re-project canonical content over the
2225
- // tampered file (write w.content to w.path).
2073
+ // Apply mode only. Tampered files were DEFERRED in the write loop above; here we either
2074
+ // prompt the user (interactive TTY) or refuse headless (--no-prompt or non-TTY stdout):
2075
+ // enumerate to stderr, write a setup_receipt row (kind='transition', DEC-962) capturing
2076
+ // refusedTamperedFiles[], exit non-zero — NEVER auto-resolve. Interactive: adopt (create a
2077
+ // personal-scoped setup_asset draft from the tampered content, see helper below) or revert
2078
+ // (re-project canonical content over the tampered file, write w.content to w.path).
2226
2079
  const adoptedTamperedPaths = [];
2227
2080
  const revertedTamperedPaths = [];
2228
2081
  if (writeMode && tamperedBucket.length > 0) {
@@ -2388,156 +2241,11 @@ export async function runHandshake(options = {}) {
2388
2241
  }
2389
2242
  }
2390
2243
  }
2391
- // 8a. Dormant marker + .dormant rename (WP-379 S4 + WP-426 E4) — apply mode only.
2392
- const dormantMarkedPaths = [];
2393
- if (writeMode && dormantDbAssetRows.length > 0) {
2394
- const dormantState = loadAuthoringSyncState(pbDir);
2395
- let dormantStateChanged = false;
2396
- for (const dormantAsset of dormantDbAssetRows) {
2397
- for (const { path: filePath, surface } of deriveDormantFilePaths(dormantAsset, cwd)) {
2398
- // Codex P2: deriveDormantFilePaths emits every known surface path. Honor the run's
2399
- // target set (allowedTargets = the --surfaces selection, else all manifest surfaces)
2400
- // so a `--surfaces cursor` run never renames .claude/.codex to .dormant, and surfaces
2401
- // outside the manifest are skipped silently (no false "could not dormant-mark" warning).
2402
- if (!allowedTargets.has(surface))
2403
- continue;
2404
- // FIX 4: use relative() instead of string-replace for correct cross-platform behaviour.
2405
- const rel = relative(cwd, filePath);
2406
- const alreadyReactivated = dormantState.dormantReactivated?.includes(rel) ?? false;
2407
- const previouslyRenamed = dormantState.dormantRenamed?.includes(rel) ?? false;
2408
- try {
2409
- assertSetupWritePath(filePath, perimeterManifest);
2410
- // WP-426 E4: BUG 1 fix — hands-off set.
2411
- //
2412
- // Step 1: permanently hands-off — user already reactivated this path on a
2413
- // prior run. Skip silently every time until they re-lower or raise in PB.
2414
- // (Task 7 raise-cleanup must later also prune dormantReactivated for raised
2415
- // assets — see the `dormantReactivated` comment on AuthoringSyncState.)
2416
- if (alreadyReactivated) {
2417
- continue; // leave file untouched; no TEN (already fired on first detection)
2418
- }
2419
- // Step 2: FIRST detection of manual reactivation — we previously renamed
2420
- // this to .dormant but the user renamed it back to a live file. Push the
2421
- // drift TEN exactly once, add to the permanent hands-off set, remove from
2422
- // dormantRenamed, leave file untouched.
2423
- if (previouslyRenamed && existsSync(filePath) && !existsSync(`${filePath}.dormant`)) {
2424
- syncDriftTensToFire.push(`Dormant asset ${dormantAsset.entryId} was manually reactivated at ${rel}; left untouched. Re-lower or raise it in PB to resync.`);
2425
- logErr(`Warning: ${rel} was manually un-dormanted; leaving it in place (drift).`);
2426
- dormantState.dormantReactivated = [...(dormantState.dormantReactivated ?? []), rel];
2427
- dormantState.dormantRenamed = (dormantState.dormantRenamed ?? []).filter((p) => p !== rel);
2428
- dormantStateChanged = true;
2429
- continue;
2430
- }
2431
- // Step 3: normal lowering — write dormant marker + rename to .dormant.
2432
- // WP-426 E4 spec: every rename TARGET must also pass the perimeter guard.
2433
- // Check the post-rename .dormant path BEFORE any FS mutation, so a guard
2434
- // failure can't leave a half-dormant file (marker appended but not renamed).
2435
- assertSetupWritePath(`${filePath}.dormant`, perimeterManifest);
2436
- const markerResult = writeDormantMarkerToFile(filePath);
2437
- if (markerResult === 'written')
2438
- log(`Dormant marker written: ${filePath}`);
2439
- const renameResult = renameSurfaceForDormancy(filePath);
2440
- if (renameResult === 'renamed' || renameResult === 'replaced') {
2441
- // dormantMarkedPaths holds post-rename .dormant paths (not the original surface paths).
2442
- dormantMarkedPaths.push(`${filePath}.dormant`);
2443
- if (!dormantState.dormantRenamed?.includes(rel)) {
2444
- dormantState.dormantRenamed = [...(dormantState.dormantRenamed ?? []), rel];
2445
- dormantStateChanged = true;
2446
- }
2447
- log(`Dormant rename: ${filePath} → ${filePath}.dormant`);
2448
- }
2449
- else if (renameResult === 'drift') {
2450
- // Codex P1: an edited .dormant already exists alongside the live file. Preserve
2451
- // both and flag, rather than overwriting the user's edited dormant copy.
2452
- syncDriftTensToFire.push(`Edited dormant copy ${rel}.dormant diverges from the live surface for ${dormantAsset.entryId}; left both in place. Resolve manually.`);
2453
- logErr(`Warning: ${rel}.dormant diverges from the live file; not replacing (possible manual edit).`);
2454
- }
2455
- }
2456
- catch (err) {
2457
- logErr(`Warning: could not dormant-mark ${filePath} — ${err instanceof Error ? err.message : String(err)}`);
2458
- }
2459
- }
2460
- }
2461
- if (dormantMarkedPaths.length > 0) {
2462
- log('Run `pb setup observe --purge` to remove these instead of dormant-renaming.');
2463
- }
2464
- if (dormantStateChanged) {
2465
- try {
2466
- saveAuthoringSyncState(pbDir, dormantState);
2467
- }
2468
- catch (err) {
2469
- logErr(`Warning: could not persist dormancy state — ${err instanceof Error ? err.message : String(err)}`);
2470
- }
2471
- }
2472
- }
2473
- // 8b. Raise-cleanup (WP-426 E4): for assets active this run, drop any orphan
2474
- // <surface>.dormant left by a prior lowering (active surface was re-projected
2475
- // fresh above). User-edited dormant copies are preserved + flagged. Fail-open.
2476
- if (writeMode) {
2477
- const raiseState = loadAuthoringSyncState(pbDir);
2478
- let raiseStateChanged = false;
2479
- const dormantIds = new Set(dormantDbAssetRows.map((a) => a.entryId));
2480
- const activeRows = dbAssetRows.filter((a) => !dormantIds.has(a.entryId) &&
2481
- (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
2482
- for (const asset of activeRows) {
2483
- // Codex P2: if this asset's body fetch failed, the write loop did NOT reproject its
2484
- // surfaces this run (same skip the authoring loop applies), so there's no fresh surface
2485
- // to reconcile against — skip raise-cleanup to avoid acting on stale content.
2486
- if (bodyFetchFailedEntryIds.has(asset.entryId))
2487
- continue;
2488
- for (const { path: filePath, surface } of deriveDormantFilePaths(asset, cwd)) {
2489
- // Codex P2: honor the run's target set (parity with the dormant pass) — a
2490
- // `--surfaces cursor` run must not touch .claude/.codex .dormant files, and surfaces
2491
- // outside the manifest are skipped silently (no false "raise-cleanup failed" warning).
2492
- if (!allowedTargets.has(surface))
2493
- continue;
2494
- const rel = relative(cwd, filePath);
2495
- try {
2496
- assertSetupWritePath(filePath, perimeterManifest); // WP-426 E4: perimeter before any FS mutation (parity with the dormant pass)
2497
- // E4 spec parity (review): restoreSurfaceFromDormant deletes the .dormant
2498
- // sibling, so guard that delete TARGET too — exactly as the lowering pass
2499
- // guards both filePath and ${filePath}.dormant.
2500
- assertSetupWritePath(`${filePath}.dormant`, perimeterManifest);
2501
- const r = restoreSurfaceFromDormant(filePath);
2502
- if (r === 'restored') {
2503
- log(`Raised: removed superseded ${filePath}.dormant`);
2504
- }
2505
- else if (r === 'orphan-drift') {
2506
- syncDriftTensToFire.push(`Dormant copy ${rel}.dormant was edited while dormant for ${asset.entryId}; preserved on raise. Resolve manually.`);
2507
- logErr(`Warning: ${rel}.dormant differs from the freshly raised projection; left in place.`);
2508
- }
2509
- // Prune the registry only when no .dormant sibling remains for this surface:
2510
- // • 'restored' removed it, or
2511
- // • it was already gone (manual .dormant→live reactivation; 'skipped', no .dormant).
2512
- // Carry-over obligation (Phase 3) + Codex P1: the manual-reactivation case must
2513
- // leave the hands-off set so a future lowering can re-evaluate the surface.
2514
- // Codex P2: but if a .dormant STILL exists (surface-filtered 'skipped' with no
2515
- // fresh live file, or a preserved 'orphan-drift'), KEEP the registry evidence —
2516
- // otherwise a later manual .dormant→live reactivation would not be recognized as
2517
- // previouslyRenamed and would be silently re-dormanted on the next lowering.
2518
- if (!existsSync(`${filePath}.dormant`)) {
2519
- if (raiseState.dormantRenamed?.includes(rel)) {
2520
- raiseState.dormantRenamed = raiseState.dormantRenamed.filter((p) => p !== rel);
2521
- raiseStateChanged = true;
2522
- }
2523
- if (raiseState.dormantReactivated?.includes(rel)) {
2524
- raiseState.dormantReactivated = raiseState.dormantReactivated.filter((p) => p !== rel);
2525
- raiseStateChanged = true;
2526
- }
2527
- }
2528
- }
2529
- catch (err) {
2530
- logErr(`Warning: raise-cleanup failed for ${filePath} — ${err instanceof Error ? err.message : String(err)}`);
2531
- }
2532
- }
2533
- }
2534
- if (raiseStateChanged) {
2535
- try {
2536
- saveAuthoringSyncState(pbDir, raiseState);
2537
- }
2538
- catch { /* fail-open */ }
2539
- }
2540
- }
2244
+ // DEC-1690: a dormant (gate-failed) asset is simply not projected — dbAssets above
2245
+ // already comes from the server's activeAssets list, so no further action is needed
2246
+ // here. The WP-379 S4 / WP-426 E4 on-disk marker+rename+restore machinery (and its
2247
+ // .authoring-sync.json dormancy registries) is retired: pre-launch, every on-disk
2248
+ // projection is regenerable, so there is nothing to reconcile.
2541
2249
  // 8. User-owned files left untouched are NOT drift (TEN-2150).
2542
2250
  // A marker-less file at an adapter path is the user's own file: handshake never
2543
2251
  // wrote it, so there is nothing to "sync" and no draft TEN is logged. These files
@@ -2657,5 +2365,5 @@ export async function runHandshake(options = {}) {
2657
2365
  return report;
2658
2366
  }
2659
2367
  // WP-426 E3/E4: exported test-only surface (not part of the public CLI API).
2660
- export const __test = { setupAuthoringPath, renameSurfaceForDormancy, restoreSurfaceFromDormant, loadAuthoringSyncState, MARKER };
2368
+ export const __test = { setupAuthoringPath, loadAuthoringSyncState, MARKER };
2661
2369
  //# sourceMappingURL=handshake.js.map