@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 CHANGED
@@ -11,27 +11,35 @@ device pairing.
11
11
 
12
12
  ## One-paste install
13
13
 
14
- Run this from the repo where work will happen:
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 --workspace "$PWD"
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 "$PWD"
28
+ cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI
27
29
  ```
28
30
 
29
- If a work folder contains multiple repos, run the same command from the parent:
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 "$PWD"
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 sync --workspace "$PWD" --json`. `--repo <path>` remains supported
43
- for older prompts and the agent ticket-binding guardrail.
44
-
45
- On machines where Codex or Claude agents will do ticketed work, interactive
46
- `cockpit onboard` checks `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after
47
- harvest proof. If equivalent Cockpit ticket-binding guidance already exists, it
48
- leaves the files alone. If guidance is missing or clearly stale, it asks whether
49
- to install or replace it. The managed guidance is scoped to the workspace path
50
- used for onboarding, so agents should ignore it in private chats or unrelated
51
- repos.
52
-
53
- For intern machines, accept the onboarding prompt to keep Cockpit syncing in the
54
- background. It installs the launchd autostart agent so Cockpit syncs at login
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, or headless/json onboarding where Cockpit cannot prompt.
60
- cockpit agent-rules install --workspace "$PWD"
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 "$PWD"` and the
91
- stored approval auto-pairs without another Admin action. Admin can still
92
- paste the printed code there as a rescue path. The signed-in intern can still
93
- use the printed URL. If the claimed email is not an app user yet, a super
94
- admin approval creates the user, adds team membership, and sends the
95
- Supabase invite flow automatically.
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
- 6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
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
- if (containsSecretLikeContent(raw.toString("utf8"))) {
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: "secret_like_content_guard",
621
+ reason: sanitized.reason,
611
622
  });
612
623
  return false;
613
624
  }
614
- const contentHash = sha256(raw);
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, raw.byteLength);
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, raw, { mode: 0o600 });
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: options.redactedSummary,
647
- bytes: raw,
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
- if (containsSecretLikeContent(diff.stdout)) {
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: "secret_like_content_guard",
756
+ reason: sanitized.reason,
705
757
  });
706
758
  continue;
707
759
  }
708
- const raw = Buffer.from(diff.stdout, "utf8");
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: diff.truncated
741
- ? `Raw git ${target.label} diff truncated to the capture cap and preserved locally with env/secret paths excluded.`
742
- : `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
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
- : hasGaps
984
- ? ["Evidence is incomplete; downstream analysis should lower confidence."]
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);
@@ -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({ scopePath: options.scopePath });
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, options.scopePath);
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({ scopePath: options.scopePath });
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, options.scopePath);
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 scopePath = options.scopePath ? path.resolve(options.scopePath) : null;
101
- const scopeLine = scopePath
102
- ? `- This guidance only applies when the current working directory is inside the Cockpit-onboarded workspace/repo: \`${scopePath}\`. Outside that folder, do not run Cockpit ticket binding or sync commands for private chats or unrelated repos.`
103
- : "- 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.";
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 tied to 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.",
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, ask once for the Linear ticket ID before editing. Agents cannot reliably infer it from context.",
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, scopePath) {
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, scopePath)) {
166
+ if (hasEquivalentUnmanagedTicketBinding(contents, scopePaths)) {
144
167
  return { next: null, state: "equivalent" };
145
168
  }
146
- const staleBlock = findStaleUnmanagedTicketBindingBlock(contents, scopePath);
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, scopePath) {
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, scopePath))
182
+ if (hasEquivalentUnmanagedTicketBinding(contents, scopePaths))
160
183
  return "equivalent";
161
- if (findStaleUnmanagedTicketBindingBlock(contents, scopePath))
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, scopePath) {
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 && !hasScopePath(text, 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 >= 5;
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, scopePath) {
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, scopePath)) {
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 workDir = path.resolve(options.repoRoot ?? process.cwd());
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 command = `cockpit sync --workspace ${shellQuote(options.workDir)}${dashboardArg} --json`;
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