@productbrain/cli 0.1.0-beta.7395 → 0.1.0-beta.7409
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 +6 -64
- package/dist/__tests__/handshake-dormancy.test.js.map +1 -1
- package/dist/__tests__/handshake-preview.test.js +41 -651
- package/dist/__tests__/handshake-preview.test.js.map +1 -1
- package/dist/__tests__/handshake.e2e.test.js +3 -262
- package/dist/__tests__/handshake.e2e.test.js.map +1 -1
- package/dist/__tests__/perimeter.test.js +1 -1
- package/dist/__tests__/perimeter.test.js.map +1 -1
- package/dist/__tests__/route-latency-budget.test.js +3 -1
- package/dist/__tests__/route-latency-budget.test.js.map +1 -1
- package/dist/commands/admin/seed.test.js +4 -9
- package/dist/commands/admin/seed.test.js.map +1 -1
- package/dist/commands/admin/seedRegistryEntries.generated.d.ts.map +1 -1
- package/dist/commands/admin/seedRegistryEntries.generated.js +0 -18
- package/dist/commands/admin/seedRegistryEntries.generated.js.map +1 -1
- package/dist/commands/admin/seedRegistryEntries.test.js +10 -7
- package/dist/commands/admin/seedRegistryEntries.test.js.map +1 -1
- package/dist/commands/connect-context.d.ts +8 -10
- package/dist/commands/connect-context.d.ts.map +1 -1
- package/dist/commands/connect-context.js +6 -8
- package/dist/commands/connect-context.js.map +1 -1
- package/dist/commands/connect-context.test.js +15 -10
- package/dist/commands/connect-context.test.js.map +1 -1
- package/dist/commands/connect-screens.d.ts +5 -4
- package/dist/commands/connect-screens.d.ts.map +1 -1
- package/dist/commands/connect-screens.js +7 -7
- package/dist/commands/connect-screens.js.map +1 -1
- package/dist/commands/handshake.d.ts +0 -19
- package/dist/commands/handshake.d.ts.map +1 -1
- package/dist/commands/handshake.js +67 -746
- package/dist/commands/handshake.js.map +1 -1
- package/dist/generated/routeLatencyBudget.generated.d.ts.map +1 -1
- package/dist/generated/routeLatencyBudget.generated.js +0 -25
- package/dist/generated/routeLatencyBudget.generated.js.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/{setup/perimeter.d.ts → lib/writePerimeter.d.ts} +11 -11
- package/dist/lib/writePerimeter.d.ts.map +1 -0
- package/dist/{setup/perimeter.js → lib/writePerimeter.js} +11 -11
- package/dist/lib/writePerimeter.js.map +1 -0
- package/package.json +1 -1
- package/dist/setup/perimeter.d.ts.map +0 -1
- package/dist/setup/perimeter.js.map +0 -1
- package/dist/surfaces/__tests__/pb-setup-passthrough.test.d.ts +0 -2
- package/dist/surfaces/__tests__/pb-setup-passthrough.test.d.ts.map +0 -1
- package/dist/surfaces/__tests__/pb-setup-passthrough.test.js +0 -131
- package/dist/surfaces/__tests__/pb-setup-passthrough.test.js.map +0 -1
|
@@ -3,16 +3,14 @@
|
|
|
3
3
|
* Context export wiring (read-only filesystem bridge; GLO-63, DEC-161) — not a product surface.
|
|
4
4
|
*/
|
|
5
5
|
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync, realpathSync } from 'fs';
|
|
6
|
-
import { join, dirname, resolve, basename, relative, sep
|
|
6
|
+
import { join, dirname, resolve, basename, relative, sep } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
|
-
import { createHash } from 'crypto';
|
|
10
9
|
import { execSync } from 'child_process';
|
|
11
10
|
import { getConfigOrGuide } from '../lib/config.js';
|
|
12
11
|
import { select as promptSelect, confirm as promptConfirm } from '../lib/prompts.js';
|
|
13
12
|
import { composeHooksFromIntents, getHookStatusForSurface } from '../lib/hook-intents.js';
|
|
14
|
-
import { kernelCall
|
|
15
|
-
import { readSession } from '../lib/session.js';
|
|
13
|
+
import { kernelCall } from '../lib/client.js';
|
|
16
14
|
import { detectRepo, extractWorkspaceProfile } from '../lib/repo-detect.js';
|
|
17
15
|
import { generateContextMd } from '../generators/context-md.js';
|
|
18
16
|
import { generateBriefingMd } from '../generators/briefing-md.js';
|
|
@@ -29,7 +27,6 @@ import { readManifest, readManifestStatus, filterByAdoptionState } from '../gene
|
|
|
29
27
|
import { generateBoundaryManifest, getBoundaryEnforcementMode } from '../generators/boundary-manifest.js';
|
|
30
28
|
import { loadMethodRegistry } from '../lib/method-registry.js';
|
|
31
29
|
import { CLIError, ErrorCode } from '../lib/errors.js';
|
|
32
|
-
import { trackEvent } from '../lib/telemetry.js';
|
|
33
30
|
import { isJsonMode } from '../lib/runner.js';
|
|
34
31
|
import { stripConvexInternals } from '../lib/strip.js';
|
|
35
32
|
import { replaceVocabTokens } from '../lib/canonicalRefs.js';
|
|
@@ -38,12 +35,9 @@ import { getOrFetchVocabCtx } from '../lib/workspaceVocabCache.js';
|
|
|
38
35
|
import { normalizeMaterializedFilename } from '../lib/normalizeMaterializedFilename.js';
|
|
39
36
|
import { findLeftoverCursorSkills, LEFTOVER_FILES_MAX } from './handshakeCursorSkillCleanup.js';
|
|
40
37
|
import { classifyDriftBucket } from './handshakeDriftClassification.js';
|
|
41
|
-
import { parseSetupFrontmatter } from '../lib/frontmatter.js';
|
|
42
38
|
import { sanitizeStartupResolutionSignals } from '../lib/startup-resolution-signals.js';
|
|
43
|
-
import { assertSetupWritePath } from '../
|
|
44
|
-
|
|
45
|
-
import { canonicalPathForAnySurface, SURFACE_GOVERN_NO_SURFACES, SURFACE_REGISTRY, validateSurfacesForMode, } from '../surfaces/registry.js';
|
|
46
|
-
import { getReverseMapFallbackMessage, reportReverseMapMissing } from '../surfaces/telemetry.js';
|
|
39
|
+
import { assertSetupWritePath } from '../lib/writePerimeter.js';
|
|
40
|
+
import { SURFACE_GOVERN_NO_SURFACES, SURFACE_REGISTRY, validateSurfacesForMode, } from '../surfaces/registry.js';
|
|
47
41
|
const LEVELS = {
|
|
48
42
|
guide: {
|
|
49
43
|
label: 'Guide me',
|
|
@@ -492,154 +486,8 @@ function surfacePerimeterRoots(surface) {
|
|
|
492
486
|
return ['.claude', 'CLAUDE.md'];
|
|
493
487
|
return [`.${surface}`];
|
|
494
488
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
498
|
-
function normalizeSetupAuthoringBody(body) {
|
|
499
|
-
return body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd();
|
|
500
|
-
}
|
|
501
|
-
function setupAuthoringAssetHash(asset) {
|
|
502
|
-
const canonical = {
|
|
503
|
-
entryId: asset.entryId,
|
|
504
|
-
name: asset.name,
|
|
505
|
-
description: asset.description ?? '',
|
|
506
|
-
assetKind: asset.assetKind,
|
|
507
|
-
triggers: asset.triggers ?? [],
|
|
508
|
-
semanticRefs: asset.semanticRefs ?? [],
|
|
509
|
-
body: normalizeSetupAuthoringBody(asset.body),
|
|
510
|
-
};
|
|
511
|
-
return `sha256:${createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex')}`;
|
|
512
|
-
}
|
|
513
|
-
function parseSetupAuthoringFrontmatter(raw) {
|
|
514
|
-
// Shared parser (lib/frontmatter.ts): array-item quotes strip exactly as before, now canonical
|
|
515
|
-
// for migrate-setup/setup-ingest too. `assetKind` ignored (handshake derives kind from dir).
|
|
516
|
-
const parsed = parseSetupFrontmatter(raw);
|
|
517
|
-
return {
|
|
518
|
-
frontmatterId: parsed.frontmatterId,
|
|
519
|
-
name: parsed.name,
|
|
520
|
-
description: parsed.description,
|
|
521
|
-
body: parsed.body,
|
|
522
|
-
triggers: parsed.triggers,
|
|
523
|
-
semanticRefs: parsed.semanticRefs,
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
function deriveSetupAuthoringEntryId(filename, kind) {
|
|
527
|
-
const base = basename(filename, '.md');
|
|
528
|
-
const snakeCase = base.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
|
|
529
|
-
return `SETUP-${kind.toUpperCase()}-${snakeCase}`;
|
|
530
|
-
}
|
|
531
|
-
function scanSetupAuthoringFiles(productbrainDir) {
|
|
532
|
-
const dirs = [
|
|
533
|
-
{ dir: 'skills', kind: 'skill' },
|
|
534
|
-
{ dir: 'rules', kind: 'rule' },
|
|
535
|
-
{ dir: 'hooks', kind: 'hook' },
|
|
536
|
-
];
|
|
537
|
-
const items = [];
|
|
538
|
-
for (const { dir, kind } of dirs) {
|
|
539
|
-
const absDir = join(productbrainDir, dir);
|
|
540
|
-
if (!existsSync(absDir))
|
|
541
|
-
continue;
|
|
542
|
-
for (const file of readdirSync(absDir).filter((f) => f.endsWith('.md'))) {
|
|
543
|
-
const filePath = join(absDir, file);
|
|
544
|
-
const parsed = parseSetupAuthoringFrontmatter(readFileSync(filePath, 'utf8'));
|
|
545
|
-
const fallbackName = basename(file, '.md');
|
|
546
|
-
items.push({
|
|
547
|
-
filePath,
|
|
548
|
-
derivedEntryId: deriveSetupAuthoringEntryId(file, kind),
|
|
549
|
-
frontmatterId: parsed.frontmatterId,
|
|
550
|
-
name: parsed.name || fallbackName,
|
|
551
|
-
description: parsed.description,
|
|
552
|
-
body: parsed.body,
|
|
553
|
-
assetKind: kind,
|
|
554
|
-
triggers: parsed.triggers,
|
|
555
|
-
semanticRefs: parsed.semanticRefs,
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return items.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
560
|
-
}
|
|
561
|
-
function setupAuthoringDirForKind(kind) {
|
|
562
|
-
if (kind === 'skill')
|
|
563
|
-
return 'skills';
|
|
564
|
-
if (kind === 'rule')
|
|
565
|
-
return 'rules';
|
|
566
|
-
if (kind === 'hook')
|
|
567
|
-
return 'hooks';
|
|
568
|
-
return null;
|
|
569
|
-
}
|
|
570
|
-
function setupAuthoringFilename(name, entryId) {
|
|
571
|
-
const base = (name || entryId)
|
|
572
|
-
.replace(/[\/\\:*?"<>|]/g, '-')
|
|
573
|
-
.replace(/\s+/g, ' ')
|
|
574
|
-
.trim();
|
|
575
|
-
return `${base || entryId}.md`;
|
|
576
|
-
}
|
|
577
|
-
function setupAuthoringPath(cwd, asset) {
|
|
578
|
-
const dir = setupAuthoringDirForKind(asset.assetKind);
|
|
579
|
-
if (!dir)
|
|
580
|
-
return null;
|
|
581
|
-
// WP-426 E3: recorded authoring source path wins so reprojection lands where the
|
|
582
|
-
// user authored — no duplicate (TEN-1920). Stored relative to .productbrain/.
|
|
583
|
-
if (asset.authoringPath && asset.authoringPath.trim()) {
|
|
584
|
-
// Codex P1/P2: authoringPath is untrusted DB data, probed before any assertSetupWritePath guard runs —
|
|
585
|
-
// constrain to a kind-appropriate markdown file, else fall back to the safe name-derived path.
|
|
586
|
-
const pbDir = join(cwd, '.productbrain');
|
|
587
|
-
const candidate = resolve(pbDir, asset.authoringPath);
|
|
588
|
-
const within = relative(pbDir, candidate);
|
|
589
|
-
const withinPosix = within.split(sep).join('/');
|
|
590
|
-
if (!within.startsWith('..') &&
|
|
591
|
-
!isAbsolute(within) &&
|
|
592
|
-
withinPosix.startsWith(`${dir}/`) &&
|
|
593
|
-
withinPosix.toLowerCase().endsWith('.md')) {
|
|
594
|
-
return candidate;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
return join(cwd, '.productbrain', dir, setupAuthoringFilename(asset.name, asset.entryId));
|
|
598
|
-
}
|
|
599
|
-
function renderSetupAuthoringFile(asset) {
|
|
600
|
-
const lines = [
|
|
601
|
-
'---',
|
|
602
|
-
`id: ${asset.entryId}`,
|
|
603
|
-
`name: ${JSON.stringify(asset.name)}`,
|
|
604
|
-
`description: ${JSON.stringify(asset.description ?? '')}`,
|
|
605
|
-
`assetKind: ${asset.assetKind}`,
|
|
606
|
-
];
|
|
607
|
-
const pushArray = (key, values) => {
|
|
608
|
-
if (!values || values.length === 0)
|
|
609
|
-
return;
|
|
610
|
-
lines.push(`${key}:`);
|
|
611
|
-
for (const value of values)
|
|
612
|
-
lines.push(` - ${JSON.stringify(value)}`);
|
|
613
|
-
};
|
|
614
|
-
pushArray('triggers', asset.triggers);
|
|
615
|
-
pushArray('semanticRefs', asset.semanticRefs);
|
|
616
|
-
lines.push('---', normalizeSetupAuthoringBody(asset.body), '');
|
|
617
|
-
return lines.join('\n');
|
|
618
|
-
}
|
|
619
|
-
function loadAuthoringSyncState(productbrainDir) {
|
|
620
|
-
const statePath = join(productbrainDir, '.authoring-sync.json');
|
|
621
|
-
if (!existsSync(statePath))
|
|
622
|
-
return { version: 1, assets: {} };
|
|
623
|
-
try {
|
|
624
|
-
const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
625
|
-
if (parsed.version !== 1 || !parsed.assets || typeof parsed.assets !== 'object') {
|
|
626
|
-
return { version: 1, assets: {} };
|
|
627
|
-
}
|
|
628
|
-
return {
|
|
629
|
-
version: 1,
|
|
630
|
-
assets: parsed.assets,
|
|
631
|
-
};
|
|
632
|
-
}
|
|
633
|
-
catch {
|
|
634
|
-
return { version: 1, assets: {} };
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
function saveAuthoringSyncState(productbrainDir, state) {
|
|
638
|
-
const statePath = join(productbrainDir, '.authoring-sync.json');
|
|
639
|
-
assertSetupWritePath(statePath, { surfaces: [] });
|
|
640
|
-
mkdirSync(productbrainDir, { recursive: true });
|
|
641
|
-
writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
642
|
-
}
|
|
489
|
+
// WP-776 S3f: modeRank deleted — its only caller compared authority-mode transitions for the
|
|
490
|
+
// now-deleted setup.recordTransition receipt.
|
|
643
491
|
// ── WP-421 S3: Three-bucket drift classification (doneWhen #17) — now its own module (STD-2/DEC-1504
|
|
644
492
|
// shrink-only offset). Re-exported so existing imports `from '../commands/handshake.js'` keep working.
|
|
645
493
|
export { classifyDriftBucket };
|
|
@@ -887,280 +735,14 @@ export async function runHandshake(options = {}) {
|
|
|
887
735
|
const authorityCanWrite = manifestStatus.mode === 'project' || manifestStatus.mode === 'govern';
|
|
888
736
|
const authorityPreviewOnly = applyMode && !authorityCanWrite;
|
|
889
737
|
const writeMode = applyMode && authorityCanWrite;
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
// WP-428 S2 (Finding #5): entryIds whose body fetch failed — hoisted so both the
|
|
899
|
-
// skills/rules AND authoring-file projection loops can skip them.
|
|
900
|
-
const bodyFetchFailedEntryIds = new Set();
|
|
901
|
-
const dbProjectionHashes = new Map();
|
|
902
|
-
const syncDriftTensToFire = [];
|
|
903
|
-
const deferredAuthoringBaselineEntryIds = new Set();
|
|
904
|
-
if (writeMode) {
|
|
905
|
-
const authoringItems = scanSetupAuthoringFiles(pbDir);
|
|
906
|
-
if (authoringItems.length > 0) {
|
|
907
|
-
// WP-428 S2 (Critical #1): ingestSetupAssetWithBody runs per-asset (batch can't call ctx.storage.store()), losing intra-batch collision detection — checked client-side below (replaces server-side DEC-954).
|
|
908
|
-
const idToPaths = new Map();
|
|
909
|
-
for (const item of authoringItems) {
|
|
910
|
-
const canonicalId = item.frontmatterId?.trim() || item.derivedEntryId;
|
|
911
|
-
const paths = idToPaths.get(canonicalId) ?? [];
|
|
912
|
-
paths.push(item.filePath);
|
|
913
|
-
idToPaths.set(canonicalId, paths);
|
|
914
|
-
}
|
|
915
|
-
const conflictingPaths = new Set();
|
|
916
|
-
for (const [, paths] of idToPaths) {
|
|
917
|
-
if (paths.length > 1) {
|
|
918
|
-
for (const p of paths)
|
|
919
|
-
conflictingPaths.add(p);
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
if (conflictingPaths.size > 0) {
|
|
923
|
-
logErr(`Setup authoring import partial: ${conflictingPaths.size} file(s) refused for duplicate setup id. ` +
|
|
924
|
-
[...conflictingPaths].join(', '));
|
|
925
|
-
}
|
|
926
|
-
const itemsToIngest = authoringItems.filter((item) => !conflictingPaths.has(item.filePath));
|
|
927
|
-
if (itemsToIngest.length > 0) {
|
|
928
|
-
try {
|
|
929
|
-
// Parallel uploads — concurrency limit 10 to avoid overwhelming the gateway.
|
|
930
|
-
const CONCURRENCY = 10;
|
|
931
|
-
const ingestResults = [];
|
|
932
|
-
for (let i = 0; i < itemsToIngest.length; i += CONCURRENCY) {
|
|
933
|
-
const batch = itemsToIngest.slice(i, i + CONCURRENCY);
|
|
934
|
-
const settled = await Promise.allSettled(batch.map(async (item) => {
|
|
935
|
-
const result = await kernelCall('setup.ingestSetupAssetWithBody', {
|
|
936
|
-
entryId: item.derivedEntryId,
|
|
937
|
-
frontmatterId: item.frontmatterId,
|
|
938
|
-
name: item.name,
|
|
939
|
-
description: item.description,
|
|
940
|
-
body: item.body,
|
|
941
|
-
assetKind: item.assetKind,
|
|
942
|
-
triggers: item.triggers,
|
|
943
|
-
semanticRefs: item.semanticRefs,
|
|
944
|
-
authoringPath: relative(pbDir, item.filePath).split(sep).join('/'),
|
|
945
|
-
});
|
|
946
|
-
return { filePath: item.filePath, ...result };
|
|
947
|
-
}));
|
|
948
|
-
for (const r of settled) {
|
|
949
|
-
if (r.status === 'fulfilled') {
|
|
950
|
-
ingestResults.push(r.value);
|
|
951
|
-
if (r.value.conflict === 'repo-wins') {
|
|
952
|
-
syncDriftTensToFire.push(`Repo authoring file won setup sync conflict for ${r.value.entryId} at ${r.value.filePath}.`);
|
|
953
|
-
logErr(`Warning: repo authoring file won sync conflict for ${r.value.entryId}; DB edit was overwritten.`);
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
else {
|
|
957
|
-
trackEvent('setup.authoring_import.item_failed', { error: r.reason instanceof Error ? r.reason.message : String(r.reason) });
|
|
958
|
-
logErr(`Warning: setup authoring import failed for one asset — ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
if (ingestResults.length > 0) {
|
|
963
|
-
log(`Setup authoring import: ${ingestResults.length} file(s) checked (with bodyStorageId).`);
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
catch (err) {
|
|
967
|
-
trackEvent('setup.authoring_import.failed', { error: err instanceof Error ? err.message : String(err) });
|
|
968
|
-
logErr(`Warning: setup authoring import failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
try {
|
|
974
|
-
// WP-379 S4: only activeAssets projects (DEC-1690); falls back to [] for the old flat-array server shape.
|
|
975
|
-
const rawResponse = await kernelCall('setup.listAssetsForUser', {}).catch(() => null);
|
|
976
|
-
let dbAssets = [];
|
|
977
|
-
if (rawResponse !== null) {
|
|
978
|
-
if (Array.isArray(rawResponse)) {
|
|
979
|
-
// Pre-S4 server — treat entire response as active assets with no dormant list.
|
|
980
|
-
dbAssets = rawResponse;
|
|
981
|
-
}
|
|
982
|
-
else {
|
|
983
|
-
dbAssets = rawResponse.activeAssets ?? [];
|
|
984
|
-
}
|
|
985
|
-
// Successful (even empty) is authoritative, distinct from "unavailable" (catch below) — an
|
|
986
|
-
// empty active set must still gate cleanup on, or a workspace whose last asset was deleted
|
|
987
|
-
// keeps stale projections forever.
|
|
988
|
-
dbQuerySucceeded = true;
|
|
989
|
-
dbAssetRows = dbAssets;
|
|
990
|
-
}
|
|
991
|
-
if (dbAssets.length > 0) {
|
|
992
|
-
// WP-428 S2: body is no longer inline — fetch from storage per non-disabled skill/rule/hook (bounded, parallel).
|
|
993
|
-
const projectableAssets = dbAssets.filter((a) => !a.disabledByOwner && (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
|
|
994
|
-
const assetsNeedingBodyFetch = projectableAssets.filter((a) => a.bodyStorageId);
|
|
995
|
-
const bodyFetchMap = new Map(); // entryId → body
|
|
996
|
-
// WP-428 S2 (Finding #5/#12): fetch-failed entryIds excluded from dbSkills/dbRules below (outer-scope bodyFetchFailedEntryIds).
|
|
997
|
-
if (assetsNeedingBodyFetch.length > 0) {
|
|
998
|
-
log(`Fetching ${assetsNeedingBodyFetch.length} asset body(s) from storage...`);
|
|
999
|
-
const bodyFetchResults = await Promise.allSettled(assetsNeedingBodyFetch.map(async (asset) => {
|
|
1000
|
-
const result = await kernelCall('setup.fetchAssetBody', { bodyStorageId: asset.bodyStorageId });
|
|
1001
|
-
return { entryId: asset.entryId, name: asset.name, body: result.body };
|
|
1002
|
-
}));
|
|
1003
|
-
for (let i = 0; i < bodyFetchResults.length; i++) {
|
|
1004
|
-
const result = bodyFetchResults[i];
|
|
1005
|
-
if (result.status === 'fulfilled') {
|
|
1006
|
-
bodyFetchMap.set(result.value.entryId, result.value.body);
|
|
1007
|
-
}
|
|
1008
|
-
else {
|
|
1009
|
-
// WP-428 S2 (Finding #12): include entryId and name in the warning (Finding #5: skip projection).
|
|
1010
|
-
const asset = assetsNeedingBodyFetch[i];
|
|
1011
|
-
logErr(`Warning: failed to fetch body for ${asset.entryId} (${asset.name}) — ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
|
1012
|
-
bodyFetchFailedEntryIds.add(asset.entryId);
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
if (bodyFetchFailedEntryIds.size > 0) {
|
|
1016
|
-
// WP-439 S4: strict-by-default — a body-fetch failure is hard unless --lenient.
|
|
1017
|
-
const failedList = [...bodyFetchFailedEntryIds].join(', ');
|
|
1018
|
-
if (options.lenient) {
|
|
1019
|
-
logErr(`Warning: ${bodyFetchFailedEntryIds.size} asset(s) skipped due to body fetch failure (--lenient): ${failedList}`);
|
|
1020
|
-
}
|
|
1021
|
-
else {
|
|
1022
|
-
throw new CLIError(`${bodyFetchFailedEntryIds.size} asset(s) failed body fetch: ${failedList}. ` +
|
|
1023
|
-
`There is no CLI repair path for this release. ` +
|
|
1024
|
-
`Pass --lenient to suppress this and continue with affected entries skipped.`, { code: ErrorCode.INTERNAL, category: 'internal' });
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
// Map DB assets to CanonicalSkill/CanonicalRule shapes
|
|
1029
|
-
for (const asset of dbAssets) {
|
|
1030
|
-
if (asset.disabledByOwner)
|
|
1031
|
-
continue;
|
|
1032
|
-
// WP-428 S2 (Finding #5): skip fetch-failed assets — no empty body written, no lastProjectedHash update.
|
|
1033
|
-
if (bodyFetchFailedEntryIds.has(asset.entryId))
|
|
1034
|
-
continue;
|
|
1035
|
-
// WP-428 S2: prefer storage-fetched body, fall back to inline body (pre-S2 servers, deletable once all servers are S2+).
|
|
1036
|
-
const resolvedBody = bodyFetchMap.get(asset.entryId) ?? asset.body ?? '';
|
|
1037
|
-
if (asset.assetKind === 'skill') {
|
|
1038
|
-
dbSkills.push({
|
|
1039
|
-
name: asset.name,
|
|
1040
|
-
description: asset.description,
|
|
1041
|
-
triggers: asset.triggers ?? [],
|
|
1042
|
-
body: resolvedBody,
|
|
1043
|
-
sourcePath: `db:${asset.entryId}`,
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
else if (asset.assetKind === 'rule' || asset.assetKind === 'hook') {
|
|
1047
|
-
dbRules.push({
|
|
1048
|
-
name: asset.name,
|
|
1049
|
-
description: asset.description,
|
|
1050
|
-
autoApply: false,
|
|
1051
|
-
body: resolvedBody,
|
|
1052
|
-
sourcePath: `db:${asset.entryId}`,
|
|
1053
|
-
});
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
log(`Setup assets: ${dbSkills.length} skills, ${dbRules.length} rules/hooks from DB (WP-345 DB-first path)`);
|
|
1057
|
-
// Reaching here (no throw above) means it's safe to use as the CONTENT source (Codex P2, PR #912 round 3).
|
|
1058
|
-
usedDbSource = true;
|
|
1059
|
-
}
|
|
1060
|
-
else if (dbQuerySucceeded && writeMode) {
|
|
1061
|
-
// Empty DB in WRITE mode is authoritative (ingest already ran); preview never ingests, so FS still forecasts apply.
|
|
1062
|
-
// Round 14: findLeftoverProjections only lists stale files, never removes them — say so.
|
|
1063
|
-
// Round 15: it also skips surfaces outside allowedTargets — scope the claim to those.
|
|
1064
|
-
log('Setup assets: DB query succeeded with zero active assets — any existing projections on the selected surfaces will be listed as leftovers, not removed.');
|
|
1065
|
-
usedDbSource = true;
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
catch {
|
|
1069
|
-
// DB unavailable, or post-query processing failed hard — usedDbSource stays false either way.
|
|
1070
|
-
}
|
|
1071
|
-
const allSkills = usedDbSource ? dbSkills : readCanonicalSkills(pbDir);
|
|
1072
|
-
const manualRules = usedDbSource ? dbRules : readCanonicalRules(pbDir);
|
|
1073
|
-
if (!usedDbSource) {
|
|
1074
|
-
log('Setup assets: reading from filesystem (DB source unavailable, or unauthoritative for this mode)');
|
|
1075
|
-
}
|
|
1076
|
-
// 5a-pre. E4: resolve semantic refs (WP-345, DEC-A/C/K) — replace {{ref:key}} via the Convex
|
|
1077
|
-
// resolver, apply mode only. NG11: PostHog events fire from CLI side only.
|
|
1078
|
-
if (usedDbSource && writeMode) {
|
|
1079
|
-
const projectableDbAssets = dbAssetRows.filter((a) => !a.disabledByOwner && (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
|
|
1080
|
-
const assetsWithRefs = projectableDbAssets.filter((a) => a.semanticRefs && a.semanticRefs.length > 0);
|
|
1081
|
-
if (assetsWithRefs.length > 0) {
|
|
1082
|
-
log(`Resolving semantic refs for ${assetsWithRefs.length} asset(s)...`);
|
|
1083
|
-
// Collect unique ref keys across all assets
|
|
1084
|
-
const allRefKeys = [...new Set(assetsWithRefs.flatMap((a) => a.semanticRefs))];
|
|
1085
|
-
// Resolve all refs in a single batch call (DEC-767 / WP-354 Build-Order #6 — kind + status discriminator).
|
|
1086
|
-
let resolvedRefs = [];
|
|
1087
|
-
try {
|
|
1088
|
-
resolvedRefs = await kernelCall('setup.resolveSemanticRefs', { semanticRefs: allRefKeys });
|
|
1089
|
-
}
|
|
1090
|
-
catch (err) {
|
|
1091
|
-
trackEvent('setup.refs.resolve_failed', { error: err instanceof Error ? err.message : String(err) });
|
|
1092
|
-
logErr(`Warning: could not resolve semantic refs — ${err instanceof Error ? err.message : String(err)}`);
|
|
1093
|
-
}
|
|
1094
|
-
// Build resolved map: canonicalKey → display name. Only required refs count as unresolved warnings.
|
|
1095
|
-
const resolvedMap = new Map();
|
|
1096
|
-
let unresolvedCount = 0;
|
|
1097
|
-
for (const result of resolvedRefs) {
|
|
1098
|
-
if (result.status === 'resolved' && result.localEntryId) {
|
|
1099
|
-
resolvedMap.set(result.ref, result.localEntryId);
|
|
1100
|
-
trackEvent('skill.ref.resolved', { ref: result.ref, kind: result.kind });
|
|
1101
|
-
}
|
|
1102
|
-
else if (result.status === 'unsupported-future') {
|
|
1103
|
-
// seed: refs are explicitly future scope per WP-354 Build-Order #6 — not a warning.
|
|
1104
|
-
trackEvent('skill.ref.future', { ref: result.ref, kind: result.kind });
|
|
1105
|
-
}
|
|
1106
|
-
else if (result.required) {
|
|
1107
|
-
unresolvedCount++;
|
|
1108
|
-
trackEvent('skill.ref.unresolved', { ref: result.ref, kind: result.kind, status: result.status });
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
if (unresolvedCount > 0) {
|
|
1112
|
-
logErr(`Warning: ${unresolvedCount} required semantic ref(s) could not be resolved.`);
|
|
1113
|
-
}
|
|
1114
|
-
// Projection preserves ref tokens as portable refs — resolvedMap is validation/telemetry only.
|
|
1115
|
-
}
|
|
1116
|
-
// Compute projection hash per projected DB asset (DEC-K): sha256 of the normalized resolved body.
|
|
1117
|
-
const HASH_TRAILER_REGEX = /^<!--\s*pb-hash:.*-->\s*$/gm;
|
|
1118
|
-
const TIMESTAMP_REGEX = /^<!--\s*pb-generated-at:.*-->\s*$/gm;
|
|
1119
|
-
for (const rawAsset of projectableDbAssets) {
|
|
1120
|
-
const resolvedAsset = [...dbSkills, ...dbRules].find((a) => a.sourcePath === `db:${rawAsset.entryId}`);
|
|
1121
|
-
if (!resolvedAsset)
|
|
1122
|
-
continue;
|
|
1123
|
-
try {
|
|
1124
|
-
// Build the projected body (what will be written to disk)
|
|
1125
|
-
const projectedBody = resolvedAsset.body;
|
|
1126
|
-
// Normalize: strip existing hash/timestamp trailers, convert to LF
|
|
1127
|
-
const normalized = projectedBody
|
|
1128
|
-
.replace(HASH_TRAILER_REGEX, '')
|
|
1129
|
-
.replace(TIMESTAMP_REGEX, '')
|
|
1130
|
-
.replace(/\r\n/g, '\n')
|
|
1131
|
-
.replace(/\r/g, '\n')
|
|
1132
|
-
.trimEnd();
|
|
1133
|
-
// Compute sha256 hash
|
|
1134
|
-
const hash = createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
1135
|
-
const hashTrailer = `<!-- pb-hash: sha256:${hash} -->`;
|
|
1136
|
-
// Append hash trailer to the projected body
|
|
1137
|
-
resolvedAsset.body = `${normalized}\n${hashTrailer}`;
|
|
1138
|
-
dbProjectionHashes.set(rawAsset.entryId, {
|
|
1139
|
-
hash: `sha256:${hash}`,
|
|
1140
|
-
assetKind: rawAsset.assetKind,
|
|
1141
|
-
});
|
|
1142
|
-
// Drift detection: compare against last known hash
|
|
1143
|
-
if (rawAsset.lastProjectedHash && rawAsset.lastProjectedHash !== `sha256:${hash}`) {
|
|
1144
|
-
trackEvent('skill.drift.detected', {
|
|
1145
|
-
entryId: rawAsset.entryId,
|
|
1146
|
-
assetKind: rawAsset.assetKind,
|
|
1147
|
-
});
|
|
1148
|
-
log(`Drift detected for asset ${rawAsset.entryId} — projecting updated version.`);
|
|
1149
|
-
}
|
|
1150
|
-
trackEvent('skill.projection.succeeded', {
|
|
1151
|
-
entryId: rawAsset.entryId,
|
|
1152
|
-
assetKind: rawAsset.assetKind,
|
|
1153
|
-
});
|
|
1154
|
-
}
|
|
1155
|
-
catch (err) {
|
|
1156
|
-
trackEvent('skill.projection.failed', {
|
|
1157
|
-
entryId: rawAsset.entryId,
|
|
1158
|
-
assetKind: rawAsset.assetKind,
|
|
1159
|
-
});
|
|
1160
|
-
logErr(`Warning: projection failed for ${rawAsset.entryId} — ${err instanceof Error ? err.message : String(err)}`);
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
738
|
+
// WP-776 S3f: the DB-first setup-asset sourcing pipeline (setup.listAssetsForUser +
|
|
739
|
+
// setup.fetchAssetBody + setup.resolveSemanticRefs + setup.ingestSetupAssetWithBody, plus the
|
|
740
|
+
// personal-asset dormancy sync and per-asset projection-hash/drift-detection bookkeeping around
|
|
741
|
+
// it) is deleted along with the rest of the setup-assets system — DEC-1771 (delete, never hide).
|
|
742
|
+
// The filesystem read path below (readCanonicalSkills/readCanonicalRules) was always the
|
|
743
|
+
// fallback when the DB source was unavailable; it is now the only source.
|
|
744
|
+
const allSkills = readCanonicalSkills(pbDir);
|
|
745
|
+
const manualRules = readCanonicalRules(pbDir);
|
|
1164
746
|
// 5a. Optionally fetch and merge Chain-derived rules (--generate flag)
|
|
1165
747
|
let chainRulesStats = null;
|
|
1166
748
|
let chainGaps = [];
|
|
@@ -1215,7 +797,8 @@ export async function runHandshake(options = {}) {
|
|
|
1215
797
|
}
|
|
1216
798
|
allSkills.push(...personalSkills);
|
|
1217
799
|
}
|
|
1218
|
-
// 5d. Load method registry (WP-310 E4) — only when manifest is present.
|
|
800
|
+
// 5d. Load method registry (WP-310 E4) — only when manifest is present. Unrelated to the
|
|
801
|
+
// setup-assets system (WP-329's own "methods" Chain collection, no setup.* coupling) — kept.
|
|
1219
802
|
let registrySource;
|
|
1220
803
|
let registryStale;
|
|
1221
804
|
if (manifest) {
|
|
@@ -1337,117 +920,9 @@ export async function runHandshake(options = {}) {
|
|
|
1337
920
|
const filesWritten = [];
|
|
1338
921
|
const filesSkipped = [];
|
|
1339
922
|
const previewPlan = [];
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
const personalAssets = dbAssetRows.filter((asset) => asset.scope === 'personal' &&
|
|
1344
|
-
!asset.disabledByOwner &&
|
|
1345
|
-
(asset.assetKind === 'skill' || asset.assetKind === 'rule' || asset.assetKind === 'hook'));
|
|
1346
|
-
for (const asset of personalAssets) {
|
|
1347
|
-
// WP-428 S2 (Finding #5): skip fetch-failed assets — no empty-body authoring file, no lastProjectedHash update.
|
|
1348
|
-
if (bodyFetchFailedEntryIds.has(asset.entryId))
|
|
1349
|
-
continue;
|
|
1350
|
-
const authoringPath = setupAuthoringPath(cwd, asset);
|
|
1351
|
-
if (!authoringPath)
|
|
1352
|
-
continue;
|
|
1353
|
-
// relative() for correct cross-platform path computation (string-replace mis-fires on non-'/' cwd separators).
|
|
1354
|
-
const relativeAuthoringPath = relative(cwd, authoringPath);
|
|
1355
|
-
const bodyHash = setupAuthoringAssetHash(asset);
|
|
1356
|
-
const authoringExists = existsSync(authoringPath);
|
|
1357
|
-
const tracked = authoringSyncState.assets[asset.entryId];
|
|
1358
|
-
const trackedHere = tracked?.path === relativeAuthoringPath;
|
|
1359
|
-
const trackedMatchesServer = Boolean(trackedHere && asset.lastProjectedHash && tracked?.hash === asset.lastProjectedHash);
|
|
1360
|
-
if (!authoringExists && asset.lastProjectedHash && trackedHere && trackedMatchesServer) {
|
|
1361
|
-
try {
|
|
1362
|
-
const dormantResult = await kernelCall('setup.markPersonalSetupAssetDormantFromSync', { entryId: asset.entryId, expectedLastProjectedHash: tracked.hash });
|
|
1363
|
-
if (dormantResult.action === 'conflict') {
|
|
1364
|
-
syncDriftTensToFire.push(`Repo authoring deletion skipped for ${asset.entryId}; server baseline changed during sync, so writeback was deferred until the next DB read.`);
|
|
1365
|
-
logErr(`Warning: authoring deletion for ${asset.entryId} was not applied because the DB baseline changed during sync.`);
|
|
1366
|
-
deferredAuthoringBaselineEntryIds.add(asset.entryId);
|
|
1367
|
-
continue;
|
|
1368
|
-
}
|
|
1369
|
-
else {
|
|
1370
|
-
syncDriftTensToFire.push(`Repo authoring file was deleted for ${asset.entryId}; caller-owned personal setup asset marked dormant.`);
|
|
1371
|
-
log(`Setup authoring deletion: ${asset.entryId} marked dormant.`);
|
|
1372
|
-
delete authoringSyncState.assets[asset.entryId];
|
|
1373
|
-
authoringSyncStateChanged = true;
|
|
1374
|
-
continue;
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
catch (err) {
|
|
1378
|
-
logErr(`Warning: could not mark ${asset.entryId} dormant after authoring deletion — ${err instanceof Error ? err.message : String(err)}`);
|
|
1379
|
-
continue;
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
else if (!authoringExists && asset.lastProjectedHash && trackedHere) {
|
|
1383
|
-
syncDriftTensToFire.push(`Repo authoring deletion skipped for ${asset.entryId}; local sync baseline is stale, so DB writeback wins.`);
|
|
1384
|
-
logErr(`Warning: authoring deletion for ${asset.entryId} was not applied because the DB baseline changed.`);
|
|
1385
|
-
}
|
|
1386
|
-
const nextAuthoringContent = renderSetupAuthoringFile(asset);
|
|
1387
|
-
if (authoringExists) {
|
|
1388
|
-
const parsed = parseSetupAuthoringFrontmatter(readFileSync(authoringPath, 'utf8'));
|
|
1389
|
-
const fileHash = setupAuthoringAssetHash({
|
|
1390
|
-
entryId: parsed.frontmatterId?.trim() || asset.entryId,
|
|
1391
|
-
name: parsed.name || asset.name,
|
|
1392
|
-
description: parsed.description,
|
|
1393
|
-
body: parsed.body,
|
|
1394
|
-
assetKind: asset.assetKind,
|
|
1395
|
-
triggers: parsed.triggers,
|
|
1396
|
-
semanticRefs: parsed.semanticRefs,
|
|
1397
|
-
});
|
|
1398
|
-
if (fileHash === bodyHash) {
|
|
1399
|
-
if (asset.lastProjectedHash !== bodyHash) {
|
|
1400
|
-
await kernelCall('setup.updateLastProjectedHash', {
|
|
1401
|
-
entryId: asset.entryId,
|
|
1402
|
-
hash: bodyHash,
|
|
1403
|
-
}).catch(() => null);
|
|
1404
|
-
}
|
|
1405
|
-
authoringSyncState.assets[asset.entryId] = { path: relativeAuthoringPath, hash: bodyHash };
|
|
1406
|
-
authoringSyncStateChanged = true;
|
|
1407
|
-
continue;
|
|
1408
|
-
}
|
|
1409
|
-
if (!asset.lastProjectedHash) {
|
|
1410
|
-
syncDriftTensToFire.push(`Repo authoring file differs from DB for ${asset.entryId} at ${authoringPath} with no shared baseline; DB writeback skipped.`);
|
|
1411
|
-
logErr(`Warning: setup authoring drift for ${asset.entryId}; DB writeback skipped because no shared baseline exists.`);
|
|
1412
|
-
continue;
|
|
1413
|
-
}
|
|
1414
|
-
if (fileHash !== asset.lastProjectedHash &&
|
|
1415
|
-
bodyHash !== asset.lastProjectedHash) {
|
|
1416
|
-
syncDriftTensToFire.push(`Repo authoring file won setup sync conflict for ${asset.entryId} at ${authoringPath}.`);
|
|
1417
|
-
logErr(`Warning: repo authoring file won sync conflict for ${asset.entryId}; DB writeback skipped.`);
|
|
1418
|
-
continue;
|
|
1419
|
-
}
|
|
1420
|
-
if (fileHash !== asset.lastProjectedHash) {
|
|
1421
|
-
syncDriftTensToFire.push(`Repo authoring file differs from DB baseline for ${asset.entryId} at ${authoringPath}; DB writeback skipped.`);
|
|
1422
|
-
logErr(`Warning: setup authoring drift for ${asset.entryId}; DB writeback skipped.`);
|
|
1423
|
-
continue;
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
try {
|
|
1427
|
-
assertSetupWritePath(authoringPath, perimeterManifest);
|
|
1428
|
-
mkdirSync(dirname(authoringPath), { recursive: true });
|
|
1429
|
-
writeFileSync(authoringPath, nextAuthoringContent);
|
|
1430
|
-
filesWritten.push(relativeAuthoringPath);
|
|
1431
|
-
await kernelCall('setup.updateLastProjectedHash', {
|
|
1432
|
-
entryId: asset.entryId,
|
|
1433
|
-
hash: bodyHash,
|
|
1434
|
-
}).catch(() => null);
|
|
1435
|
-
authoringSyncState.assets[asset.entryId] = { path: relativeAuthoringPath, hash: bodyHash };
|
|
1436
|
-
authoringSyncStateChanged = true;
|
|
1437
|
-
}
|
|
1438
|
-
catch (err) {
|
|
1439
|
-
logErr(`Warning: could not write setup authoring file for ${asset.entryId} — ${err instanceof Error ? err.message : String(err)}`);
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
if (authoringSyncStateChanged) {
|
|
1443
|
-
try {
|
|
1444
|
-
saveAuthoringSyncState(pbDir, authoringSyncState);
|
|
1445
|
-
}
|
|
1446
|
-
catch (err) {
|
|
1447
|
-
logErr(`Warning: could not persist setup authoring sync state — ${err instanceof Error ? err.message : String(err)}`);
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
923
|
+
// WP-776 S3f: the personal-setup-asset authoring sync block (setup.markPersonalSetupAssetDormantFromSync
|
|
924
|
+
// + setup.updateLastProjectedHash) is deleted along with the DB-asset sourcing pipeline above —
|
|
925
|
+
// there is no DB-sourced personal asset any more to reconcile against .productbrain authoring files.
|
|
1451
926
|
const writes = [
|
|
1452
927
|
...(contextContent ? [{ path: join(cwd, '.productbrain', 'context.md'), relative: '.productbrain/context.md', content: contextContent, dirs: join(cwd, '.productbrain'), isAdapter: false }] : []),
|
|
1453
928
|
{ path: join(cwd, '.productbrain', 'briefing.md'), relative: '.productbrain/briefing.md', content: briefingContent, isAdapter: false },
|
|
@@ -1529,17 +1004,16 @@ export async function runHandshake(options = {}) {
|
|
|
1529
1004
|
});
|
|
1530
1005
|
}
|
|
1531
1006
|
// 7a. WP-379 S5b: find leftover projections (apply mode) — REPORT-ONLY (coordinator ruling, PR
|
|
1532
|
-
// #912 round 10), see findLeftoverProjections's own header.
|
|
1533
|
-
//
|
|
1007
|
+
// #912 round 10), see findLeftoverProjections's own header. WP-776 S3f: gated on writeMode
|
|
1008
|
+
// alone now — the DB source (dbQuerySucceeded) is gone, so activeAssetNames comes from the
|
|
1009
|
+
// filesystem-sourced allSkills/allRules (which already include the personal layer, merged in
|
|
1010
|
+
// above) instead of dbAssetRows.
|
|
1534
1011
|
let leftoverFiles = [];
|
|
1535
1012
|
let leftoverFilesTruncated = false;
|
|
1536
|
-
if (writeMode
|
|
1537
|
-
// Personal-layer names: merged into allSkills/allRules below but absent from dbAssetRows —
|
|
1538
|
-
// without this an already-projected personal asset reads as a false-positive leftover.
|
|
1013
|
+
if (writeMode) {
|
|
1539
1014
|
const activeAssetNames = [
|
|
1540
|
-
...
|
|
1541
|
-
...
|
|
1542
|
-
...personalRules.map((r) => r.name),
|
|
1015
|
+
...allSkills.map((s) => s.name),
|
|
1016
|
+
...allRules.map((r) => r.name),
|
|
1543
1017
|
];
|
|
1544
1018
|
// Nested .cursor/skills scan gets its OWN canon: canonicalSkills is SKILL-kind only and
|
|
1545
1019
|
// shouldEmitToTarget(_, 'cursor') respects per-skill target routing.
|
|
@@ -1568,18 +1042,8 @@ export async function runHandshake(options = {}) {
|
|
|
1568
1042
|
}
|
|
1569
1043
|
});
|
|
1570
1044
|
const userOwnedSkipped = [];
|
|
1571
|
-
const projectedHashUpdates = new Map();
|
|
1572
1045
|
const cleanBucketPaths = [];
|
|
1573
1046
|
const tamperedBucket = [];
|
|
1574
|
-
const recordProjectedHash = (entryId) => {
|
|
1575
|
-
if (!applyMode || !entryId)
|
|
1576
|
-
return;
|
|
1577
|
-
if (deferredAuthoringBaselineEntryIds.has(entryId))
|
|
1578
|
-
return;
|
|
1579
|
-
const projection = dbProjectionHashes.get(entryId);
|
|
1580
|
-
if (projection)
|
|
1581
|
-
projectedHashUpdates.set(entryId, projection.hash);
|
|
1582
|
-
};
|
|
1583
1047
|
// TEN-2155: region augmentation context + symlink dedupe — codexActive gates the AGENTS.md skills-index pointer.
|
|
1584
1048
|
const regionCtx = { codexActive: allowedTargets.has('codex') };
|
|
1585
1049
|
const malformedRegionPaths = [];
|
|
@@ -1723,7 +1187,6 @@ export async function runHandshake(options = {}) {
|
|
|
1723
1187
|
expectedHash: drift.expectedHash,
|
|
1724
1188
|
actualHash: drift.actualHash,
|
|
1725
1189
|
dirs: w.dirs,
|
|
1726
|
-
dbAssetEntryId: w.dbAssetEntryId,
|
|
1727
1190
|
});
|
|
1728
1191
|
// Do NOT write — defer until prompt/refusal resolves the file.
|
|
1729
1192
|
continue;
|
|
@@ -1746,27 +1209,16 @@ export async function runHandshake(options = {}) {
|
|
|
1746
1209
|
const currentNormalized = normalizeHandshakeContentForComparison(current);
|
|
1747
1210
|
if (nextNormalized === currentNormalized) {
|
|
1748
1211
|
filesSkipped.push({ path: w.relative, reason: 'unchanged' });
|
|
1749
|
-
recordProjectedHash(w.dbAssetEntryId);
|
|
1750
1212
|
continue;
|
|
1751
1213
|
}
|
|
1752
1214
|
}
|
|
1753
1215
|
writeFileSync(w.path, resolvedContent);
|
|
1754
1216
|
filesWritten.push(w.relative);
|
|
1755
|
-
recordProjectedHash(w.dbAssetEntryId);
|
|
1756
|
-
}
|
|
1757
|
-
if (projectedHashUpdates.size > 0) {
|
|
1758
|
-
const updates = [...projectedHashUpdates];
|
|
1759
|
-
const results = await Promise.allSettled(updates.map(([entryId, hash]) => kernelCall('setup.updateLastProjectedHash', { entryId, hash })));
|
|
1760
|
-
results.forEach((result, index) => {
|
|
1761
|
-
if (result.status === 'rejected') {
|
|
1762
|
-
const [entryId] = updates[index];
|
|
1763
|
-
trackEvent('skill.projection.failed', {
|
|
1764
|
-
entryId,
|
|
1765
|
-
reason: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
1766
|
-
});
|
|
1767
|
-
}
|
|
1768
|
-
});
|
|
1769
1217
|
}
|
|
1218
|
+
// WP-776 S3f: the projected-hash flush back to the DB (setup.updateLastProjectedHash) is
|
|
1219
|
+
// deleted along with the rest of the setup-assets gateway routes — there is no DB-sourced
|
|
1220
|
+
// asset any more whose lastProjectedHash needs updating (dbAssetEntryId is always undefined
|
|
1221
|
+
// now that allSkills/allRules are filesystem-sourced).
|
|
1770
1222
|
// Runs AFTER the hash flush (malformed files were never written, nothing to record). TEN-2155:
|
|
1771
1223
|
// non-interactive apply refuses on a malformed region — gated on applyMode per STD-263 vi.
|
|
1772
1224
|
if (applyMode && malformedRegionPaths.length > 0 && (options.noPrompt || !process.stdout.isTTY)) {
|
|
@@ -1777,9 +1229,15 @@ export async function runHandshake(options = {}) {
|
|
|
1777
1229
|
});
|
|
1778
1230
|
}
|
|
1779
1231
|
// ── WP-421 S3: tampered-bucket resolution (doneWhen #17), apply mode only — headless (--no-prompt
|
|
1780
|
-
// / non-TTY) refuses
|
|
1781
|
-
//
|
|
1782
|
-
|
|
1232
|
+
// / non-TTY) refuses, exit non-zero. WP-776 S3d/S3f: the setup-assets system this resolution used
|
|
1233
|
+
// to offer as an "adopt" alternative (capture tampered edits as a personal-scoped draft via
|
|
1234
|
+
// setup.ingestSetupAsset) is deleted, and with it the only OTHER option besides overwriting —
|
|
1235
|
+
// per DEC-1771 (delete, never hide) there is no shim standing in for that capability. Revert
|
|
1236
|
+
// (re-project canonical content) is the only resolution left; this is a real capability loss
|
|
1237
|
+
// for anyone who was relying on "adopt" to preserve hand-edits, named here rather than silently
|
|
1238
|
+
// dropped. The refusal receipt (`setup.recordTamperRefusal`) is deleted along with the rest of
|
|
1239
|
+
// the setup-assets gateway routes — headless mode still refuses and reports, it just no longer
|
|
1240
|
+
// writes an audit row for the refusal.
|
|
1783
1241
|
const revertedTamperedPaths = [];
|
|
1784
1242
|
if (writeMode && tamperedBucket.length > 0) {
|
|
1785
1243
|
const headless = options.noPrompt === true || !process.stdout.isTTY;
|
|
@@ -1787,7 +1245,7 @@ export async function runHandshake(options = {}) {
|
|
|
1787
1245
|
// ── Headless refusal path (doneWhen #17) ────────────────────────────────
|
|
1788
1246
|
logErr('');
|
|
1789
1247
|
logErr(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
|
|
1790
|
-
logErr('Headless mode (--no-prompt or no TTY) cannot
|
|
1248
|
+
logErr('Headless mode (--no-prompt or no TTY) cannot confirm an overwrite — refusing.');
|
|
1791
1249
|
logErr('');
|
|
1792
1250
|
const refusedTamperedFiles = tamperedBucket.map((t) => ({
|
|
1793
1251
|
path: t.relative,
|
|
@@ -1801,26 +1259,6 @@ export async function runHandshake(options = {}) {
|
|
|
1801
1259
|
}
|
|
1802
1260
|
logErr('');
|
|
1803
1261
|
logErr('Re-run interactively to resolve, or use --force to overwrite (data loss).');
|
|
1804
|
-
// Write the kind='transition' setup_receipt row. Fail-open on the
|
|
1805
|
-
// network/auth side: if the row cannot be written, we still exit non-zero
|
|
1806
|
-
// (the audit trail is best-effort; refusal is mandatory).
|
|
1807
|
-
try {
|
|
1808
|
-
const manifestStatus = readManifestStatus(pbDir);
|
|
1809
|
-
await kernelCall('setup.recordTamperRefusal', {
|
|
1810
|
-
mode: manifestStatus.mode,
|
|
1811
|
-
refusedTamperedFiles,
|
|
1812
|
-
});
|
|
1813
|
-
trackEvent('setup.transition.refused', {
|
|
1814
|
-
fileCount: refusedTamperedFiles.length,
|
|
1815
|
-
mode: manifestStatus.mode,
|
|
1816
|
-
});
|
|
1817
|
-
}
|
|
1818
|
-
catch (err) {
|
|
1819
|
-
trackEvent('setup.transition.refused.write_failed', {
|
|
1820
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1821
|
-
});
|
|
1822
|
-
logErr(`Warning: could not record transition receipt — ${err instanceof Error ? err.message : String(err)}`);
|
|
1823
|
-
}
|
|
1824
1262
|
// Surface the report counts before exit (visibility for CI logs).
|
|
1825
1263
|
if (!quiet) {
|
|
1826
1264
|
const refusalReport = {
|
|
@@ -1850,59 +1288,20 @@ export async function runHandshake(options = {}) {
|
|
|
1850
1288
|
// Exit non-zero per #17.
|
|
1851
1289
|
process.exit(1);
|
|
1852
1290
|
}
|
|
1853
|
-
// ── Interactive path:
|
|
1291
|
+
// ── Interactive path: confirm, then revert every tampered file ─────────────
|
|
1854
1292
|
log('');
|
|
1855
1293
|
log(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
|
|
1856
|
-
log('
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
message: 'Apply to all:',
|
|
1868
|
-
options: [
|
|
1869
|
-
{ value: 'adopt', label: 'Adopt all — capture each as a personal-scoped draft' },
|
|
1870
|
-
{ value: 'revert', label: 'Revert all — overwrite with canonical content' },
|
|
1871
|
-
],
|
|
1872
|
-
});
|
|
1873
|
-
batchChoice = choice === 'adopt' ? 'adopt-all' : 'revert-all';
|
|
1874
|
-
}
|
|
1875
|
-
}
|
|
1876
|
-
for (const tamper of tamperedBucket) {
|
|
1877
|
-
// Reverse-map the projection path back to the canonical authoring path.
|
|
1878
|
-
const reverse = canonicalPathForAnySurface(tamper.relative);
|
|
1879
|
-
const canonicalHint = reverse
|
|
1880
|
-
? `Canonical authoring path: ${reverse.canonicalPath}`
|
|
1881
|
-
: (() => {
|
|
1882
|
-
// Telemetry + fallback message per surfaces/telemetry.ts.
|
|
1883
|
-
reportReverseMapMissing({ surface: 'unknown', projectionPath: tamper.relative }, logErr);
|
|
1884
|
-
return `Canonical authoring path: ${getReverseMapFallbackMessage()}`;
|
|
1885
|
-
})();
|
|
1886
|
-
log('');
|
|
1887
|
-
log(`Tampered: ${tamper.relative}`);
|
|
1888
|
-
log(` expected: ${tamper.expectedHash}`);
|
|
1889
|
-
log(` actual: ${tamper.actualHash}`);
|
|
1890
|
-
log(` ${canonicalHint}`);
|
|
1891
|
-
let action;
|
|
1892
|
-
if (batchChoice === 'adopt-all')
|
|
1893
|
-
action = 'adopt';
|
|
1894
|
-
else if (batchChoice === 'revert-all')
|
|
1895
|
-
action = 'revert';
|
|
1896
|
-
else {
|
|
1897
|
-
action = await promptSelect({
|
|
1898
|
-
message: 'Adopt or revert?',
|
|
1899
|
-
options: [
|
|
1900
|
-
{ value: 'adopt', label: 'Adopt — capture this as a personal-scoped setup_asset draft' },
|
|
1901
|
-
{ value: 'revert', label: 'Revert — overwrite with canonical content' },
|
|
1902
|
-
],
|
|
1903
|
-
});
|
|
1904
|
-
}
|
|
1905
|
-
if (action === 'revert') {
|
|
1294
|
+
log('These will be REVERTED (overwritten with canonical content) — there is no adopt/draft path any more.');
|
|
1295
|
+
const proceed = await promptConfirm({
|
|
1296
|
+
message: `Overwrite ${tamperedBucket.length} tampered file(s) with canonical content?`,
|
|
1297
|
+
initialValue: false,
|
|
1298
|
+
});
|
|
1299
|
+
if (proceed) {
|
|
1300
|
+
for (const tamper of tamperedBucket) {
|
|
1301
|
+
log('');
|
|
1302
|
+
log(`Reverting: ${tamper.relative}`);
|
|
1303
|
+
log(` expected: ${tamper.expectedHash}`);
|
|
1304
|
+
log(` actual: ${tamper.actualHash}`);
|
|
1906
1305
|
// Re-project canonical content over the tampered file.
|
|
1907
1306
|
// WP-436 S3: resolve vocab tokens before writing (all tampered files are adapters).
|
|
1908
1307
|
if (tamper.dirs)
|
|
@@ -1910,106 +1309,26 @@ export async function runHandshake(options = {}) {
|
|
|
1910
1309
|
assertSetupWritePath(tamper.path, perimeterManifest);
|
|
1911
1310
|
writeFileSync(tamper.path, replaceVocabTokens(tamper.content, handshakeVocabCtx));
|
|
1912
1311
|
revertedTamperedPaths.push(tamper.relative);
|
|
1913
|
-
recordProjectedHash(tamper.dbAssetEntryId);
|
|
1914
|
-
trackEvent('setup.tampered.reverted', { path: tamper.relative });
|
|
1915
|
-
}
|
|
1916
|
-
else {
|
|
1917
|
-
// Adopt: capture the tampered content as a personal-scoped draft (DEC-953: personal
|
|
1918
|
-
// scope = push, no fork required). Best-effort — on failure the file stays untouched and
|
|
1919
|
-
// a warning is logged. Does NOT revert; preserves the edits AND records them as a draft.
|
|
1920
|
-
const draftName = basename(tamper.relative).replace(/\.(md|mdc)$/, '') + ' (adopted)';
|
|
1921
|
-
try {
|
|
1922
|
-
const tamperedContent = readFileSync(tamper.path, 'utf8');
|
|
1923
|
-
const session = readSession();
|
|
1924
|
-
const caller = session ? kernelCallWithSession : kernelCall;
|
|
1925
|
-
await caller('setup.ingestSetupAsset', {
|
|
1926
|
-
entryId: `SETUP-ADOPTED-${Date.now()}-${draftName.replace(/\s+/g, '-')}`,
|
|
1927
|
-
name: draftName,
|
|
1928
|
-
// WP-421 S3
|
|
1929
|
-
description: `Adopted from tampered projection at ${tamper.relative}.`,
|
|
1930
|
-
body: tamperedContent,
|
|
1931
|
-
assetKind: tamper.relative.includes('/skills/') ? 'skill' : 'rule',
|
|
1932
|
-
triggers: [],
|
|
1933
|
-
semanticRefs: [],
|
|
1934
|
-
});
|
|
1935
|
-
adoptedTamperedPaths.push(tamper.relative);
|
|
1936
|
-
trackEvent('setup.tampered.adopted', { path: tamper.relative });
|
|
1937
|
-
}
|
|
1938
|
-
catch (err) {
|
|
1939
|
-
logErr(`Warning: could not adopt ${tamper.relative} as draft — ${err instanceof Error ? err.message : String(err)}`);
|
|
1940
|
-
trackEvent('setup.tampered.adopt_failed', {
|
|
1941
|
-
path: tamper.relative,
|
|
1942
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1943
|
-
});
|
|
1944
|
-
}
|
|
1945
1312
|
}
|
|
1946
1313
|
}
|
|
1314
|
+
else {
|
|
1315
|
+
logErr('Skipped — tampered files left as-is; re-run to resolve.');
|
|
1316
|
+
process.exit(1); // doneWhen #17: never exit 0 with a tampered file unresolved (codex review round 3)
|
|
1317
|
+
}
|
|
1947
1318
|
}
|
|
1948
1319
|
// DEC-1690: a dormant (gate-failed) asset is simply not projected — dbAssets already comes
|
|
1949
1320
|
// from the server's activeAssets list. The WP-379 S4 / WP-426 E4 on-disk marker/rename/restore
|
|
1950
1321
|
// machinery (and its .authoring-sync.json registries) is retired: nothing to reconcile.
|
|
1951
1322
|
// 8. User-owned files left untouched are NOT drift (TEN-2150) — a marker-less adapter-path
|
|
1952
1323
|
// file is the user's own; handshake never wrote it, so no "sync", no draft TEN, just a benign
|
|
1953
|
-
// "Skipped:" surfaced via report.userOwnedSkipped
|
|
1954
|
-
if (syncDriftTensToFire.length > 0) {
|
|
1955
|
-
const session = readSession();
|
|
1956
|
-
if (session) {
|
|
1957
|
-
for (const driftDescription of syncDriftTensToFire) {
|
|
1958
|
-
kernelCallWithSession('chain.createEntry', {
|
|
1959
|
-
collectionSlug: 'tensions',
|
|
1960
|
-
name: 'TEN: setup authoring sync drift — repo wins',
|
|
1961
|
-
status: 'draft',
|
|
1962
|
-
data: { kind: 'drift', description: driftDescription },
|
|
1963
|
-
sessionId: session.sessionId,
|
|
1964
|
-
createdBy: `agent:${session.sessionId}`,
|
|
1965
|
-
}, { retryOnRateLimit: false }).catch((err) => {
|
|
1966
|
-
// WP-513 round 3 (P2): still fail-open, but surfaced — an unowned rung-2 refusal must not silently drop the drift evidence.
|
|
1967
|
-
logErr(`Warning: could not persist drift TEN (${driftDescription.slice(0, 60)}…) — ${err instanceof Error ? err.message : String(err)}`);
|
|
1968
|
-
});
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1324
|
+
// "Skipped:" surfaced via report.userOwnedSkipped.
|
|
1972
1325
|
// 8. Case-collision and dormant-drift TENs are gone (coordinator ruling, PR #912 round 10) —
|
|
1973
1326
|
// they existed only to audit automated deletions that no longer happen; `report.leftoverFiles`
|
|
1974
1327
|
// below is the single reporting surface now.
|
|
1975
|
-
//
|
|
1976
|
-
//
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
const caller = session ? kernelCallWithSession : kernelCall;
|
|
1980
|
-
try {
|
|
1981
|
-
const currentState = await caller('setup.getCurrentSetupState', {});
|
|
1982
|
-
const fromMode = currentState?.effectiveMode ?? 'observe';
|
|
1983
|
-
if (fromMode !== manifestStatus.mode) {
|
|
1984
|
-
await caller('setup.recordTransition', {
|
|
1985
|
-
fromMode, toMode: manifestStatus.mode, parseStatus: manifestStatus.parseStatus,
|
|
1986
|
-
surfaces: manifestStatus.surfaces,
|
|
1987
|
-
lock: manifestStatus.lock,
|
|
1988
|
-
});
|
|
1989
|
-
if (modeRank(manifestStatus.mode) < modeRank(fromMode)) {
|
|
1990
|
-
trackEvent('setup.transition.lowered', { fromMode, toMode: manifestStatus.mode });
|
|
1991
|
-
}
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
catch (err) {
|
|
1995
|
-
trackEvent('setup.transition.write_failed', { error: err instanceof Error ? err.message : String(err) });
|
|
1996
|
-
logErr(`Warning: could not record setup transition — ${err instanceof Error ? err.message : String(err)}`);
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
if (writeMode) {
|
|
2000
|
-
const session = readSession();
|
|
2001
|
-
const caller = session ? kernelCallWithSession : kernelCall;
|
|
2002
|
-
try {
|
|
2003
|
-
const receiptResult = await caller('setup.materializeSetup', {});
|
|
2004
|
-
if (receiptResult?.assetCount > 0) {
|
|
2005
|
-
log(`Setup receipt: ${receiptResult.assetCount} asset(s) recorded.`);
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
catch (err) {
|
|
2009
|
-
trackEvent('setup.receipt.write_failed', { error: err instanceof Error ? err.message : String(err) });
|
|
2010
|
-
logErr(`Warning: could not write setup receipt — ${err instanceof Error ? err.message : String(err)}`);
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
1328
|
+
// WP-776 S3f: the setup-transition receipt (setup.getCurrentSetupState / setup.recordTransition)
|
|
1329
|
+
// and the setup materialization receipt (setup.materializeSetup) are deleted along with the
|
|
1330
|
+
// rest of the setup-assets gateway routes — both were fail-open advisory writes recording
|
|
1331
|
+
// authority-mode transitions and materialized-asset counts for a system that no longer exists.
|
|
2013
1332
|
// 9. Report
|
|
2014
1333
|
const report = {
|
|
2015
1334
|
filesWritten,
|
|
@@ -2031,9 +1350,10 @@ export async function runHandshake(options = {}) {
|
|
|
2031
1350
|
userOwnedSkipped: userOwnedSkipped.length > 0 ? userOwnedSkipped : undefined,
|
|
2032
1351
|
// WP-421 S3: three-bucket drift report (doneWhen #17). PB-managed-clean is
|
|
2033
1352
|
// the count of files whose marker + hash matched. Tampered files were
|
|
2034
|
-
// resolved (
|
|
1353
|
+
// resolved (reverted) above and are reported separately. WP-776 S3d/S3f:
|
|
1354
|
+
// adoptedTamperedPaths is gone — "adopt" is no longer an option (see the
|
|
1355
|
+
// tampered-bucket resolution comment above).
|
|
2035
1356
|
managedCleanCount: cleanBucketPaths.length > 0 ? cleanBucketPaths.length : undefined,
|
|
2036
|
-
adoptedTamperedPaths: adoptedTamperedPaths.length > 0 ? adoptedTamperedPaths : undefined,
|
|
2037
1357
|
revertedTamperedPaths: revertedTamperedPaths.length > 0 ? revertedTamperedPaths : undefined,
|
|
2038
1358
|
// REPORT-ONLY leftover-file listing (coordinator ruling, PR #912 round 10) — the human removes these.
|
|
2039
1359
|
leftoverFiles: leftoverFiles.length > 0 ? leftoverFiles : undefined,
|
|
@@ -2047,6 +1367,7 @@ export async function runHandshake(options = {}) {
|
|
|
2047
1367
|
// skipped/user-owned files without re-deriving the skip logic (TEN-2107).
|
|
2048
1368
|
return report;
|
|
2049
1369
|
}
|
|
2050
|
-
// WP-426 E3/E4: exported test-only surface (not part of the public CLI API).
|
|
2051
|
-
|
|
1370
|
+
// WP-426 E3/E4: exported test-only surface (not part of the public CLI API). setupAuthoringPath
|
|
1371
|
+
// and loadAuthoringSyncState are gone with the setup-authoring sync machinery (WP-776 S3d/S3f).
|
|
1372
|
+
export const __test = { MARKER };
|
|
2052
1373
|
//# sourceMappingURL=handshake.js.map
|