@bli-cockpit/cli 0.2.17 → 0.2.19
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/adapters/attribution-core.js +71 -13
- package/dist/adapters/claude-attribution.js +7 -40
- package/dist/adapters/codex-attribution.js +2 -22
- package/dist/adapters/raw-evidence.js +164 -59
- package/dist/autostart.js +10 -2
- package/dist/commands/backfill.js +126 -30
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +12 -13
- package/dist/health-detail.js +30 -7
- package/dist/raw-evidence-attribution-policy.js +13 -7
- package/package.json +1 -1
|
@@ -94,9 +94,20 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
94
94
|
collectionRoots,
|
|
95
95
|
pathExists: options.pathExists,
|
|
96
96
|
});
|
|
97
|
-
if (transcriptFallback)
|
|
98
|
-
return
|
|
99
|
-
|
|
97
|
+
if (transcriptFallback) {
|
|
98
|
+
return attachApprovedRootWorkspace({
|
|
99
|
+
outcome: transcriptFallback,
|
|
100
|
+
signals,
|
|
101
|
+
collectionRoots,
|
|
102
|
+
approvedRecordedPaths,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return attachApprovedRootWorkspace({
|
|
106
|
+
outcome: skipped(terminalReasonForRecordedPaths(approvedRecordedPaths, options.pathExists)),
|
|
107
|
+
signals,
|
|
108
|
+
collectionRoots,
|
|
109
|
+
approvedRecordedPaths,
|
|
110
|
+
});
|
|
100
111
|
}
|
|
101
112
|
const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
|
|
102
113
|
? "cwd_outside_scanned_worktrees"
|
|
@@ -129,20 +140,67 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
129
140
|
signals,
|
|
130
141
|
worktrees: scopedWorktrees,
|
|
131
142
|
});
|
|
132
|
-
if (fallback)
|
|
133
|
-
return
|
|
143
|
+
if (fallback) {
|
|
144
|
+
return attachApprovedRootWorkspace({
|
|
145
|
+
outcome: fallback,
|
|
146
|
+
signals,
|
|
147
|
+
collectionRoots,
|
|
148
|
+
approvedRecordedPaths,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
134
151
|
}
|
|
152
|
+
return attachApprovedRootWorkspace({
|
|
153
|
+
outcome: {
|
|
154
|
+
state: "ambiguous",
|
|
155
|
+
reason: best.score - secondBestScore < ATTRIBUTION_MIN_MARGIN
|
|
156
|
+
? "multiple_worktrees_close_scores"
|
|
157
|
+
: "signal_score_below_threshold",
|
|
158
|
+
signals: best.matched,
|
|
159
|
+
attribution_score: score,
|
|
160
|
+
path_score: pathScore,
|
|
161
|
+
worktree: null,
|
|
162
|
+
},
|
|
163
|
+
signals,
|
|
164
|
+
collectionRoots,
|
|
165
|
+
approvedRecordedPaths,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Gives an inside-boundary terminal attribution a durable upload context
|
|
170
|
+
* without pretending the session belongs to any discovered repository. The
|
|
171
|
+
* original state, reason, signals, and scores are deliberately untouched: the
|
|
172
|
+
* synthetic folder is transport identity, not stronger attribution.
|
|
173
|
+
*/
|
|
174
|
+
function attachApprovedRootWorkspace(options) {
|
|
175
|
+
if (options.outcome.worktree || options.approvedRecordedPaths.length === 0) {
|
|
176
|
+
return options.outcome;
|
|
177
|
+
}
|
|
178
|
+
const approvedRoot = deepestApprovedRootForRecordedPaths(options.approvedRecordedPaths, options.collectionRoots);
|
|
179
|
+
if (!approvedRoot)
|
|
180
|
+
return options.outcome;
|
|
181
|
+
const repoLabel = basenameForAttributionPath(approvedRoot);
|
|
135
182
|
return {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
183
|
+
...options.outcome,
|
|
184
|
+
worktree: {
|
|
185
|
+
requested_path: approvedRoot,
|
|
186
|
+
repo_root: approvedRoot,
|
|
187
|
+
repo_label: repoLabel,
|
|
188
|
+
repo_fingerprint: repoFingerprintFromLocalRoot(approvedRoot),
|
|
189
|
+
repo_origin_url: null,
|
|
190
|
+
branch: options.signals.branches[0] ?? "unknown",
|
|
191
|
+
head_sha: options.signals.headShas[0] ?? null,
|
|
192
|
+
worktree_label: repoLabel,
|
|
193
|
+
worktree_fingerprint: stableWorktreeFingerprint(approvedRoot),
|
|
194
|
+
worktree_is_primary: true,
|
|
195
|
+
},
|
|
144
196
|
};
|
|
145
197
|
}
|
|
198
|
+
function deepestApprovedRootForRecordedPaths(recordedPaths, collectionRoots) {
|
|
199
|
+
const candidates = collectionRoots.filter((root) => recordedPaths.some((recordedPath) => isPathWithin(recordedPath, root)));
|
|
200
|
+
candidates.sort((a, b) => pathDepth(b) - pathDepth(a) ||
|
|
201
|
+
normalizedAttributionPathKey(a).localeCompare(normalizedAttributionPathKey(b)));
|
|
202
|
+
return candidates[0] ? resolveAttributionPath(candidates[0]) : null;
|
|
203
|
+
}
|
|
146
204
|
function unattributed(reason) {
|
|
147
205
|
return {
|
|
148
206
|
state: "unattributed",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES
|
|
1
|
+
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import { createReadStream, existsSync } from "node:fs";
|
|
@@ -84,7 +84,7 @@ export async function scanAndAttributeClaudeSessions(options) {
|
|
|
84
84
|
}
|
|
85
85
|
async function discoverClaudeSessions(projectsDir, cutoffMs) {
|
|
86
86
|
const sessions = [];
|
|
87
|
-
|
|
87
|
+
const projectDirsSkipped = 0;
|
|
88
88
|
let projectDirReadFailedCount = 0;
|
|
89
89
|
let sessionStatFailedCount = 0;
|
|
90
90
|
let sidecarDirReadFailedCount = 0;
|
|
@@ -106,14 +106,6 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
|
|
|
106
106
|
for (const projectEntry of projectEntries) {
|
|
107
107
|
if (!projectEntry.isDirectory())
|
|
108
108
|
continue;
|
|
109
|
-
// A project slug encodes the full cwd, so a repo named e.g.
|
|
110
|
-
// `credentials-service` poisons its slug. Never read inside such a dir, but
|
|
111
|
-
// count the skip so a whole repo silently missing is visible (D12). The
|
|
112
|
-
// slug itself is a local path encoding and never printed.
|
|
113
|
-
if (isSecretLikePath(projectEntry.name)) {
|
|
114
|
-
projectDirsSkipped += 1;
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
109
|
const projectDir = path.join(projectsDir, projectEntry.name);
|
|
118
110
|
let sessionEntries;
|
|
119
111
|
try {
|
|
@@ -237,18 +229,14 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
|
237
229
|
sidecars_capped: session.sidecarsCapped,
|
|
238
230
|
sidecar_files: [],
|
|
239
231
|
};
|
|
240
|
-
if (isSecretLikePath(fileName)) {
|
|
241
|
-
return skippedResult(base, "secret_like_file_name");
|
|
242
|
-
}
|
|
243
232
|
if (session.mainByteSize === 0) {
|
|
244
233
|
return skippedResult(base, "empty_file");
|
|
245
234
|
}
|
|
246
235
|
let signals;
|
|
247
|
-
let secretLike;
|
|
248
236
|
const oversized = session.mainByteSize > CLAUDE_SESSION_MAX_FILE_BYTES;
|
|
249
237
|
if (oversized) {
|
|
250
|
-
// D7: stream the oversized main so signals
|
|
251
|
-
// bytes themselves are
|
|
238
|
+
// D7: stream the oversized main so metadata signals remain available. The
|
|
239
|
+
// bytes themselves are not uploaded until the collection ceiling changes.
|
|
252
240
|
let streamed;
|
|
253
241
|
try {
|
|
254
242
|
streamed = await streamMainSignals(session.mainFile);
|
|
@@ -259,7 +247,6 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
|
259
247
|
return skippedResult(base, "file_read_failed");
|
|
260
248
|
}
|
|
261
249
|
signals = streamed.signals;
|
|
262
|
-
secretLike = streamed.secretLike;
|
|
263
250
|
base.content_hash_sha256 = streamed.contentHash;
|
|
264
251
|
base.byte_size = streamed.byteSize;
|
|
265
252
|
base.main_file_oversized = true;
|
|
@@ -280,10 +267,6 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
|
280
267
|
return skippedResult(base, "empty_file");
|
|
281
268
|
}
|
|
282
269
|
signals = extractClaudeSessionSignals(content);
|
|
283
|
-
secretLike = containsSecretLikeContent(content);
|
|
284
|
-
}
|
|
285
|
-
if (secretLike) {
|
|
286
|
-
return skippedResult(base, "secret_like_content_guard");
|
|
287
270
|
}
|
|
288
271
|
const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
|
|
289
272
|
if (metaSessionId) {
|
|
@@ -317,7 +300,7 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
|
317
300
|
extraSignals.push("main_file_oversized");
|
|
318
301
|
if (isSchemaDriftSuspected(signals))
|
|
319
302
|
extraSignals.push("schema_drift_suspected");
|
|
320
|
-
const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state) && outcome.worktree
|
|
303
|
+
const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
|
|
321
304
|
? await collectSidecarDiagnostics(session.sidecars, outcome.worktree)
|
|
322
305
|
: session.sidecars.map((sidecar) => ({
|
|
323
306
|
local_path: sidecar.local_path,
|
|
@@ -355,10 +338,6 @@ async function collectSidecarDiagnostics(sidecars, worktree) {
|
|
|
355
338
|
content_hash_sha256: null,
|
|
356
339
|
skipped_reason: null,
|
|
357
340
|
};
|
|
358
|
-
if (isSecretLikePath(sidecar.file_name)) {
|
|
359
|
-
out.push({ ...entry, skipped_reason: "secret_like_file_name" });
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
341
|
if (sidecar.byteSize > CLAUDE_SESSION_MAX_FILE_BYTES) {
|
|
363
342
|
out.push({ ...entry, skipped_reason: "file_too_large" });
|
|
364
343
|
continue;
|
|
@@ -372,10 +351,6 @@ async function collectSidecarDiagnostics(sidecars, worktree) {
|
|
|
372
351
|
continue;
|
|
373
352
|
}
|
|
374
353
|
const content = raw.toString("utf8");
|
|
375
|
-
if (containsSecretLikeContent(content)) {
|
|
376
|
-
out.push({ ...entry, skipped_reason: "secret_like_content_guard" });
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
354
|
const sidecarCwds = extractClaudeSessionSignals(content).cwds;
|
|
380
355
|
if (sidecarCwds.length > 0 &&
|
|
381
356
|
!sidecarCwds.some((cwd) => isPathWithin(cwd, worktree.repo_root))) {
|
|
@@ -483,25 +458,21 @@ function isSchemaDriftSuspected(signals) {
|
|
|
483
458
|
async function streamMainSignals(filePath) {
|
|
484
459
|
const accumulator = createSignalAccumulator();
|
|
485
460
|
const hash = crypto.createHash("sha256");
|
|
486
|
-
let secretLike = false;
|
|
487
461
|
let oversizedLinesSkipped = 0;
|
|
488
462
|
let byteSize = 0;
|
|
489
463
|
let pending = "";
|
|
490
464
|
let pendingTruncated = false;
|
|
491
465
|
const flushCompletedLine = (lineText) => {
|
|
492
466
|
if (pendingTruncated) {
|
|
493
|
-
// The line exceeded the buffer;
|
|
467
|
+
// The line exceeded the metadata buffer; count it and continue. The raw
|
|
468
|
+
// evidence collector performs full-file sanitization before upload.
|
|
494
469
|
oversizedLinesSkipped += 1;
|
|
495
|
-
if (containsSecretLikeContent(pending))
|
|
496
|
-
secretLike = true;
|
|
497
470
|
pending = "";
|
|
498
471
|
pendingTruncated = false;
|
|
499
472
|
return;
|
|
500
473
|
}
|
|
501
474
|
const full = pending + lineText;
|
|
502
475
|
pending = "";
|
|
503
|
-
if (containsSecretLikeContent(full))
|
|
504
|
-
secretLike = true;
|
|
505
476
|
accumulator.processLine(full);
|
|
506
477
|
};
|
|
507
478
|
// StringDecoder buffers an incomplete multibyte char across chunk boundaries
|
|
@@ -547,7 +518,6 @@ async function streamMainSignals(filePath) {
|
|
|
547
518
|
});
|
|
548
519
|
return {
|
|
549
520
|
signals: accumulator.finalize(),
|
|
550
|
-
secretLike,
|
|
551
521
|
oversizedLinesSkipped,
|
|
552
522
|
contentHash: hash.digest("hex"),
|
|
553
523
|
byteSize,
|
|
@@ -578,9 +548,6 @@ function skippedResult(base, reason) {
|
|
|
578
548
|
function stringOrNull(value) {
|
|
579
549
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
580
550
|
}
|
|
581
|
-
function isSecretLikePath(value) {
|
|
582
|
-
return SECRET_FILE_SEGMENT_PATTERN.test(value);
|
|
583
|
-
}
|
|
584
551
|
function sha256(value) {
|
|
585
552
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
586
553
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { SECRET_FILE_SEGMENT_PATTERN, } from "@bli-cockpit/telemetry-core";
|
|
2
1
|
import { createReadStream, existsSync } from "node:fs";
|
|
3
2
|
import crypto from "node:crypto";
|
|
4
3
|
import fs from "node:fs/promises";
|
|
@@ -72,17 +71,13 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
|
|
|
72
71
|
const out = [];
|
|
73
72
|
let directoryReadFailedCount = 0;
|
|
74
73
|
let statFailedCount = 0;
|
|
75
|
-
|
|
74
|
+
const secretPathSkippedCount = 0;
|
|
76
75
|
const stack = Array.isArray(dir) ? [...dir] : [dir];
|
|
77
76
|
const seenFiles = new Set();
|
|
78
77
|
while (stack.length > 0) {
|
|
79
78
|
const current = stack.pop();
|
|
80
79
|
if (!current)
|
|
81
80
|
continue;
|
|
82
|
-
if (isSecretLikePath(current)) {
|
|
83
|
-
secretPathSkippedCount += 1;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
81
|
let entries;
|
|
87
82
|
try {
|
|
88
83
|
entries = await fs.readdir(current, { withFileTypes: true });
|
|
@@ -95,18 +90,9 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
|
|
|
95
90
|
for (const entry of entries) {
|
|
96
91
|
const full = path.join(current, entry.name);
|
|
97
92
|
if (entry.isDirectory()) {
|
|
98
|
-
|
|
99
|
-
if (isSecretLikePath(full)) {
|
|
100
|
-
secretPathSkippedCount += 1;
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
stack.push(full);
|
|
104
|
-
}
|
|
93
|
+
stack.push(full);
|
|
105
94
|
continue;
|
|
106
95
|
}
|
|
107
|
-
// Secret-like file NAMES stay in the list so attribution can record a
|
|
108
|
-
// "skipped" observation with a reason label (the content is never read)
|
|
109
|
-
// instead of silently dropping the session.
|
|
110
96
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
|
|
111
97
|
continue;
|
|
112
98
|
let stat;
|
|
@@ -168,9 +154,6 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
|
|
|
168
154
|
byte_size: file.byteSize,
|
|
169
155
|
content_hash_sha256: null,
|
|
170
156
|
};
|
|
171
|
-
if (isSecretLikePath(fileName)) {
|
|
172
|
-
return skippedResult(base, "secret_like_file_name");
|
|
173
|
-
}
|
|
174
157
|
let read;
|
|
175
158
|
try {
|
|
176
159
|
read = await readCodexMetadataSignals(file.file);
|
|
@@ -352,7 +335,4 @@ function skippedResult(base, reason) {
|
|
|
352
335
|
function addString(target, value) {
|
|
353
336
|
if (typeof value === "string" && value.trim())
|
|
354
337
|
target.add(value.trim());
|
|
355
|
-
}
|
|
356
|
-
function isSecretLikePath(value) {
|
|
357
|
-
return SECRET_FILE_SEGMENT_PATTERN.test(value);
|
|
358
338
|
}
|
|
@@ -383,13 +383,12 @@ function recordClaudeAttributionCompleteness(collection, scan, selectedPaths) {
|
|
|
383
383
|
}
|
|
384
384
|
function recordAttributionResultSkips(collection, source, results, selectedPaths) {
|
|
385
385
|
for (const result of results) {
|
|
386
|
-
// A selected
|
|
387
|
-
//
|
|
386
|
+
// A selected session is being collected by this pass. A live-sync-safe
|
|
387
|
+
// synthetic target is also not a skip when another workspace pack owns it.
|
|
388
388
|
if (result.state === "attributed" ||
|
|
389
|
-
(result.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
isLiveRawEvidenceSyncAttribution(result.state, result.reason))))) {
|
|
389
|
+
selectedPaths.has(result.file_path) ||
|
|
390
|
+
(result.worktree !== null &&
|
|
391
|
+
isLiveRawEvidenceSyncAttribution(result.state, result.reason, true))) {
|
|
393
392
|
continue;
|
|
394
393
|
}
|
|
395
394
|
const reason = result.state === "skipped"
|
|
@@ -497,6 +496,9 @@ async function collectClaudeJsonlFiles(collection, sessions) {
|
|
|
497
496
|
}
|
|
498
497
|
for (const sidecar of session.sidecar_files) {
|
|
499
498
|
const stem = path.basename(sidecar.local_path).replace(/\.jsonl$/i, "");
|
|
499
|
+
const safeSidecarId = isSecretLikePath(stem)
|
|
500
|
+
? "redacted-file-name"
|
|
501
|
+
: safeKeySegment(stem);
|
|
500
502
|
const sidecarAccepted = await collectOneEvidenceFile(collection, {
|
|
501
503
|
filePath: sidecar.local_path,
|
|
502
504
|
kind: "claude_jsonl_sidecar",
|
|
@@ -504,16 +506,16 @@ async function collectClaudeJsonlFiles(collection, sessions) {
|
|
|
504
506
|
mediaType: "application/jsonl",
|
|
505
507
|
maxFileBytes: CLAUDE_MAX_COLLECT_FILE_BYTES,
|
|
506
508
|
redactedSummary: "Raw Claude Code subagent transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
|
|
507
|
-
contentAddress: (hash16) => `claude/${safeKeySegment(sessionId)}/subagents/${
|
|
509
|
+
contentAddress: (hash16) => `claude/${safeKeySegment(sessionId)}/subagents/${safeSidecarId}-${hash16}.jsonl`,
|
|
508
510
|
});
|
|
509
511
|
if (sidecarAccepted) {
|
|
510
512
|
await collectAgentImagesFromTranscript(collection, {
|
|
511
513
|
filePath: sidecar.local_path,
|
|
512
514
|
source: "claude_code",
|
|
513
515
|
sessionId,
|
|
514
|
-
sidecarId:
|
|
516
|
+
sidecarId: safeSidecarId,
|
|
515
517
|
kind: "claude_image_attachment",
|
|
516
|
-
contentAddress: (hash16, extension) => `claude/${safeKeySegment(sessionId)}/subagents/${
|
|
518
|
+
contentAddress: (hash16, extension) => `claude/${safeKeySegment(sessionId)}/subagents/${safeSidecarId}/images/${hash16}.${extension}`,
|
|
517
519
|
});
|
|
518
520
|
}
|
|
519
521
|
}
|
|
@@ -598,14 +600,11 @@ async function collectOneAgentImageFile(collection, options) {
|
|
|
598
600
|
*/
|
|
599
601
|
async function collectOneEvidenceFile(collection, options) {
|
|
600
602
|
const fileName = path.basename(options.filePath);
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
});
|
|
607
|
-
return false;
|
|
608
|
-
}
|
|
603
|
+
const secretLikeFileName = isSecretLikePath(fileName);
|
|
604
|
+
const evidenceLabel = secretLikeFileName
|
|
605
|
+
? "[REDACTED_FILE_NAME]"
|
|
606
|
+
: fileName;
|
|
607
|
+
const packedFileName = secretLikeFileName ? "redacted-file.jsonl" : fileName;
|
|
609
608
|
if (options.maxFileBytes) {
|
|
610
609
|
let stat;
|
|
611
610
|
try {
|
|
@@ -614,7 +613,7 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
614
613
|
catch {
|
|
615
614
|
collection.skipped.push({
|
|
616
615
|
kind: options.kind,
|
|
617
|
-
label:
|
|
616
|
+
label: evidenceLabel,
|
|
618
617
|
reason: "file_read_failed",
|
|
619
618
|
});
|
|
620
619
|
return false;
|
|
@@ -623,7 +622,7 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
623
622
|
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
624
623
|
collection.skipped.push({
|
|
625
624
|
kind: options.kind,
|
|
626
|
-
label:
|
|
625
|
+
label: evidenceLabel,
|
|
627
626
|
reason: "file_too_large",
|
|
628
627
|
});
|
|
629
628
|
return false;
|
|
@@ -636,7 +635,7 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
636
635
|
catch {
|
|
637
636
|
collection.skipped.push({
|
|
638
637
|
kind: options.kind,
|
|
639
|
-
label:
|
|
638
|
+
label: evidenceLabel,
|
|
640
639
|
reason: "file_read_failed",
|
|
641
640
|
});
|
|
642
641
|
return false;
|
|
@@ -645,7 +644,7 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
645
644
|
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
646
645
|
collection.skipped.push({
|
|
647
646
|
kind: options.kind,
|
|
648
|
-
label:
|
|
647
|
+
label: evidenceLabel,
|
|
649
648
|
reason: "file_too_large",
|
|
650
649
|
});
|
|
651
650
|
return false;
|
|
@@ -654,21 +653,21 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
654
653
|
text: raw.toString("utf8"),
|
|
655
654
|
originalBytes: raw,
|
|
656
655
|
redactedFields: [`${options.kind}.body`],
|
|
656
|
+
secretLikeFileName,
|
|
657
657
|
});
|
|
658
|
-
if (sanitized.status === "blocked") {
|
|
659
|
-
collection.skipped.push({
|
|
660
|
-
kind: options.kind,
|
|
661
|
-
label: fileName,
|
|
662
|
-
reason: sanitized.reason,
|
|
663
|
-
});
|
|
664
|
-
return false;
|
|
665
|
-
}
|
|
666
658
|
if (sanitized.status === "redacted") {
|
|
667
659
|
collection.redacted.push({
|
|
668
660
|
kind: options.kind,
|
|
669
|
-
label:
|
|
661
|
+
label: evidenceLabel,
|
|
670
662
|
redaction: sanitized.redaction,
|
|
663
|
+
completenessLabel: sanitized.completenessLabel,
|
|
671
664
|
});
|
|
665
|
+
console.error("[raw-evidence] text evidence sanitized", JSON.stringify({
|
|
666
|
+
kind: options.kind,
|
|
667
|
+
mode: sanitized.completenessLabel,
|
|
668
|
+
original_bytes: raw.byteLength,
|
|
669
|
+
uploaded_bytes: sanitized.bytes.byteLength,
|
|
670
|
+
}));
|
|
672
671
|
}
|
|
673
672
|
const evidenceBytes = sanitized.bytes;
|
|
674
673
|
const redaction = sanitized.redaction;
|
|
@@ -676,7 +675,7 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
676
675
|
if (collection.skipContentHashes.has(contentHash)) {
|
|
677
676
|
collection.reused.push({
|
|
678
677
|
kind: options.kind,
|
|
679
|
-
label:
|
|
678
|
+
label: evidenceLabel,
|
|
680
679
|
content_hash_sha256: contentHash,
|
|
681
680
|
codex_session_id: options.sessionId,
|
|
682
681
|
});
|
|
@@ -687,13 +686,13 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
687
686
|
markBudgetCapApplied(collection, deferReason);
|
|
688
687
|
collection.skipped.push({
|
|
689
688
|
kind: options.kind,
|
|
690
|
-
label:
|
|
689
|
+
label: evidenceLabel,
|
|
691
690
|
reason: deferReason,
|
|
692
691
|
});
|
|
693
692
|
return false;
|
|
694
693
|
}
|
|
695
694
|
collection.index.value += 1;
|
|
696
|
-
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${
|
|
695
|
+
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${packedFileName}`);
|
|
697
696
|
const destination = path.join(collection.filesDir, path.basename(relativePath));
|
|
698
697
|
await fs.writeFile(destination, evidenceBytes, { mode: 0o600 });
|
|
699
698
|
await chmodPrivate(destination, 0o600);
|
|
@@ -728,30 +727,138 @@ function admitToBudget(budget, byteLength) {
|
|
|
728
727
|
budget.remainingBytes -= byteLength;
|
|
729
728
|
return null;
|
|
730
729
|
}
|
|
731
|
-
function sanitizeTextEvidenceForUpload(options) {
|
|
730
|
+
export function sanitizeTextEvidenceForUpload(options) {
|
|
732
731
|
const originalBytes = options.originalBytes ?? Buffer.from(options.text, "utf8");
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
732
|
+
const secretLikeContent = containsSecretLikeContent(options.text);
|
|
733
|
+
try {
|
|
734
|
+
const redactionResult = (options.redact ?? redactSecretLikeContent)(options.text, {
|
|
735
|
+
appliedBy: "local_collector",
|
|
736
|
+
redactedFields: options.redactedFields,
|
|
737
|
+
});
|
|
738
|
+
if (options.secretLikeFileName) {
|
|
739
|
+
const sanitizedText = redactionResult.redacted
|
|
740
|
+
? redactionResult.text
|
|
741
|
+
: options.text;
|
|
742
|
+
const safeText = containsSecretLikeContent(sanitizedText)
|
|
743
|
+
? "[REDACTED_LINE:secret_redaction_failed]\n"
|
|
744
|
+
: sanitizedText;
|
|
745
|
+
const sanitizedBytes = Buffer.from(safeText, "utf8");
|
|
746
|
+
return {
|
|
747
|
+
status: "redacted",
|
|
748
|
+
bytes: sanitizedBytes,
|
|
749
|
+
redaction: withRedactionContentMetadata(redactionResult.metadata ??
|
|
750
|
+
fallbackRedactionMetadata({
|
|
751
|
+
ruleId: "secret_like_file_name",
|
|
752
|
+
originalText: options.text,
|
|
753
|
+
redactedFields: options.redactedFields,
|
|
754
|
+
fullContentRedacted: false,
|
|
755
|
+
}), { originalBytes, sanitizedBytes }),
|
|
756
|
+
completenessLabel: "secret_like_name_masked",
|
|
757
|
+
};
|
|
740
758
|
}
|
|
741
|
-
|
|
759
|
+
if (secretLikeContent) {
|
|
760
|
+
const redaction = redactionResult.metadata ??
|
|
761
|
+
fallbackRedactionMetadata({
|
|
762
|
+
ruleId: "secret_like_content_guard",
|
|
763
|
+
originalText: options.text,
|
|
764
|
+
redactedFields: options.redactedFields,
|
|
765
|
+
});
|
|
766
|
+
let maskedText = maskSecretBearingLines(options.text, redaction);
|
|
767
|
+
if (containsSecretLikeContent(maskedText)) {
|
|
768
|
+
maskedText = "[REDACTED_LINE:secret_redaction_failed]\n";
|
|
769
|
+
}
|
|
770
|
+
const sanitizedBytes = Buffer.from(maskedText, "utf8");
|
|
771
|
+
return {
|
|
772
|
+
status: "redacted",
|
|
773
|
+
bytes: sanitizedBytes,
|
|
774
|
+
redaction: withRedactionContentMetadata(redaction, {
|
|
775
|
+
originalBytes,
|
|
776
|
+
sanitizedBytes,
|
|
777
|
+
}),
|
|
778
|
+
completenessLabel: "secret_content_masked",
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
if (redactionResult.redacted) {
|
|
782
|
+
const sanitizedBytes = Buffer.from(redactionResult.text, "utf8");
|
|
783
|
+
return {
|
|
784
|
+
status: "redacted",
|
|
785
|
+
bytes: sanitizedBytes,
|
|
786
|
+
redaction: withRedactionContentMetadata(redactionResult.metadata ??
|
|
787
|
+
fallbackRedactionMetadata({
|
|
788
|
+
ruleId: "secret_redaction_failed",
|
|
789
|
+
originalText: options.text,
|
|
790
|
+
redactedFields: options.redactedFields,
|
|
791
|
+
}), { originalBytes, sanitizedBytes }),
|
|
792
|
+
completenessLabel: "secret_content_masked",
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
return { status: "clean", bytes: originalBytes };
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
const sanitizedBytes = Buffer.from("[REDACTED_LINE:redaction_crashed_stubbed]\n", "utf8");
|
|
742
799
|
return {
|
|
743
800
|
status: "redacted",
|
|
744
801
|
bytes: sanitizedBytes,
|
|
745
|
-
redaction: withRedactionContentMetadata(
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
802
|
+
redaction: withRedactionContentMetadata(fallbackRedactionMetadata({
|
|
803
|
+
ruleId: "redaction_crashed_stubbed",
|
|
804
|
+
originalText: options.text,
|
|
805
|
+
redactedFields: options.redactedFields,
|
|
806
|
+
}), { originalBytes, sanitizedBytes }),
|
|
807
|
+
completenessLabel: "redaction_crashed_stubbed",
|
|
749
808
|
};
|
|
750
809
|
}
|
|
751
|
-
|
|
752
|
-
|
|
810
|
+
}
|
|
811
|
+
function maskSecretBearingLines(text, redaction) {
|
|
812
|
+
if (redaction.redacted_ranges.length === 0) {
|
|
813
|
+
return "[REDACTED_LINE:secret_like_content_guard]\n";
|
|
753
814
|
}
|
|
754
|
-
|
|
815
|
+
const segments = text.match(/[^\n]*(?:\n|$)/gu)?.filter(Boolean) ?? [];
|
|
816
|
+
let offset = 0;
|
|
817
|
+
return segments
|
|
818
|
+
.map((segment) => {
|
|
819
|
+
const start = offset;
|
|
820
|
+
const end = offset + segment.length;
|
|
821
|
+
offset = end;
|
|
822
|
+
const matched = redaction.redacted_ranges.find((range) => range.start < end && start < range.end);
|
|
823
|
+
if (!matched)
|
|
824
|
+
return segment;
|
|
825
|
+
const lineEnding = segment.endsWith("\r\n")
|
|
826
|
+
? "\r\n"
|
|
827
|
+
: segment.endsWith("\n")
|
|
828
|
+
? "\n"
|
|
829
|
+
: "";
|
|
830
|
+
return `[REDACTED_LINE:${matched.rule_id}]${lineEnding}`;
|
|
831
|
+
})
|
|
832
|
+
.join("");
|
|
833
|
+
}
|
|
834
|
+
function fallbackRedactionMetadata(options) {
|
|
835
|
+
const fullContentRedacted = options.fullContentRedacted !== false;
|
|
836
|
+
return {
|
|
837
|
+
schema_version: "raw-evidence-redaction.v1",
|
|
838
|
+
status: "sanitized",
|
|
839
|
+
mode: "deterministic_text_replacement",
|
|
840
|
+
applied_by: ["local_collector"],
|
|
841
|
+
rule_counts: [
|
|
842
|
+
{
|
|
843
|
+
rule_id: options.ruleId,
|
|
844
|
+
match_count: 1,
|
|
845
|
+
redacted_char_count: fullContentRedacted
|
|
846
|
+
? options.originalText.length
|
|
847
|
+
: 0,
|
|
848
|
+
},
|
|
849
|
+
],
|
|
850
|
+
secret_like_match_count: 1,
|
|
851
|
+
redacted_fields: options.redactedFields,
|
|
852
|
+
redacted_ranges: fullContentRedacted && options.originalText.length > 0
|
|
853
|
+
? [
|
|
854
|
+
{
|
|
855
|
+
start: 0,
|
|
856
|
+
end: options.originalText.length,
|
|
857
|
+
rule_id: options.ruleId,
|
|
858
|
+
},
|
|
859
|
+
]
|
|
860
|
+
: [],
|
|
861
|
+
};
|
|
755
862
|
}
|
|
756
863
|
async function collectGitDiffFiles(collection, repoRoot) {
|
|
757
864
|
const diffTargets = [
|
|
@@ -790,20 +897,18 @@ async function collectGitDiffFiles(collection, repoRoot) {
|
|
|
790
897
|
text: diff.stdout,
|
|
791
898
|
redactedFields: [`git_diff.${target.label}`],
|
|
792
899
|
});
|
|
793
|
-
if (sanitized.status === "blocked") {
|
|
794
|
-
collection.skipped.push({
|
|
795
|
-
kind: "git_diff",
|
|
796
|
-
label: target.label,
|
|
797
|
-
reason: sanitized.reason,
|
|
798
|
-
});
|
|
799
|
-
continue;
|
|
800
|
-
}
|
|
801
900
|
if (sanitized.status === "redacted") {
|
|
802
901
|
collection.redacted.push({
|
|
803
902
|
kind: "git_diff",
|
|
804
903
|
label: target.label,
|
|
805
904
|
redaction: sanitized.redaction,
|
|
905
|
+
completenessLabel: sanitized.completenessLabel,
|
|
806
906
|
});
|
|
907
|
+
console.error("[raw-evidence] git diff sanitized", JSON.stringify({
|
|
908
|
+
mode: sanitized.completenessLabel,
|
|
909
|
+
original_bytes: Buffer.byteLength(diff.stdout, "utf8"),
|
|
910
|
+
uploaded_bytes: sanitized.bytes.byteLength,
|
|
911
|
+
}));
|
|
807
912
|
}
|
|
808
913
|
const raw = sanitized.bytes;
|
|
809
914
|
const redaction = sanitized.redaction;
|
|
@@ -1059,7 +1164,7 @@ function makeEvidenceCompleteness(collection, options) {
|
|
|
1059
1164
|
const redactionCounts = new Map();
|
|
1060
1165
|
for (const redacted of collection.redacted) {
|
|
1061
1166
|
const ruleIds = redacted.redaction.rule_counts.map((rule) => rule.rule_id);
|
|
1062
|
-
const key = `${redacted.kind}:${redacted.
|
|
1167
|
+
const key = `${redacted.kind}:${redacted.completenessLabel}:${ruleIds.sort().join(",")}`;
|
|
1063
1168
|
const existing = redactionCounts.get(key);
|
|
1064
1169
|
if (existing) {
|
|
1065
1170
|
existing.count += 1;
|
|
@@ -1069,7 +1174,7 @@ function makeEvidenceCompleteness(collection, options) {
|
|
|
1069
1174
|
redactionCounts.set(key, {
|
|
1070
1175
|
source: redacted.kind,
|
|
1071
1176
|
status: "sanitized",
|
|
1072
|
-
mode: redacted.
|
|
1177
|
+
mode: redacted.completenessLabel,
|
|
1073
1178
|
count: 1,
|
|
1074
1179
|
rule_ids: [...new Set(ruleIds)].sort(),
|
|
1075
1180
|
});
|
package/dist/autostart.js
CHANGED
|
@@ -353,8 +353,16 @@ function renderWindowsRegistrationScript(options) {
|
|
|
353
353
|
`$taskName = ${powershellLiteral(options.taskName)}`,
|
|
354
354
|
`$syncScriptPath = ${powershellLiteral(options.scriptPath)}`,
|
|
355
355
|
"$powershellPath = Join-Path $PSHOME 'powershell.exe'",
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
// Windows PowerShell 5.1 passes a native argument's embedded quotes to
|
|
357
|
+
// CommandLineToArgvW unescaped, so a /TR value with bare quotes falls out
|
|
358
|
+
// of the quoted region at the first space inside a quoted path:
|
|
359
|
+
// `C:\Users\Brandon Chiem\...` reached schtasks as a stray `Chiem\...`
|
|
360
|
+
// argument and /Create exited 0x80004005 on every CLI version (BLI-2598).
|
|
361
|
+
// Escaping the embedded quotes as \" keeps the whole /TR value one
|
|
362
|
+
// argument; Task Scheduler still normalizes what it stores (BLI-2541),
|
|
363
|
+
// which the read-back validator already compares path-wise.
|
|
364
|
+
"$actionArgs = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \\\"' + $syncScriptPath + '\\\"'",
|
|
365
|
+
"$taskCommand = '\\\"' + $powershellPath + '\\\" ' + $actionArgs",
|
|
358
366
|
`& schtasks.exe /Create /TN $taskName /TR $taskCommand /SC MINUTE /MO ${options.intervalMinutes} /IT /RL LIMITED /F | Out-Null`,
|
|
359
367
|
"if ($LASTEXITCODE -ne 0) { throw \"schtasks /Create exited $LASTEXITCODE\" }",
|
|
360
368
|
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
@@ -8,6 +8,7 @@ import { defaultCodexSessionDirs, scanAndAttributeCodexSessions } from "../adapt
|
|
|
8
8
|
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
|
|
9
9
|
import { acquireBackfillLock } from "../backfill-lock.js";
|
|
10
10
|
import { BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, recordBackfillCursorObservations, recordBackfillScanCoverage, writeBackfillCompletionMarker, writeBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
11
|
+
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
11
12
|
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, startLocalWorkContext } from "../local-state.js";
|
|
12
13
|
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
13
14
|
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
@@ -87,6 +88,7 @@ export async function runBackfill(command, io) {
|
|
|
87
88
|
: await readBackfillCursor(paths);
|
|
88
89
|
const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
|
|
89
90
|
const cursor = scopedCursor.cursor;
|
|
91
|
+
const pointerlessTerminalSessions = await loadPointerlessTerminalSessions(paths, sources);
|
|
90
92
|
const scan = await scanBackfillSessions({
|
|
91
93
|
command,
|
|
92
94
|
homeDir: command.homeDir ?? os.homedir(),
|
|
@@ -95,6 +97,7 @@ export async function runBackfill(command, io) {
|
|
|
95
97
|
sources,
|
|
96
98
|
window,
|
|
97
99
|
cursor,
|
|
100
|
+
pointerlessTerminalSessions,
|
|
98
101
|
now,
|
|
99
102
|
});
|
|
100
103
|
for (const reason of worktreeDiscovery.incomplete_reasons) {
|
|
@@ -107,13 +110,6 @@ export async function runBackfill(command, io) {
|
|
|
107
110
|
scan.issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
|
|
108
111
|
a.reason.localeCompare(b.reason));
|
|
109
112
|
const guards = await countReadOnlyGuards(scan.candidates);
|
|
110
|
-
for (const candidate of scan.candidates) {
|
|
111
|
-
const permanentReason = guards.permanent_skip_reasons.get(candidateCursorKey(candidate));
|
|
112
|
-
if (!permanentReason)
|
|
113
|
-
continue;
|
|
114
|
-
candidate.state = "skipped";
|
|
115
|
-
candidate.reason = permanentReason;
|
|
116
|
-
}
|
|
117
113
|
scan.retryable_candidate_keys = new Set([
|
|
118
114
|
...scan.retryable_candidate_keys,
|
|
119
115
|
...guards.retryable_candidate_keys,
|
|
@@ -392,6 +388,14 @@ export async function runBackfill(command, io) {
|
|
|
392
388
|
? "session_report_ack_incomplete"
|
|
393
389
|
: report.reason;
|
|
394
390
|
}
|
|
391
|
+
if (reportAcknowledged && durableCandidateKeys.size > 0) {
|
|
392
|
+
await recordBackfillDurableSessionPointers({
|
|
393
|
+
paths,
|
|
394
|
+
candidates: scan.candidates,
|
|
395
|
+
syncResults,
|
|
396
|
+
now,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
395
399
|
// Raw evidence durability is necessary but not sufficient: the server must
|
|
396
400
|
// also acknowledge the session attribution rows before their historical
|
|
397
401
|
// cursor positions become irreversible.
|
|
@@ -672,8 +676,9 @@ async function scanBackfillSessions(options) {
|
|
|
672
676
|
...(codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
|
|
673
677
|
...(claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
|
|
674
678
|
]
|
|
675
|
-
.filter((candidate) => isAfterCursor(candidate, options.cursor)
|
|
676
|
-
.
|
|
679
|
+
.filter((candidate) => isAfterCursor(candidate, options.cursor) ||
|
|
680
|
+
isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
|
|
681
|
+
.sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
|
|
677
682
|
const candidates = allCandidates.slice(0, options.command.maxFiles ?? Number.MAX_SAFE_INTEGER);
|
|
678
683
|
const omittedCandidateCount = allCandidates.length - candidates.length;
|
|
679
684
|
const issues = await backfillScanIssues({
|
|
@@ -693,6 +698,52 @@ async function scanBackfillSessions(options) {
|
|
|
693
698
|
omitted_candidate_count: omittedCandidateCount,
|
|
694
699
|
};
|
|
695
700
|
}
|
|
701
|
+
async function loadPointerlessTerminalSessions(paths, sources) {
|
|
702
|
+
const [codexCursor, claudeCursor] = await Promise.all([
|
|
703
|
+
sources.includes("codex")
|
|
704
|
+
? readRawEvidenceCursor(paths)
|
|
705
|
+
: Promise.resolve(null),
|
|
706
|
+
sources.includes("claude_code")
|
|
707
|
+
? readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME })
|
|
708
|
+
: Promise.resolve(null),
|
|
709
|
+
]);
|
|
710
|
+
const terminalIds = (sessions) => new Set(Object.entries(sessions ?? {})
|
|
711
|
+
.filter(([, entry]) => isPointerlessTerminalCursorEntry(entry))
|
|
712
|
+
.map(([sessionId]) => sessionId));
|
|
713
|
+
const result = {
|
|
714
|
+
codex: terminalIds(codexCursor?.sessions),
|
|
715
|
+
claude_code: terminalIds(claudeCursor?.sessions),
|
|
716
|
+
};
|
|
717
|
+
const count = result.codex.size + result.claude_code.size;
|
|
718
|
+
if (count > 0) {
|
|
719
|
+
console.error("[backfill] pointer-less terminal sessions reopened", JSON.stringify({
|
|
720
|
+
count,
|
|
721
|
+
codex_count: result.codex.size,
|
|
722
|
+
claude_count: result.claude_code.size,
|
|
723
|
+
}));
|
|
724
|
+
}
|
|
725
|
+
return result;
|
|
726
|
+
}
|
|
727
|
+
function isPointerlessTerminalCursorEntry(entry) {
|
|
728
|
+
return (!entry.uploaded_object_key &&
|
|
729
|
+
(entry.state === "ambiguous" ||
|
|
730
|
+
entry.state === "unattributed" ||
|
|
731
|
+
entry.state === "skipped"));
|
|
732
|
+
}
|
|
733
|
+
function isPointerlessTerminalRetry(candidate, sessions) {
|
|
734
|
+
return sessions[candidate.source].has(candidate.session_id);
|
|
735
|
+
}
|
|
736
|
+
function compareBackfillCandidatesForRetry(a, b, sessions) {
|
|
737
|
+
const aRetry = isPointerlessTerminalRetry(a, sessions);
|
|
738
|
+
const bRetry = isPointerlessTerminalRetry(b, sessions);
|
|
739
|
+
if (aRetry !== bRetry)
|
|
740
|
+
return aRetry ? -1 : 1;
|
|
741
|
+
if (aRetry && bRetry) {
|
|
742
|
+
return (a.session_file_mtime_ms - b.session_file_mtime_ms ||
|
|
743
|
+
candidateCursorKey(a).localeCompare(candidateCursorKey(b)));
|
|
744
|
+
}
|
|
745
|
+
return compareBackfillCandidates(a, b);
|
|
746
|
+
}
|
|
696
747
|
function normalizeCodexCandidate(result) {
|
|
697
748
|
return {
|
|
698
749
|
source: "codex",
|
|
@@ -808,7 +859,7 @@ async function backfillScanIssues(options) {
|
|
|
808
859
|
}
|
|
809
860
|
add("backfill_max_files_applied", options.omittedCandidateCount, "selection");
|
|
810
861
|
add("candidate_file_read_failed", options.candidates.filter((candidate) => candidate.reason === "file_read_failed").length, "candidate");
|
|
811
|
-
add("candidate_worktree_unavailable", options.candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state) &&
|
|
862
|
+
add("candidate_worktree_unavailable", options.candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
812
863
|
candidate.worktree === null).length, "candidate");
|
|
813
864
|
add("claude_main_file_too_large", options.candidates.filter((candidate) => candidate.source === "claude_code" &&
|
|
814
865
|
candidate.claude?.main_file_oversized).length, "candidate");
|
|
@@ -830,7 +881,7 @@ function retryableCandidateKeys(candidates) {
|
|
|
830
881
|
return new Set(candidates
|
|
831
882
|
.filter((candidate) => candidate.reason === "file_read_failed" ||
|
|
832
883
|
candidate.reason === "repo_not_on_disk" ||
|
|
833
|
-
(isRawEvidenceUploadableAttributionState(candidate.state) &&
|
|
884
|
+
(isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
834
885
|
!candidate.worktree) ||
|
|
835
886
|
Boolean(candidate.claude?.main_file_oversized) ||
|
|
836
887
|
(candidate.claude?.sidecars_capped ?? 0) > 0 ||
|
|
@@ -872,7 +923,6 @@ function isMissingFsError(error) {
|
|
|
872
923
|
async function countReadOnlyGuards(candidates) {
|
|
873
924
|
const counts = new Map();
|
|
874
925
|
const retryableCandidateKeys = new Set();
|
|
875
|
-
const permanentSkipReasons = new Map();
|
|
876
926
|
for (const candidate of candidates) {
|
|
877
927
|
if (candidate.reason === "repo_not_on_disk") {
|
|
878
928
|
increment(counts, "repo_not_on_disk");
|
|
@@ -888,14 +938,10 @@ async function countReadOnlyGuards(candidates) {
|
|
|
888
938
|
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
889
939
|
continue;
|
|
890
940
|
}
|
|
891
|
-
if (!isRawEvidenceUploadableAttributionState(candidate.state))
|
|
941
|
+
if (!isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null))
|
|
892
942
|
continue;
|
|
893
943
|
try {
|
|
894
|
-
|
|
895
|
-
if (containsSecretLikeContent(raw)) {
|
|
896
|
-
increment(counts, "secret_like_content_guard");
|
|
897
|
-
permanentSkipReasons.set(candidateCursorKey(candidate), "secret_like_content_guard");
|
|
898
|
-
}
|
|
944
|
+
await fs.readFile(candidate.file_path);
|
|
899
945
|
}
|
|
900
946
|
catch {
|
|
901
947
|
increment(counts, "file_read_failed");
|
|
@@ -905,7 +951,6 @@ async function countReadOnlyGuards(candidates) {
|
|
|
905
951
|
return {
|
|
906
952
|
counts,
|
|
907
953
|
retryable_candidate_keys: retryableCandidateKeys,
|
|
908
|
-
permanent_skip_reasons: permanentSkipReasons,
|
|
909
954
|
};
|
|
910
955
|
}
|
|
911
956
|
function reasonCountsFor(candidates, guardCounts, scanIssues) {
|
|
@@ -918,11 +963,7 @@ function reasonCountsFor(candidates, guardCounts, scanIssues) {
|
|
|
918
963
|
for (const issue of scanIssues) {
|
|
919
964
|
counts.set(issue.reason, Math.max(counts.get(issue.reason) ?? 0, issue.count));
|
|
920
965
|
}
|
|
921
|
-
for (const required of [
|
|
922
|
-
"secret_like_content_guard",
|
|
923
|
-
"file_too_large",
|
|
924
|
-
"repo_not_on_disk",
|
|
925
|
-
]) {
|
|
966
|
+
for (const required of ["file_too_large", "repo_not_on_disk"]) {
|
|
926
967
|
counts.set(required, counts.get(required) ?? 0);
|
|
927
968
|
}
|
|
928
969
|
return [...counts.entries()]
|
|
@@ -936,8 +977,8 @@ function reasonCountsFor(candidates, guardCounts, scanIssues) {
|
|
|
936
977
|
function reasonClassification(reason) {
|
|
937
978
|
if (reason === "secret_like_content_guard" || reason === "secret_redaction_failed") {
|
|
938
979
|
return {
|
|
939
|
-
classification: "
|
|
940
|
-
note: "
|
|
980
|
+
classification: "retryable",
|
|
981
|
+
note: "historical guard result; collector now masks and retries",
|
|
941
982
|
};
|
|
942
983
|
}
|
|
943
984
|
if (reason === "file_too_large") {
|
|
@@ -988,7 +1029,7 @@ function reasonClassification(reason) {
|
|
|
988
1029
|
};
|
|
989
1030
|
}
|
|
990
1031
|
function uploadableCandidates(candidates) {
|
|
991
|
-
return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state) &&
|
|
1032
|
+
return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
992
1033
|
candidate.worktree);
|
|
993
1034
|
}
|
|
994
1035
|
function buildBackfillBatches(candidates) {
|
|
@@ -1157,7 +1198,7 @@ function buildBackfillSessionReport(options) {
|
|
|
1157
1198
|
? { upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED }
|
|
1158
1199
|
: {}),
|
|
1159
1200
|
}
|
|
1160
|
-
: isRawEvidenceUploadableAttributionState(candidate.state)
|
|
1201
|
+
: isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null)
|
|
1161
1202
|
? {
|
|
1162
1203
|
upload_state: "not_uploaded",
|
|
1163
1204
|
// BLI-2107: same rule as live sync — an attributed session with
|
|
@@ -1237,6 +1278,61 @@ function durableBackfillCandidateKeys(batch, sync) {
|
|
|
1237
1278
|
}
|
|
1238
1279
|
return durable;
|
|
1239
1280
|
}
|
|
1281
|
+
async function recordBackfillDurableSessionPointers(options) {
|
|
1282
|
+
const durableObjectBySession = new Map();
|
|
1283
|
+
for (const sync of options.syncResults) {
|
|
1284
|
+
for (const outcome of sync.raw_evidence_outcomes) {
|
|
1285
|
+
if (!outcome.codex_session_id ||
|
|
1286
|
+
(outcome.kind !== "codex_jsonl" && outcome.kind !== "claude_jsonl") ||
|
|
1287
|
+
(outcome.upload_state !== "uploaded" &&
|
|
1288
|
+
outcome.upload_state !== "reused_existing") ||
|
|
1289
|
+
!outcome.object_key) {
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
const source = outcome.kind === "codex_jsonl" ? "codex" : "claude_code";
|
|
1293
|
+
durableObjectBySession.set(`${source}:${outcome.codex_session_id}`, outcome.object_key);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
let recordedCount = 0;
|
|
1297
|
+
for (const source of ["codex", "claude_code"]) {
|
|
1298
|
+
const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
|
|
1299
|
+
const cursor = await readRawEvidenceCursor(options.paths, { filename });
|
|
1300
|
+
let changed = false;
|
|
1301
|
+
for (const candidate of options.candidates) {
|
|
1302
|
+
if (candidate.source !== source)
|
|
1303
|
+
continue;
|
|
1304
|
+
const objectKey = durableObjectBySession.get(`${source}:${candidate.session_id}`);
|
|
1305
|
+
const prior = cursor.sessions[candidate.session_id];
|
|
1306
|
+
if (!objectKey || !prior || prior.uploaded_object_key)
|
|
1307
|
+
continue;
|
|
1308
|
+
cursor.sessions[candidate.session_id] = {
|
|
1309
|
+
...prior,
|
|
1310
|
+
file_hash_sha256: candidate.content_hash_sha256,
|
|
1311
|
+
file_mtime_ms: candidate.session_file_mtime_ms,
|
|
1312
|
+
byte_size: candidate.byte_size,
|
|
1313
|
+
byte_offset: candidate.byte_size,
|
|
1314
|
+
state: candidate.state,
|
|
1315
|
+
reason: candidate.reason,
|
|
1316
|
+
worktree_fingerprint: candidate.worktree?.worktree_fingerprint ?? null,
|
|
1317
|
+
uploaded_object_key: objectKey,
|
|
1318
|
+
uploaded_at: options.now.toISOString(),
|
|
1319
|
+
uploaded_byte_size: candidate.byte_size,
|
|
1320
|
+
last_seen_at: options.now.toISOString(),
|
|
1321
|
+
};
|
|
1322
|
+
changed = true;
|
|
1323
|
+
recordedCount += 1;
|
|
1324
|
+
}
|
|
1325
|
+
if (changed) {
|
|
1326
|
+
await writeRawEvidenceCursor(options.paths, cursor, {
|
|
1327
|
+
filename,
|
|
1328
|
+
sessionsOnly: source === "claude_code",
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (recordedCount > 0) {
|
|
1333
|
+
console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount }));
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1240
1336
|
function countSessionUploadFailures(sync) {
|
|
1241
1337
|
return sync.raw_evidence_outcomes.filter((outcome) => Boolean(outcome.codex_session_id) &&
|
|
1242
1338
|
outcome.upload_state === "upload_failed" &&
|
|
@@ -1266,7 +1362,7 @@ function advanceBackfillCursorThroughResolvedPrefix(options) {
|
|
|
1266
1362
|
const key = candidateCursorKey(candidate);
|
|
1267
1363
|
if (options.retryableCandidateKeys.has(key))
|
|
1268
1364
|
break;
|
|
1269
|
-
const resolved = isRawEvidenceUploadableAttributionState(candidate.state)
|
|
1365
|
+
const resolved = isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null)
|
|
1270
1366
|
? options.durableCandidateKeys.has(key)
|
|
1271
1367
|
: true;
|
|
1272
1368
|
if (!resolved)
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.19");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -14,7 +14,7 @@ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET }
|
|
|
14
14
|
import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
|
|
15
15
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
16
16
|
import { clearSourceRetryFailure, readLocalUploadSpoolState, recordSourceRetryFailure, } from "../spool/local-spool.js";
|
|
17
|
-
import { isLiveRawEvidenceSyncAttribution, } from "../raw-evidence-attribution-policy.js";
|
|
17
|
+
import { isLiveRawEvidenceSyncAttribution, isRawEvidenceUploadableAttributionState, } from "../raw-evidence-attribution-policy.js";
|
|
18
18
|
/**
|
|
19
19
|
* The label used when a sync fails and nothing on the way there said why.
|
|
20
20
|
*
|
|
@@ -30,16 +30,15 @@ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
|
30
30
|
* Live sync remains reason-allowlisted even though historical backfill accepts
|
|
31
31
|
* every deterministic fallback state.
|
|
32
32
|
*/
|
|
33
|
-
export function isLiveSyncCollectableAttributionState(state, reason) {
|
|
34
|
-
return isLiveRawEvidenceSyncAttribution(state, reason);
|
|
33
|
+
export function isLiveSyncCollectableAttributionState(state, reason, hasApprovedWorkspace = false) {
|
|
34
|
+
return isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace);
|
|
35
35
|
}
|
|
36
36
|
function cursorHasUndurableCollectableSession(cursor) {
|
|
37
37
|
return Object.values(cursor.sessions).some((entry) => liveSyncCursorEntryRequiresRetry(entry));
|
|
38
38
|
}
|
|
39
39
|
export function liveSyncCursorEntryRequiresRetry(entry) {
|
|
40
40
|
return (!entry.uploaded_object_key &&
|
|
41
|
-
(isLiveSyncCollectableAttributionState(entry.state, entry.reason) ||
|
|
42
|
-
entry.reason === "repo_not_on_disk"));
|
|
41
|
+
(isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk"));
|
|
43
42
|
}
|
|
44
43
|
function allLocalHistorySinceMinutes(now) {
|
|
45
44
|
// A cutoff just before the Unix epoch is effectively unbounded for Codex and
|
|
@@ -82,7 +81,7 @@ async function reconcileSourceScanRetry(options) {
|
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
83
|
export function matchesLiveSyncWorktree(result, target) {
|
|
85
|
-
return (isLiveRawEvidenceSyncAttribution(result.state, result.reason) &&
|
|
84
|
+
return (isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null) &&
|
|
86
85
|
result.worktree !== null &&
|
|
87
86
|
liveSyncTargetKey(result.worktree) === liveSyncTargetKey(target));
|
|
88
87
|
}
|
|
@@ -101,7 +100,7 @@ export function liveSyncTargetWorktrees(discovered, results) {
|
|
|
101
100
|
const targetKey = result.worktree
|
|
102
101
|
? liveSyncTargetKey(result.worktree)
|
|
103
102
|
: null;
|
|
104
|
-
if (!isLiveSyncCollectableAttributionState(result.state, result.reason) ||
|
|
103
|
+
if (!isLiveSyncCollectableAttributionState(result.state, result.reason, result.worktree !== null) ||
|
|
105
104
|
!result.worktree ||
|
|
106
105
|
!targetKey ||
|
|
107
106
|
seen.has(targetKey)) {
|
|
@@ -117,10 +116,11 @@ export function liveSyncTargetWorktrees(discovered, results) {
|
|
|
117
116
|
* modes. Codex AND Claude Code sessions are scanned and attributed once across
|
|
118
117
|
* every discovered worktree; each worktree syncs its exact and deterministic
|
|
119
118
|
* fallback-attributed transcripts (Codex + Claude main + sidecars). The
|
|
120
|
-
* ambiguous/unattributed/skipped
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* outcome (D3); sidecar outcomes aggregate into
|
|
119
|
+
* ambiguous/unattributed/skipped sessions inside an approved root retain those
|
|
120
|
+
* labels while syncing once through that root's synthetic folder workspace.
|
|
121
|
+
* Outside-root sessions remain diagnostic-only. The session row's upload state
|
|
122
|
+
* maps ONLY from the main-file outcome (D3); sidecar outcomes aggregate into
|
|
123
|
+
* CLI counts.
|
|
124
124
|
*/
|
|
125
125
|
export async function runAttributedWorktreeSync(options) {
|
|
126
126
|
const now = new Date();
|
|
@@ -625,8 +625,7 @@ export function buildAgentSessionReport(options) {
|
|
|
625
625
|
raw_evidence_pointer_id: priorDurablePointer,
|
|
626
626
|
upload_state: "reused_existing",
|
|
627
627
|
}
|
|
628
|
-
: result.state
|
|
629
|
-
result.state === "attributed_fallback"
|
|
628
|
+
: isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
|
|
630
629
|
? {
|
|
631
630
|
upload_state: "not_uploaded",
|
|
632
631
|
// BLI-2107: `not_uploaded` used to be the branch of last
|
package/dist/health-detail.js
CHANGED
|
@@ -27,9 +27,24 @@ import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
|
|
|
27
27
|
export const HEALTH_DETAIL_MAX_CHARS = 600;
|
|
28
28
|
const WINDOWS_ABSOLUTE_PATH = /[A-Za-z]:\\[^\s"';]+/gu;
|
|
29
29
|
const UNC_PATH = /\\\\[^\s"';]+/gu;
|
|
30
|
+
// A quoted Windows path may contain spaces; the quotes bound it, so the whole
|
|
31
|
+
// quoted region is the path. Unquoted, the space-blind pattern above stops at
|
|
32
|
+
// the first space — which is how `C:\Users\Brandon Chiem\...` masked to
|
|
33
|
+
// `[path] Chiem\...` and an operator surname reached the server (BLI-2599).
|
|
34
|
+
const QUOTED_WINDOWS_PATH = /"[A-Za-z]:\\[^"]*"/gu;
|
|
35
|
+
const SINGLE_QUOTED_WINDOWS_PATH = /'[A-Za-z]:\\[^']*'/gu;
|
|
36
|
+
// A backslash-joined fragment of three or more segments is path material even
|
|
37
|
+
// without a drive anchor: schtasks echoes a severed path tail as its own token
|
|
38
|
+
// (`Chiem\.local\state\...`, the BLI-2598 receipt). Over-matching here is the
|
|
39
|
+
// safe direction — this boundary exists to keep local identifiers on the
|
|
40
|
+
// machine, not to keep messages pretty.
|
|
41
|
+
const BACKSLASH_PATH_FRAGMENT = /[^\s"';:,()]+(?:\\[^\s"';:,()]+){2,}/gu;
|
|
30
42
|
// An absolute POSIX path of at least two segments. One segment ("/tmp") carries
|
|
31
43
|
// nothing identifying and masking it would make messages harder to read.
|
|
32
44
|
const POSIX_ABSOLUTE_PATH = /\/[\w.@-]+(?:\/[\w.@ -]+)+/gu;
|
|
45
|
+
// The tail a space split off a masked path: `[path] Chiem\...` collapses back
|
|
46
|
+
// into the placeholder it was severed from.
|
|
47
|
+
const PATH_PLACEHOLDER_RUN = /\[path\](?:\s+\[path\])+/gu;
|
|
33
48
|
export function redactedHealthDetail(message) {
|
|
34
49
|
const collapsed = message.replace(/\s+/gu, " ").trim();
|
|
35
50
|
if (!collapsed)
|
|
@@ -43,20 +58,28 @@ export function redactedHealthDetail(message) {
|
|
|
43
58
|
: masked;
|
|
44
59
|
}
|
|
45
60
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
61
|
+
* Identifiers first, so the account name is caught even when it sits inside a
|
|
62
|
+
* path the path patterns would only partially cover (BLI-2599: the path
|
|
63
|
+
* pattern stopped at the space in `Brandon Chiem` and the surname survived).
|
|
64
|
+
* Then quoted paths before unquoted ones, because the quotes are what bound a
|
|
65
|
+
* space-containing path; then the drive-anchored and fragment patterns; then
|
|
66
|
+
* adjacent placeholders collapse so a severed path reads as one `[path]`.
|
|
48
67
|
*/
|
|
49
68
|
export function maskLocalIdentifiers(value) {
|
|
50
|
-
let text = value
|
|
51
|
-
.replace(UNC_PATH, "[path]")
|
|
52
|
-
.replace(WINDOWS_ABSOLUTE_PATH, "[path]")
|
|
53
|
-
.replace(POSIX_ABSOLUTE_PATH, "[path]");
|
|
69
|
+
let text = value;
|
|
54
70
|
for (const [identifier, placeholder] of localIdentifiers()) {
|
|
55
71
|
if (!identifier)
|
|
56
72
|
continue;
|
|
57
73
|
text = text.replace(literalPattern(identifier), placeholder);
|
|
58
74
|
}
|
|
59
|
-
return text
|
|
75
|
+
return text
|
|
76
|
+
.replace(QUOTED_WINDOWS_PATH, "[path]")
|
|
77
|
+
.replace(SINGLE_QUOTED_WINDOWS_PATH, "[path]")
|
|
78
|
+
.replace(UNC_PATH, "[path]")
|
|
79
|
+
.replace(WINDOWS_ABSOLUTE_PATH, "[path]")
|
|
80
|
+
.replace(BACKSLASH_PATH_FRAGMENT, "[path]")
|
|
81
|
+
.replace(POSIX_ABSOLUTE_PATH, "[path]")
|
|
82
|
+
.replace(PATH_PLACEHOLDER_RUN, "[path]");
|
|
60
83
|
}
|
|
61
84
|
function localIdentifiers() {
|
|
62
85
|
const identifiers = [];
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Historical backfill and attribution diagnostics may retain any deterministic
|
|
3
|
-
* fallback that produced a worktree identity.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* fallback that produced a worktree identity. Session-first terminal labels
|
|
4
|
+
* are uploadable only when attribution attached an approved-root synthetic
|
|
5
|
+
* workspace; the label stays terminal and no repository provenance is invented.
|
|
6
6
|
*/
|
|
7
|
-
export function isRawEvidenceUploadableAttributionState(state) {
|
|
8
|
-
return state === "attributed" ||
|
|
7
|
+
export function isRawEvidenceUploadableAttributionState(state, hasApprovedWorkspace = false) {
|
|
8
|
+
return (state === "attributed" ||
|
|
9
|
+
state === "attributed_fallback" ||
|
|
10
|
+
(hasApprovedWorkspace && isTerminalAttributionState(state)));
|
|
9
11
|
}
|
|
10
12
|
const LIVE_WORKTREE_FALLBACK_REASONS = new Set([
|
|
11
13
|
"known_repo_primary_fallback",
|
|
@@ -18,8 +20,12 @@ const LIVE_WORKTREE_FALLBACK_REASONS = new Set([
|
|
|
18
20
|
* identities it knows how to validate and materialize as sync targets. Future
|
|
19
21
|
* fallback reasons stay fail-closed until their provenance contract is added.
|
|
20
22
|
*/
|
|
21
|
-
export function isLiveRawEvidenceSyncAttribution(state, reason) {
|
|
23
|
+
export function isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace = false) {
|
|
22
24
|
return (state === "attributed" ||
|
|
23
25
|
(state === "attributed_fallback" &&
|
|
24
|
-
LIVE_WORKTREE_FALLBACK_REASONS.has(reason))
|
|
26
|
+
LIVE_WORKTREE_FALLBACK_REASONS.has(reason)) ||
|
|
27
|
+
(hasApprovedWorkspace && isTerminalAttributionState(state)));
|
|
28
|
+
}
|
|
29
|
+
function isTerminalAttributionState(state) {
|
|
30
|
+
return state === "ambiguous" || state === "unattributed" || state === "skipped";
|
|
25
31
|
}
|