@bli-cockpit/cli 0.1.22 → 0.1.24
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/README.md +46 -33
- package/dist/adapters/raw-evidence.js +121 -17
- package/dist/agent-rules.js +49 -22
- package/dist/autostart.js +23 -2
- package/dist/commands/local-args.js +19 -1
- package/dist/commands/local.js +227 -68
- package/dist/commands/public-root.js +4 -3
- package/dist/evidence-upload-client.js +51 -5
- package/dist/local-state.js +20 -3
- package/dist/onboarding-roots.js +104 -0
- package/dist/upload.js +37 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,27 +11,35 @@ device pairing.
|
|
|
11
11
|
|
|
12
12
|
## One-paste install
|
|
13
13
|
|
|
14
|
-
Run this
|
|
14
|
+
Run this in a terminal. If you are inside `~/BLI`, Cockpit suggests that
|
|
15
|
+
workspace root; otherwise it looks for likely roots such as `~/BLI` and asks
|
|
16
|
+
before collecting. Later reruns remember the approved roots:
|
|
15
17
|
|
|
16
18
|
```bash
|
|
17
19
|
npm install -g @bli-cockpit/cli@latest
|
|
18
|
-
cockpit onboard
|
|
20
|
+
cockpit onboard
|
|
19
21
|
```
|
|
20
22
|
|
|
21
23
|
Interactive onboarding prompts for the dashboard email. Enter the approved
|
|
22
24
|
intern/operator email. For reused laptops, VMs, or headless/agent-run setup,
|
|
23
|
-
pass the email explicitly:
|
|
25
|
+
pass the email and root explicitly:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
26
|
-
cockpit onboard --email <APPROVED_EMAIL> --workspace
|
|
28
|
+
cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
If a work folder contains multiple repos,
|
|
31
|
+
If a work folder contains multiple repos, confirm the parent folder as the
|
|
32
|
+
collection root. In a headless setup, pass it explicitly:
|
|
30
33
|
|
|
31
34
|
```bash
|
|
32
|
-
cd ~/Downloads/bluepearl-workspace
|
|
33
35
|
npm install -g @bli-cockpit/cli@latest
|
|
34
|
-
cockpit onboard --workspace
|
|
36
|
+
cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For multiple unrelated collection roots, repeat `--workspace`:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI --workspace ~/side-projects
|
|
35
43
|
```
|
|
36
44
|
|
|
37
45
|
The CLI defaults to the production dashboard. Normal intern/operator setup,
|
|
@@ -39,25 +47,23 @@ updates, and syncs omit `--dashboard-url`. Pass `--dashboard-url` only for
|
|
|
39
47
|
staging, a custom dashboard, or deliberately forcing a different dashboard
|
|
40
48
|
pairing. Already-onboarded users update with
|
|
41
49
|
`npm install -g @bli-cockpit/cli@latest`, then run
|
|
42
|
-
`cockpit
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
and every 15 minutes. If skipped, run
|
|
56
|
-
`cockpit autostart install --workspace "$PWD"` later.
|
|
50
|
+
`cockpit onboard` from anywhere to refresh pairing, agent rules, autostart, and
|
|
51
|
+
an initial sync against saved roots. `--repo <path>` remains supported for older
|
|
52
|
+
prompts and the agent ticket-binding guardrail.
|
|
53
|
+
|
|
54
|
+
On machines where Codex or Claude agents will do ticketed work, `cockpit
|
|
55
|
+
onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
|
|
56
|
+
proof. The managed Cockpit ticket-binding block is replaced in place rather
|
|
57
|
+
than duplicated and is scoped to the confirmed root set, so agents should ignore
|
|
58
|
+
it in private chats or unrelated repos.
|
|
59
|
+
|
|
60
|
+
For intern machines, `cockpit onboard` refreshes the launchd autostart agent so
|
|
61
|
+
Cockpit syncs confirmed roots at login and every 15 minutes. For a manual
|
|
62
|
+
repair path, run `cockpit autostart install --workspace <root>` later.
|
|
57
63
|
|
|
58
64
|
```bash
|
|
59
|
-
# Repair/manual path
|
|
60
|
-
cockpit agent-rules install --workspace
|
|
65
|
+
# Repair/manual path.
|
|
66
|
+
cockpit agent-rules install --workspace <root>
|
|
61
67
|
```
|
|
62
68
|
|
|
63
69
|
The direct command updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with
|
|
@@ -87,16 +93,21 @@ What happens:
|
|
|
87
93
|
4. Admin approves the persistent email access request from Ambient -> Collector
|
|
88
94
|
approvals. Pair codes still expire, but the access request remains. If the
|
|
89
95
|
terminal is still polling, approval pairs it immediately; if not, the intern
|
|
90
|
-
reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
Supabase invite flow
|
|
96
|
+
reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI` or
|
|
97
|
+
simply `cockpit onboard` after roots are saved, and the stored approval
|
|
98
|
+
auto-pairs without another Admin action. Admin can still paste the printed
|
|
99
|
+
code there as a rescue path. The signed-in intern can still use the printed
|
|
100
|
+
URL. If the claimed email is not an app user yet, a super admin approval
|
|
101
|
+
creates the user, adds team membership, and sends the Supabase invite flow
|
|
102
|
+
automatically.
|
|
96
103
|
5. The CLI starts general ambient capture, uploads private raw evidence objects
|
|
97
104
|
when present (chunked and resumable, with identical content acknowledged
|
|
98
|
-
instead of re-uploaded), then uploads one safe metadata/ref envelope.
|
|
99
|
-
|
|
105
|
+
instead of re-uploaded), then uploads one safe metadata/ref envelope. Text
|
|
106
|
+
evidence with secret-shaped values is sanitized before storage and marked as
|
|
107
|
+
partial evidence with redaction metadata; env files are still never read.
|
|
108
|
+
6. The CLI prints `PASS: Cockpit collector is ready for harvest.` and a final
|
|
109
|
+
`You're live.` block with collection roots, pairing state, background sync
|
|
110
|
+
state, dashboard link, and `cockpit status` as the next command.
|
|
100
111
|
|
|
101
112
|
`--device-name` is optional and only a readable label in Cockpit. Pass it only
|
|
102
113
|
when you want a specific label such as `"Savina MacBook"` or `"Box VM 42"`.
|
|
@@ -173,7 +184,9 @@ Remote dashboard:
|
|
|
173
184
|
- raw evidence refs accepted by `/api/ambient/ingest`;
|
|
174
185
|
- durable private Storage objects accepted by the chunked
|
|
175
186
|
`/api/ambient/evidence/upload/begin|chunk|commit` endpoints, tracked in a
|
|
176
|
-
durable per-object upload ledger
|
|
187
|
+
durable per-object upload ledger. When deterministic sanitization was
|
|
188
|
+
applied, the evidence ref stores the redaction metadata and sanitized object
|
|
189
|
+
hash/size;
|
|
177
190
|
- attached image artifact metadata accepted by `/api/ambient/agent-artifacts`
|
|
178
191
|
for screenshots/images explicitly attached into Codex or Claude sessions;
|
|
179
192
|
- Codex session attribution records (session id, file hash, attribution state
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EvidenceCompletenessPayloadSchema, SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
1
|
+
import { EvidenceCompletenessPayloadSchema, SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
@@ -29,6 +29,7 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
29
29
|
const skipped = [];
|
|
30
30
|
const truncated = [];
|
|
31
31
|
const failed = [];
|
|
32
|
+
const redacted = [];
|
|
32
33
|
const reused = [];
|
|
33
34
|
const sinceMinutes = options.sinceMinutes ?? DEFAULT_SINCE_MINUTES;
|
|
34
35
|
const sessionLimit = options.sessionLimit ?? DEFAULT_SESSION_LIMIT;
|
|
@@ -42,6 +43,7 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
42
43
|
skipped,
|
|
43
44
|
truncated,
|
|
44
45
|
failed,
|
|
46
|
+
redacted,
|
|
45
47
|
reused,
|
|
46
48
|
scanned: new Map(),
|
|
47
49
|
caps: [
|
|
@@ -126,6 +128,7 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
126
128
|
file_count: 0,
|
|
127
129
|
byte_size: 0,
|
|
128
130
|
skipped_count: countEvidenceEntries(skipped),
|
|
131
|
+
sanitized_count: redacted.length,
|
|
129
132
|
reused_count: reused.length,
|
|
130
133
|
deferred_byte_budget_count: deferredByteBudgetCount,
|
|
131
134
|
deferred_object_budget_count: deferredObjectBudgetCount,
|
|
@@ -150,6 +153,7 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
150
153
|
packId,
|
|
151
154
|
entries,
|
|
152
155
|
skipped,
|
|
156
|
+
redacted,
|
|
153
157
|
reused,
|
|
154
158
|
});
|
|
155
159
|
const manifestPath = path.join(evidenceDir, "manifest.json");
|
|
@@ -180,6 +184,7 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
180
184
|
file_count: entries.length,
|
|
181
185
|
byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
|
|
182
186
|
skipped_count: countEvidenceEntries(skipped),
|
|
187
|
+
sanitized_count: redacted.length,
|
|
183
188
|
reused_count: reused.length,
|
|
184
189
|
deferred_byte_budget_count: deferredByteBudgetCount,
|
|
185
190
|
deferred_object_budget_count: deferredObjectBudgetCount,
|
|
@@ -225,6 +230,7 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
225
230
|
file_count: 0,
|
|
226
231
|
byte_size: 0,
|
|
227
232
|
skipped_count: countEvidenceEntries(skipped),
|
|
233
|
+
sanitized_count: redacted.length,
|
|
228
234
|
reused_count: reused.length,
|
|
229
235
|
deferred_byte_budget_count: skipped
|
|
230
236
|
.filter((entry) => entry.reason === "deferred_byte_budget")
|
|
@@ -603,15 +609,29 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
603
609
|
});
|
|
604
610
|
return false;
|
|
605
611
|
}
|
|
606
|
-
|
|
612
|
+
const sanitized = sanitizeTextEvidenceForUpload({
|
|
613
|
+
text: raw.toString("utf8"),
|
|
614
|
+
originalBytes: raw,
|
|
615
|
+
redactedFields: [`${options.kind}.body`],
|
|
616
|
+
});
|
|
617
|
+
if (sanitized.status === "blocked") {
|
|
607
618
|
collection.skipped.push({
|
|
608
619
|
kind: options.kind,
|
|
609
620
|
label: fileName,
|
|
610
|
-
reason:
|
|
621
|
+
reason: sanitized.reason,
|
|
611
622
|
});
|
|
612
623
|
return false;
|
|
613
624
|
}
|
|
614
|
-
|
|
625
|
+
if (sanitized.status === "redacted") {
|
|
626
|
+
collection.redacted.push({
|
|
627
|
+
kind: options.kind,
|
|
628
|
+
label: fileName,
|
|
629
|
+
redaction: sanitized.redaction,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
const evidenceBytes = sanitized.bytes;
|
|
633
|
+
const redaction = sanitized.redaction;
|
|
634
|
+
const contentHash = sha256(evidenceBytes);
|
|
615
635
|
if (collection.skipContentHashes.has(contentHash)) {
|
|
616
636
|
collection.reused.push({
|
|
617
637
|
kind: options.kind,
|
|
@@ -621,7 +641,7 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
621
641
|
});
|
|
622
642
|
return true;
|
|
623
643
|
}
|
|
624
|
-
const deferReason = admitToBudget(collection.budget,
|
|
644
|
+
const deferReason = admitToBudget(collection.budget, evidenceBytes.byteLength);
|
|
625
645
|
if (deferReason) {
|
|
626
646
|
markBudgetCapApplied(collection, deferReason);
|
|
627
647
|
collection.skipped.push({
|
|
@@ -634,7 +654,7 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
634
654
|
collection.index.value += 1;
|
|
635
655
|
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${fileName}`);
|
|
636
656
|
const destination = path.join(collection.filesDir, path.basename(relativePath));
|
|
637
|
-
await fs.writeFile(destination,
|
|
657
|
+
await fs.writeFile(destination, evidenceBytes, { mode: 0o600 });
|
|
638
658
|
await chmodPrivate(destination, 0o600);
|
|
639
659
|
collection.entries.push(evidenceEntry({
|
|
640
660
|
context: collection.context,
|
|
@@ -643,8 +663,11 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
643
663
|
localPath: destination,
|
|
644
664
|
relativePath,
|
|
645
665
|
mediaType: options.mediaType,
|
|
646
|
-
redactedSummary:
|
|
647
|
-
|
|
666
|
+
redactedSummary: redaction
|
|
667
|
+
? `${options.redactedSummary} Secret-like values were deterministically redacted before upload.`
|
|
668
|
+
: options.redactedSummary,
|
|
669
|
+
redaction,
|
|
670
|
+
bytes: evidenceBytes,
|
|
648
671
|
codexSessionId: options.sessionId,
|
|
649
672
|
contentAddress: options.contentAddress(contentHash.slice(0, 16)),
|
|
650
673
|
}));
|
|
@@ -664,6 +687,31 @@ function admitToBudget(budget, byteLength) {
|
|
|
664
687
|
budget.remainingBytes -= byteLength;
|
|
665
688
|
return null;
|
|
666
689
|
}
|
|
690
|
+
function sanitizeTextEvidenceForUpload(options) {
|
|
691
|
+
const originalBytes = options.originalBytes ?? Buffer.from(options.text, "utf8");
|
|
692
|
+
const redactionResult = redactSecretLikeContent(options.text, {
|
|
693
|
+
appliedBy: "local_collector",
|
|
694
|
+
redactedFields: options.redactedFields,
|
|
695
|
+
});
|
|
696
|
+
if (redactionResult.redacted) {
|
|
697
|
+
if (containsSecretLikeContent(redactionResult.text)) {
|
|
698
|
+
return { status: "blocked", reason: "secret_redaction_failed" };
|
|
699
|
+
}
|
|
700
|
+
const sanitizedBytes = Buffer.from(redactionResult.text, "utf8");
|
|
701
|
+
return {
|
|
702
|
+
status: "redacted",
|
|
703
|
+
bytes: sanitizedBytes,
|
|
704
|
+
redaction: withRedactionContentMetadata(redactionResult.metadata, {
|
|
705
|
+
originalBytes,
|
|
706
|
+
sanitizedBytes,
|
|
707
|
+
}),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (containsSecretLikeContent(options.text)) {
|
|
711
|
+
return { status: "blocked", reason: "secret_like_content_guard" };
|
|
712
|
+
}
|
|
713
|
+
return { status: "clean", bytes: originalBytes };
|
|
714
|
+
}
|
|
667
715
|
async function collectGitDiffFiles(collection, repoRoot) {
|
|
668
716
|
const diffTargets = [
|
|
669
717
|
{ label: "unstaged", args: ["diff", "--no-ext-diff", "--"] },
|
|
@@ -697,15 +745,27 @@ async function collectGitDiffFiles(collection, repoRoot) {
|
|
|
697
745
|
}
|
|
698
746
|
if (!diff.stdout.trim())
|
|
699
747
|
continue;
|
|
700
|
-
|
|
748
|
+
const sanitized = sanitizeTextEvidenceForUpload({
|
|
749
|
+
text: diff.stdout,
|
|
750
|
+
redactedFields: [`git_diff.${target.label}`],
|
|
751
|
+
});
|
|
752
|
+
if (sanitized.status === "blocked") {
|
|
701
753
|
collection.skipped.push({
|
|
702
754
|
kind: "git_diff",
|
|
703
755
|
label: target.label,
|
|
704
|
-
reason:
|
|
756
|
+
reason: sanitized.reason,
|
|
705
757
|
});
|
|
706
758
|
continue;
|
|
707
759
|
}
|
|
708
|
-
|
|
760
|
+
if (sanitized.status === "redacted") {
|
|
761
|
+
collection.redacted.push({
|
|
762
|
+
kind: "git_diff",
|
|
763
|
+
label: target.label,
|
|
764
|
+
redaction: sanitized.redaction,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
const raw = sanitized.bytes;
|
|
768
|
+
const redaction = sanitized.redaction;
|
|
709
769
|
const contentHash = sha256(raw);
|
|
710
770
|
if (collection.skipContentHashes.has(contentHash)) {
|
|
711
771
|
collection.reused.push({
|
|
@@ -737,9 +797,12 @@ async function collectGitDiffFiles(collection, repoRoot) {
|
|
|
737
797
|
localPath: destination,
|
|
738
798
|
relativePath,
|
|
739
799
|
mediaType: "text/x-diff",
|
|
740
|
-
redactedSummary:
|
|
741
|
-
? `Raw git ${target.label} diff
|
|
742
|
-
:
|
|
800
|
+
redactedSummary: redaction
|
|
801
|
+
? `Raw git ${target.label} diff preserved locally with env/secret paths excluded and secret-like values deterministically redacted.`
|
|
802
|
+
: diff.truncated
|
|
803
|
+
? `Raw git ${target.label} diff truncated to the capture cap and preserved locally with env/secret paths excluded.`
|
|
804
|
+
: `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
|
|
805
|
+
redaction,
|
|
743
806
|
bytes: raw,
|
|
744
807
|
contentAddress: `git-diff/${target.label}-${contentHash.slice(0, 16)}.diff`,
|
|
745
808
|
}));
|
|
@@ -864,6 +927,8 @@ function makeEvidenceCompleteness(collection, options) {
|
|
|
864
927
|
sources.add(entry.kind);
|
|
865
928
|
for (const entry of collection.failed)
|
|
866
929
|
sources.add(entry.kind);
|
|
930
|
+
for (const entry of collection.redacted)
|
|
931
|
+
sources.add(entry.kind);
|
|
867
932
|
const sourceCounts = [...sources].sort().map((source) => {
|
|
868
933
|
const skipped = collection.skipped.filter((entry) => entry.kind === source);
|
|
869
934
|
return {
|
|
@@ -950,10 +1015,30 @@ function makeEvidenceCompleteness(collection, options) {
|
|
|
950
1015
|
});
|
|
951
1016
|
}
|
|
952
1017
|
}
|
|
1018
|
+
const redactionCounts = new Map();
|
|
1019
|
+
for (const redacted of collection.redacted) {
|
|
1020
|
+
const ruleIds = redacted.redaction.rule_counts.map((rule) => rule.rule_id);
|
|
1021
|
+
const key = `${redacted.kind}:${redacted.redaction.mode}:${ruleIds.sort().join(",")}`;
|
|
1022
|
+
const existing = redactionCounts.get(key);
|
|
1023
|
+
if (existing) {
|
|
1024
|
+
existing.count += 1;
|
|
1025
|
+
existing.rule_ids = [...new Set([...existing.rule_ids, ...ruleIds])].sort();
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
redactionCounts.set(key, {
|
|
1029
|
+
source: redacted.kind,
|
|
1030
|
+
status: "sanitized",
|
|
1031
|
+
mode: redacted.redaction.mode,
|
|
1032
|
+
count: 1,
|
|
1033
|
+
rule_ids: [...new Set(ruleIds)].sort(),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
953
1037
|
const hasGaps = totals.skipped_count > 0 ||
|
|
954
1038
|
totals.truncated_count > 0 ||
|
|
955
1039
|
totals.deferred_count > 0 ||
|
|
956
1040
|
totals.failed_count > 0 ||
|
|
1041
|
+
collection.redacted.length > 0 ||
|
|
957
1042
|
collection.caps.some((cap) => cap.applied);
|
|
958
1043
|
const status = totals.failed_count > 0 &&
|
|
959
1044
|
totals.included_count + totals.reused_count === 0
|
|
@@ -978,11 +1063,14 @@ function makeEvidenceCompleteness(collection, options) {
|
|
|
978
1063
|
skip_reasons: [...skipReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
|
|
979
1064
|
failure_reasons: [...failureReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
|
|
980
1065
|
truncation_markers: [...truncationCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
|
|
1066
|
+
redaction_markers: [...redactionCounts.values()].sort((a, b) => `${a.source}:${a.mode}`.localeCompare(`${b.source}:${b.mode}`)),
|
|
981
1067
|
notes: totals.failed_count > 0
|
|
982
1068
|
? ["Evidence collection failed; downstream analysis should not infer confidence."]
|
|
983
|
-
:
|
|
984
|
-
? ["Evidence
|
|
985
|
-
:
|
|
1069
|
+
: collection.redacted.length > 0
|
|
1070
|
+
? ["Evidence was sanitized before upload; downstream analysis should not treat it as raw-complete."]
|
|
1071
|
+
: hasGaps
|
|
1072
|
+
? ["Evidence is incomplete; downstream analysis should lower confidence."]
|
|
1073
|
+
: [],
|
|
986
1074
|
});
|
|
987
1075
|
}
|
|
988
1076
|
async function walkJsonlFiles(dir, cutoffMs) {
|
|
@@ -1045,6 +1133,7 @@ function makeManifest(options) {
|
|
|
1045
1133
|
},
|
|
1046
1134
|
files: options.entries.map(redactManifestEntry),
|
|
1047
1135
|
skipped: options.skipped,
|
|
1136
|
+
redacted: options.redacted,
|
|
1048
1137
|
reused: options.reused,
|
|
1049
1138
|
};
|
|
1050
1139
|
}
|
|
@@ -1065,6 +1154,7 @@ function evidenceEntry(options) {
|
|
|
1065
1154
|
byte_size: options.bytes.byteLength,
|
|
1066
1155
|
media_type: options.mediaType,
|
|
1067
1156
|
redacted_summary: options.redactedSummary,
|
|
1157
|
+
...(options.redaction ? { redaction: options.redaction } : {}),
|
|
1068
1158
|
codex_session_id: options.codexSessionId ?? null,
|
|
1069
1159
|
...(options.artifactMetadata
|
|
1070
1160
|
? {
|
|
@@ -1089,6 +1179,7 @@ function redactManifestEntry(entry) {
|
|
|
1089
1179
|
byte_size: entry.byte_size,
|
|
1090
1180
|
media_type: entry.media_type,
|
|
1091
1181
|
redacted_summary: entry.redacted_summary,
|
|
1182
|
+
...(entry.redaction ? { redaction: entry.redaction } : {}),
|
|
1092
1183
|
...(entry.artifact_metadata
|
|
1093
1184
|
? { artifact_metadata: entry.artifact_metadata }
|
|
1094
1185
|
: {}),
|
|
@@ -1109,6 +1200,7 @@ function pointerFromEntry(entry) {
|
|
|
1109
1200
|
byte_size: entry.byte_size,
|
|
1110
1201
|
media_type: entry.media_type,
|
|
1111
1202
|
redacted_summary: entry.redacted_summary,
|
|
1203
|
+
...(entry.redaction ? { redaction: entry.redaction } : {}),
|
|
1112
1204
|
};
|
|
1113
1205
|
}
|
|
1114
1206
|
/**
|
|
@@ -1193,6 +1285,18 @@ function safeKeySegment(value) {
|
|
|
1193
1285
|
function sha256(value) {
|
|
1194
1286
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
1195
1287
|
}
|
|
1288
|
+
function withRedactionContentMetadata(metadata, options) {
|
|
1289
|
+
if (!metadata) {
|
|
1290
|
+
throw new Error("redacted evidence is missing redaction metadata");
|
|
1291
|
+
}
|
|
1292
|
+
return {
|
|
1293
|
+
...metadata,
|
|
1294
|
+
original_content_hash_sha256: sha256(options.originalBytes),
|
|
1295
|
+
sanitized_content_hash_sha256: sha256(options.sanitizedBytes),
|
|
1296
|
+
original_byte_size: options.originalBytes.byteLength,
|
|
1297
|
+
sanitized_byte_size: options.sanitizedBytes.byteLength,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1196
1300
|
async function ensurePrivateDir(dir) {
|
|
1197
1301
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
1198
1302
|
await chmodPrivate(dir, 0o700);
|
package/dist/agent-rules.js
CHANGED
|
@@ -15,7 +15,11 @@ export async function installAgentRules(options = {}) {
|
|
|
15
15
|
}
|
|
16
16
|
async function installAgentRulesForHost(host, options = {}) {
|
|
17
17
|
const rulesFile = agentRulesFile(host, options.homeDir);
|
|
18
|
-
const block = cockpitAgentRulesBlock({
|
|
18
|
+
const block = cockpitAgentRulesBlock({
|
|
19
|
+
scopePath: options.scopePath,
|
|
20
|
+
scopePaths: options.scopePaths,
|
|
21
|
+
});
|
|
22
|
+
const scopePaths = normalizeScopePaths(options);
|
|
19
23
|
let existing = "";
|
|
20
24
|
let existed = true;
|
|
21
25
|
try {
|
|
@@ -24,7 +28,7 @@ async function installAgentRulesForHost(host, options = {}) {
|
|
|
24
28
|
catch {
|
|
25
29
|
existed = false;
|
|
26
30
|
}
|
|
27
|
-
const prepared = prepareManagedBlockInstall(existing, block,
|
|
31
|
+
const prepared = prepareManagedBlockInstall(existing, block, scopePaths);
|
|
28
32
|
if (!prepared.next) {
|
|
29
33
|
return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
|
|
30
34
|
}
|
|
@@ -78,7 +82,11 @@ export async function inspectAgentRules(options = {}) {
|
|
|
78
82
|
}
|
|
79
83
|
async function inspectAgentRulesForHost(host, options = {}) {
|
|
80
84
|
const rulesFile = agentRulesFile(host, options.homeDir);
|
|
81
|
-
const block = cockpitAgentRulesBlock({
|
|
85
|
+
const block = cockpitAgentRulesBlock({
|
|
86
|
+
scopePath: options.scopePath,
|
|
87
|
+
scopePaths: options.scopePaths,
|
|
88
|
+
});
|
|
89
|
+
const scopePaths = normalizeScopePaths(options);
|
|
82
90
|
let existing = "";
|
|
83
91
|
try {
|
|
84
92
|
existing = await readFile(rulesFile, "utf8");
|
|
@@ -89,7 +97,7 @@ async function inspectAgentRulesForHost(host, options = {}) {
|
|
|
89
97
|
installed: false,
|
|
90
98
|
};
|
|
91
99
|
}
|
|
92
|
-
const state = inspectAgentRulesContents(existing, block,
|
|
100
|
+
const state = inspectAgentRulesContents(existing, block, scopePaths);
|
|
93
101
|
const installed = state === "managed" || state === "equivalent";
|
|
94
102
|
return {
|
|
95
103
|
...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block, state),
|
|
@@ -97,23 +105,38 @@ async function inspectAgentRulesForHost(host, options = {}) {
|
|
|
97
105
|
};
|
|
98
106
|
}
|
|
99
107
|
export function cockpitAgentRulesBlock(options = {}) {
|
|
100
|
-
const
|
|
101
|
-
const scopeLine =
|
|
102
|
-
? `- This guidance only applies when the current working directory is inside the Cockpit-onboarded workspace/repo: \`${
|
|
103
|
-
:
|
|
108
|
+
const scopePaths = normalizeScopePaths(options);
|
|
109
|
+
const scopeLine = scopePaths.length === 1
|
|
110
|
+
? `- This guidance only applies when the current working directory is inside the Cockpit-onboarded workspace/repo: \`${scopePaths[0]}\`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.`
|
|
111
|
+
: scopePaths.length > 1
|
|
112
|
+
? `- This guidance only applies when the current working directory is inside one of these Cockpit-onboarded workspace roots: ${scopePaths.map((scopePath) => `\`${scopePath}\``).join(", ")}. Outside those folders, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.`
|
|
113
|
+
: "- This guidance only applies when the current working directory is inside the workspace/repo that ran `cockpit onboard` or `cockpit agent-rules install`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.";
|
|
104
114
|
return [
|
|
105
115
|
MANAGED_BLOCK_START,
|
|
106
116
|
"## Cockpit Ticket Binding",
|
|
107
117
|
"",
|
|
108
118
|
scopeLine,
|
|
109
|
-
"- For implementation, debugging, review, PR, or ship work
|
|
119
|
+
"- For implementation, debugging, review, PR, or ship work that already has a clear Linear ticket, run `cockpit start --ticket <ticket-id> --workspace \"$PWD\"` before the first code edit or mutating tool call. This starts attributing the session's work to that specific ticket in Cockpit.",
|
|
110
120
|
"- Use `--ticket`; do not invent `--ticketId` or other flag shapes.",
|
|
111
|
-
"- If the user mentions ticketed work but no ticket ID is visible,
|
|
121
|
+
"- If the user mentions ticketed work but no ticket ID is visible, search Linear for an existing ticket before editing. If none exists and the work is ticket-worthy, create a narrow Linear ticket, then run the canonical `cockpit start --ticket ...` command. Ask the user only when ticket creation would be ambiguous or the user has forbidden creating one.",
|
|
112
122
|
"- If there is truly no ticket, state that the work remains in general ambient capture and do not invent a ticket.",
|
|
113
123
|
"- After the first meaningful checkpoint, run `cockpit sync --workspace \"$PWD\" --json` so Cockpit has fresh ticket/session binding metadata.",
|
|
114
124
|
MANAGED_BLOCK_END,
|
|
115
125
|
].join("\n");
|
|
116
126
|
}
|
|
127
|
+
function normalizeScopePaths(options) {
|
|
128
|
+
const raw = options.scopePaths ?? (options.scopePath ? [options.scopePath] : []);
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
const normalized = [];
|
|
131
|
+
for (const scopePath of raw) {
|
|
132
|
+
const resolved = path.resolve(scopePath);
|
|
133
|
+
if (seen.has(resolved))
|
|
134
|
+
continue;
|
|
135
|
+
seen.add(resolved);
|
|
136
|
+
normalized.push(resolved);
|
|
137
|
+
}
|
|
138
|
+
return normalized;
|
|
139
|
+
}
|
|
117
140
|
export function hasManagedBlock(contents) {
|
|
118
141
|
return contents.includes(MANAGED_BLOCK_START) && contents.includes(MANAGED_BLOCK_END);
|
|
119
142
|
}
|
|
@@ -131,7 +154,7 @@ export function removeManagedBlock(contents) {
|
|
|
131
154
|
return contents;
|
|
132
155
|
return contents.replace(managedBlockPattern(), "").replace(/\n{3,}/gu, "\n\n").trimEnd() + "\n";
|
|
133
156
|
}
|
|
134
|
-
function prepareManagedBlockInstall(contents, block,
|
|
157
|
+
function prepareManagedBlockInstall(contents, block, scopePaths = []) {
|
|
135
158
|
if (!contents.trim())
|
|
136
159
|
return { next: `${block}\n`, state: "missing" };
|
|
137
160
|
if (hasManagedBlock(contents)) {
|
|
@@ -140,10 +163,10 @@ function prepareManagedBlockInstall(contents, block, scopePath) {
|
|
|
140
163
|
state: extractManagedBlock(contents) === block ? "managed" : "stale",
|
|
141
164
|
};
|
|
142
165
|
}
|
|
143
|
-
if (hasEquivalentUnmanagedTicketBinding(contents,
|
|
166
|
+
if (hasEquivalentUnmanagedTicketBinding(contents, scopePaths)) {
|
|
144
167
|
return { next: null, state: "equivalent" };
|
|
145
168
|
}
|
|
146
|
-
const staleBlock = findStaleUnmanagedTicketBindingBlock(contents,
|
|
169
|
+
const staleBlock = findStaleUnmanagedTicketBindingBlock(contents, scopePaths);
|
|
147
170
|
if (staleBlock) {
|
|
148
171
|
return {
|
|
149
172
|
next: replaceLineSpan(contents, staleBlock.startLine, staleBlock.endLine, block),
|
|
@@ -152,13 +175,13 @@ function prepareManagedBlockInstall(contents, block, scopePath) {
|
|
|
152
175
|
}
|
|
153
176
|
return { next: `${contents.replace(/\s+$/u, "")}\n\n${block}\n`, state: "missing" };
|
|
154
177
|
}
|
|
155
|
-
function inspectAgentRulesContents(contents, block,
|
|
178
|
+
function inspectAgentRulesContents(contents, block, scopePaths = []) {
|
|
156
179
|
if (hasManagedBlock(contents)) {
|
|
157
180
|
return extractManagedBlock(contents) === block ? "managed" : "stale";
|
|
158
181
|
}
|
|
159
|
-
if (hasEquivalentUnmanagedTicketBinding(contents,
|
|
182
|
+
if (hasEquivalentUnmanagedTicketBinding(contents, scopePaths))
|
|
160
183
|
return "equivalent";
|
|
161
|
-
if (findStaleUnmanagedTicketBindingBlock(contents,
|
|
184
|
+
if (findStaleUnmanagedTicketBindingBlock(contents, scopePaths))
|
|
162
185
|
return "stale";
|
|
163
186
|
return "missing";
|
|
164
187
|
}
|
|
@@ -200,24 +223,28 @@ function managedBlockPattern() {
|
|
|
200
223
|
function extractManagedBlock(contents) {
|
|
201
224
|
return contents.match(managedBlockPattern())?.[0] ?? null;
|
|
202
225
|
}
|
|
203
|
-
function hasEquivalentUnmanagedTicketBinding(contents,
|
|
226
|
+
function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
|
|
204
227
|
const text = normalizeRuleText(contents);
|
|
205
228
|
if (!hasTicketBindingCues(text))
|
|
206
229
|
return false;
|
|
207
230
|
if (!hasRepoScopeGuard(text))
|
|
208
231
|
return false;
|
|
209
|
-
if (scopePath
|
|
232
|
+
if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
|
|
233
|
+
return false;
|
|
234
|
+
if (!hasTicketLookupOrCreationCue(text))
|
|
210
235
|
return false;
|
|
211
236
|
const signals = [
|
|
212
237
|
/cockpit\s+start\s+--ticket\b/u,
|
|
213
238
|
/before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
|
|
214
|
-
/ticket\s+id\s+(?:is\s+)?(?:missing|visible)|ask\s+once/u,
|
|
215
239
|
/general\s+ambient/u,
|
|
216
240
|
/cockpit\s+sync\s+--repo|fresh\s+ticket\/session\s+binding\s+metadata/u,
|
|
217
241
|
/use\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
|
|
218
242
|
];
|
|
219
243
|
const score = signals.filter((signal) => signal.test(text)).length;
|
|
220
|
-
return score >=
|
|
244
|
+
return score >= 4;
|
|
245
|
+
}
|
|
246
|
+
function hasTicketLookupOrCreationCue(text) {
|
|
247
|
+
return /search\s+linear|create\s+(?:a\s+)?(?:narrow\s+)?linear\s+ticket|new\s+linear\s+ticket/u.test(text);
|
|
221
248
|
}
|
|
222
249
|
function hasScopePath(text, scopePath) {
|
|
223
250
|
return text.includes(normalizeRuleText(path.resolve(scopePath)));
|
|
@@ -227,7 +254,7 @@ function hasRepoScopeGuard(text) {
|
|
|
227
254
|
/outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
|
|
228
255
|
/private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
|
|
229
256
|
}
|
|
230
|
-
function findStaleUnmanagedTicketBindingBlock(contents,
|
|
257
|
+
function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
|
|
231
258
|
const lines = contents.split("\n");
|
|
232
259
|
for (let index = 0; index < lines.length; index += 1) {
|
|
233
260
|
if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
|
|
@@ -243,7 +270,7 @@ function findStaleUnmanagedTicketBindingBlock(contents, scopePath) {
|
|
|
243
270
|
const candidate = lines.slice(index, endLine).join("\n");
|
|
244
271
|
const normalized = normalizeRuleText(candidate);
|
|
245
272
|
if (hasTicketBindingCues(normalized) &&
|
|
246
|
-
!hasEquivalentUnmanagedTicketBinding(candidate,
|
|
273
|
+
!hasEquivalentUnmanagedTicketBinding(candidate, scopePaths)) {
|
|
247
274
|
return { startLine: index, endLine };
|
|
248
275
|
}
|
|
249
276
|
}
|
package/dist/autostart.js
CHANGED
|
@@ -29,7 +29,9 @@ export async function installAutostartAgent(options) {
|
|
|
29
29
|
if (platform !== "darwin")
|
|
30
30
|
return unsupportedResult(options.homeDir);
|
|
31
31
|
const homeDir = options.homeDir ?? os.homedir();
|
|
32
|
-
const
|
|
32
|
+
const workDirs = normalizeWorkDirs(options.repoRoots);
|
|
33
|
+
const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
|
|
34
|
+
const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
|
|
33
35
|
const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
34
36
|
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
35
37
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
@@ -44,6 +46,7 @@ export async function installAutostartAgent(options) {
|
|
|
44
46
|
await mkdir(paths.state_dir, { recursive: true });
|
|
45
47
|
await writeFile(plistPath, renderPlist({
|
|
46
48
|
workDir,
|
|
49
|
+
workDirs: resolvedWorkDirs,
|
|
47
50
|
dashboardUrl,
|
|
48
51
|
intervalSeconds,
|
|
49
52
|
stdoutPath,
|
|
@@ -62,6 +65,7 @@ export async function installAutostartAgent(options) {
|
|
|
62
65
|
loaded,
|
|
63
66
|
interval_seconds: intervalSeconds,
|
|
64
67
|
work_dir: workDir,
|
|
68
|
+
work_dirs: resolvedWorkDirs,
|
|
65
69
|
dashboard_url: dashboardUrl,
|
|
66
70
|
...(loaded
|
|
67
71
|
? {}
|
|
@@ -131,7 +135,10 @@ function renderPlist(options) {
|
|
|
131
135
|
const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
132
136
|
? ""
|
|
133
137
|
: ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
|
|
134
|
-
const
|
|
138
|
+
const roots = options.workDirs.length > 0 ? options.workDirs : [options.workDir];
|
|
139
|
+
const command = roots
|
|
140
|
+
.map((root) => `cockpit sync --workspace ${shellQuote(root)}${dashboardArg} --json`)
|
|
141
|
+
.join("; ");
|
|
135
142
|
return [
|
|
136
143
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
137
144
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
@@ -169,6 +176,20 @@ function renderPlist(options) {
|
|
|
169
176
|
"",
|
|
170
177
|
].join("\n");
|
|
171
178
|
}
|
|
179
|
+
function normalizeWorkDirs(repoRoots) {
|
|
180
|
+
if (!repoRoots)
|
|
181
|
+
return [];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
const normalized = [];
|
|
184
|
+
for (const root of repoRoots) {
|
|
185
|
+
const resolved = path.resolve(root);
|
|
186
|
+
if (seen.has(resolved))
|
|
187
|
+
continue;
|
|
188
|
+
seen.add(resolved);
|
|
189
|
+
normalized.push(resolved);
|
|
190
|
+
}
|
|
191
|
+
return normalized;
|
|
192
|
+
}
|
|
172
193
|
/**
|
|
173
194
|
* Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
|
|
174
195
|
* transcript dir exists yet (an empty array would tell launchd to watch
|