@productbrain/cli 0.1.0-beta.5988 → 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.
- package/dist/__tests__/handshake-dormancy.test.js +4 -129
- package/dist/__tests__/handshake-dormancy.test.js.map +1 -1
- package/dist/__tests__/handshake.e2e.test.js +4 -772
- package/dist/__tests__/handshake.e2e.test.js.map +1 -1
- package/dist/__tests__/handshake.test.js +1 -58
- package/dist/__tests__/handshake.test.js.map +1 -1
- package/dist/commands/handshake.d.ts +12 -73
- package/dist/commands/handshake.d.ts.map +1 -1
- package/dist/commands/handshake.js +96 -388
- package/dist/commands/handshake.js.map +1 -1
- package/dist/formatters/brief.d.ts +4 -3
- package/dist/formatters/brief.d.ts.map +1 -1
- package/dist/formatters/brief.js +4 -1
- package/dist/formatters/brief.js.map +1 -1
- package/dist/formatters/brief.test.js +2 -1
- package/dist/formatters/brief.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
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
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
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
|
-
// (
|
|
727
|
-
//
|
|
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
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
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
|
|
971
|
-
* .
|
|
972
|
-
*
|
|
973
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
1343
|
-
//
|
|
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
|
|
1968
|
-
//
|
|
1969
|
-
//
|
|
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:
|
|
2199
|
-
//
|
|
2200
|
-
//
|
|
2201
|
-
//
|
|
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
|
-
//
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2218
|
-
//
|
|
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
|
-
//
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
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,
|
|
2368
|
+
export const __test = { setupAuthoringPath, loadAuthoringSyncState, MARKER };
|
|
2661
2369
|
//# sourceMappingURL=handshake.js.map
|