@bli-cockpit/cli 0.1.18 → 0.1.20

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
@@ -42,18 +42,25 @@ pairing. Already-onboarded users update with
42
42
  `cockpit sync --workspace "$PWD" --json`. `--repo <path>` remains supported
43
43
  for older prompts and the agent ticket-binding guardrail.
44
44
 
45
- On machines where Codex or Claude agents will do ticketed work, install the
46
- user-scope agent rule once:
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.
47
52
 
48
53
  ```bash
49
- cockpit agent-rules install
54
+ # Repair/manual path, or headless/json onboarding where Cockpit cannot prompt.
55
+ cockpit agent-rules install --workspace "$PWD"
50
56
  ```
51
57
 
52
- That updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with the Cockpit
53
- rule to bind known Linear tickets before edits, or ask once when the ticket ID
54
- is missing. That binding starts attributing the session's work to the specific
55
- ticket in Cockpit. It checks for the managed block first, so current files are
56
- left unchanged and stale managed blocks are replaced.
58
+ The direct command updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with
59
+ the Cockpit rule to bind known Linear tickets before edits, or ask once when
60
+ the ticket ID is missing inside that workspace. That binding starts attributing
61
+ the session's work to the specific ticket in Cockpit. It checks for managed or
62
+ equivalent guidance first, so current files are left unchanged and stale Cockpit
63
+ ticket-binding sections are replaced.
57
64
 
58
65
  Parent mode scans child git repos/worktrees (3 folder levels deep, up to 50
59
66
  repos by default — tune with `--max-depth` / `--max-repos`; a warning prints
@@ -108,6 +115,30 @@ cockpit sync \
108
115
  No ticket is required for setup, chatting, planning, or general ambient capture.
109
116
  Only pass `--ticket` when the work really belongs to a visible ticket.
110
117
 
118
+ When the work is important but ticketless, label it explicitly before syncing so
119
+ later analysis does not have to guess the topic:
120
+
121
+ ```bash
122
+ cockpit start \
123
+ --workspace "$PWD" \
124
+ --topic "lead ingestion rewrite planning" \
125
+ --intent planning \
126
+ --phase discovery \
127
+ --intent-confidence 0.9
128
+
129
+ cockpit sync \
130
+ --workspace "$PWD" \
131
+ --json
132
+ ```
133
+
134
+ Supported `--intent` values are `implementation`, `bug_fix`,
135
+ `root_cause_analysis`, `planning`, `discovery`, `review`, `testing`,
136
+ `documentation`, `release`, `learning`, `coordination`, `maintenance`,
137
+ `analysis`, `unknown`, and `other`. Supported `--phase` values are `planning`,
138
+ `discovery`, `implementation`, `debugging`, `review`, `testing`,
139
+ `documentation`, `release`, `handoff`, `analysis`, `unknown`, and `other`.
140
+ Use `--topic-summary` only for short redacted summaries, not transcript text.
141
+
111
142
  ## What gets saved
112
143
 
113
144
  Local files:
@@ -175,3 +206,5 @@ This public package intentionally excludes Cockpit admin bootstrap commands,
175
206
  service-role credential handling, source maps, tests, and internal runbooks.
176
207
  Clean `npm pack` and `npm publish` run the public CLI build before packaging so
177
208
  `dist/cli.js` is present in emergency releases.
209
+ When collector changes depend on new telemetry-core exports, publish
210
+ `@bli-cockpit/telemetry-core` first, then publish `@bli-cockpit/cli`.
@@ -1,16 +1,15 @@
1
- import { SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
- import { execFile } from "node:child_process";
1
+ import { EvidenceCompletenessPayloadSchema, SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
+ import { spawn } from "node:child_process";
3
3
  import crypto from "node:crypto";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { promisify } from "node:util";
8
7
  import { makeSourceAdapterIdentity, } from "./common.js";
9
8
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
10
- const execFileAsync = promisify(execFile);
11
9
  const DEFAULT_SINCE_MINUTES = 24 * 60;
12
10
  const DEFAULT_SESSION_LIMIT = 50;
13
11
  const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
12
+ const GIT_DIFF_TIMEOUT_MS = 3_000;
14
13
  export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
15
14
  export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
16
15
  // Per-sync upload budgets enforced at COLLECTION time (D7b). With sidecars a
@@ -28,18 +27,65 @@ export async function collectRawEvidencePack(context, options) {
28
27
  const filesDir = path.join(evidenceDir, "files");
29
28
  const entries = [];
30
29
  const skipped = [];
30
+ const truncated = [];
31
31
  const reused = [];
32
+ const sinceMinutes = options.sinceMinutes ?? DEFAULT_SINCE_MINUTES;
33
+ const sessionLimit = options.sessionLimit ?? DEFAULT_SESSION_LIMIT;
34
+ const byteBudget = options.byteBudget ?? RAW_EVIDENCE_DEFAULT_BYTE_BUDGET;
35
+ const objectBudget = options.objectBudget ?? RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET;
32
36
  const collection = {
33
37
  context,
34
38
  filesDir,
35
39
  packId,
36
40
  entries,
37
41
  skipped,
42
+ truncated,
38
43
  reused,
44
+ scanned: new Map(),
45
+ caps: [
46
+ {
47
+ source: "raw_evidence",
48
+ cap_type: "byte_budget",
49
+ limit: byteBudget,
50
+ observed: options.budget?.remainingBytes ?? byteBudget,
51
+ applied: false,
52
+ },
53
+ {
54
+ source: "raw_evidence",
55
+ cap_type: "object_budget",
56
+ limit: objectBudget,
57
+ observed: options.budget?.remainingObjects ?? objectBudget,
58
+ applied: false,
59
+ },
60
+ {
61
+ source: "git_diff",
62
+ cap_type: "max_bytes_per_diff",
63
+ limit: MAX_GIT_DIFF_BYTES,
64
+ applied: false,
65
+ },
66
+ {
67
+ source: "git_diff",
68
+ cap_type: "timeout_ms",
69
+ limit: GIT_DIFF_TIMEOUT_MS,
70
+ applied: false,
71
+ },
72
+ {
73
+ source: "claude_jsonl",
74
+ cap_type: "max_file_bytes",
75
+ limit: CLAUDE_MAX_COLLECT_FILE_BYTES,
76
+ applied: false,
77
+ },
78
+ {
79
+ source: "claude_jsonl_sidecar",
80
+ cap_type: "max_file_bytes",
81
+ limit: CLAUDE_MAX_COLLECT_FILE_BYTES,
82
+ applied: false,
83
+ },
84
+ ],
39
85
  skipContentHashes: options.skipContentHashes ?? new Set(),
40
86
  budget: options.budget ?? {
41
- remainingBytes: options.byteBudget ?? RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
42
- remainingObjects: options.objectBudget ?? RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
87
+ remainingBytes: byteBudget,
88
+ remainingObjects: objectBudget,
43
89
  },
44
90
  index: { value: 0 },
45
91
  };
@@ -50,8 +96,8 @@ export async function collectRawEvidencePack(context, options) {
50
96
  await collectCodexJsonlFiles(collection, {
51
97
  codexSessionFiles: options.codexSessionFiles,
52
98
  sessionsDir: options.sessionsDir,
53
- sinceMinutes: options.sinceMinutes ?? DEFAULT_SINCE_MINUTES,
54
- limit: options.sessionLimit ?? DEFAULT_SESSION_LIMIT,
99
+ sinceMinutes,
100
+ limit: sessionLimit,
55
101
  });
56
102
  }
57
103
  if (options.includeClaudeJsonl !== false && options.claudeSessionFiles) {
@@ -61,6 +107,11 @@ export async function collectRawEvidencePack(context, options) {
61
107
  const deferredByteBudgetCount = skipped.filter((entry) => entry.reason === "deferred_byte_budget").length;
62
108
  const deferredObjectBudgetCount = skipped.filter((entry) => entry.reason === "deferred_object_budget").length;
63
109
  if (entries.length === 0) {
110
+ const evidenceCompleteness = makeEvidenceCompleteness(collection, {
111
+ startedAt,
112
+ finishedAt: context.now.toISOString(),
113
+ sinceMinutes,
114
+ });
64
115
  const facts = {
65
116
  pack_id: packId,
66
117
  manifest_path: path.join(evidenceDir, "manifest.json"),
@@ -73,6 +124,7 @@ export async function collectRawEvidencePack(context, options) {
73
124
  deferred_byte_budget_count: deferredByteBudgetCount,
74
125
  deferred_object_budget_count: deferredObjectBudgetCount,
75
126
  content_kinds: [],
127
+ evidence_completeness: evidenceCompleteness,
76
128
  pointers: [],
77
129
  upload_files: [],
78
130
  reused,
@@ -109,6 +161,11 @@ export async function collectRawEvidencePack(context, options) {
109
161
  bytes: manifestBytes,
110
162
  });
111
163
  entries.push(manifestEntry);
164
+ const evidenceCompleteness = makeEvidenceCompleteness(collection, {
165
+ startedAt,
166
+ finishedAt: context.now.toISOString(),
167
+ sinceMinutes,
168
+ });
112
169
  const facts = {
113
170
  pack_id: packId,
114
171
  manifest_path: manifestPath,
@@ -121,6 +178,7 @@ export async function collectRawEvidencePack(context, options) {
121
178
  deferred_byte_budget_count: deferredByteBudgetCount,
122
179
  deferred_object_budget_count: deferredObjectBudgetCount,
123
180
  content_kinds: [...new Set(entries.map((entry) => entry.kind))],
181
+ evidence_completeness: evidenceCompleteness,
124
182
  pointers: entries.map(pointerFromEntry),
125
183
  upload_files: entries.map((entry) => ({
126
184
  pointer: pointerFromEntry(entry),
@@ -185,20 +243,33 @@ function makeRawEvidenceScan(options) {
185
243
  `bytes:${options.facts.byte_size}`,
186
244
  `skipped:${options.facts.skipped_count}`,
187
245
  `reused:${options.facts.reused_count}`,
246
+ `completeness:${options.facts.evidence_completeness.status}`,
247
+ `truncated:${options.facts.evidence_completeness.totals.truncated_count}`,
248
+ `deferred:${options.facts.evidence_completeness.totals.deferred_count}`,
188
249
  ...options.facts.content_kinds.map((kind) => `kind:${kind}`),
189
250
  ],
190
251
  });
191
252
  }
192
253
  async function collectCodexJsonlFiles(collection, options) {
193
- const candidates = options.codexSessionFiles
254
+ const resolvedCandidates = options.codexSessionFiles
194
255
  ? options.codexSessionFiles.map((file) => ({
195
256
  filePath: file.local_path,
196
257
  codexSessionId: file.codex_session_id,
197
258
  }))
198
259
  : (await walkJsonlFiles(options.sessionsDir ?? path.join(os.homedir(), ".codex", "sessions"), collection.context.now.getTime() - options.sinceMinutes * 60 * 1000))
199
- .slice(0, options.limit)
200
260
  .map((filePath) => ({ filePath, codexSessionId: null }));
261
+ collection.caps.push({
262
+ source: "codex_jsonl",
263
+ cap_type: "session_limit",
264
+ limit: options.limit,
265
+ observed: resolvedCandidates.length,
266
+ applied: !options.codexSessionFiles && resolvedCandidates.length > options.limit,
267
+ });
268
+ const candidates = options.codexSessionFiles
269
+ ? resolvedCandidates
270
+ : resolvedCandidates.slice(0, options.limit);
201
271
  for (const candidate of candidates) {
272
+ recordScanned(collection, "codex_jsonl");
202
273
  const codexSessionId = candidate.codexSessionId ?? shortHash(candidate.filePath);
203
274
  const transcriptAccepted = await collectOneEvidenceFile(collection, {
204
275
  filePath: candidate.filePath,
@@ -220,6 +291,7 @@ async function collectCodexJsonlFiles(collection, options) {
220
291
  }
221
292
  }
222
293
  async function collectClaudeJsonlFiles(collection, sessions) {
294
+ recordScanned(collection, "claude_jsonl", sessions.reduce((count, session) => count + 1 + session.sidecar_files.length, 0));
223
295
  for (const session of sessions) {
224
296
  const sessionId = session.claude_session_id;
225
297
  if (session.main_file_oversized) {
@@ -300,6 +372,7 @@ async function collectAgentImagesFromTranscript(collection, options) {
300
372
  sessionId: options.sessionId,
301
373
  sidecarId: options.sidecarId,
302
374
  });
375
+ recordScanned(collection, options.kind, result.images.length + result.skipped.length);
303
376
  for (const skipped of result.skipped) {
304
377
  collection.skipped.push({
305
378
  kind: options.kind,
@@ -336,6 +409,7 @@ async function collectOneAgentImageFile(collection, options) {
336
409
  }
337
410
  const deferReason = admitToBudget(collection.budget, raw.byteLength);
338
411
  if (deferReason) {
412
+ markBudgetCapApplied(collection, deferReason);
339
413
  collection.skipped.push({
340
414
  kind: options.kind,
341
415
  label: options.image.label,
@@ -392,6 +466,7 @@ async function collectOneEvidenceFile(collection, options) {
392
466
  return false;
393
467
  }
394
468
  if (options.maxFileBytes && raw.byteLength > options.maxFileBytes) {
469
+ markCapApplied(collection, options.kind, "max_file_bytes");
395
470
  collection.skipped.push({
396
471
  kind: options.kind,
397
472
  label: fileName,
@@ -419,6 +494,7 @@ async function collectOneEvidenceFile(collection, options) {
419
494
  }
420
495
  const deferReason = admitToBudget(collection.budget, raw.byteLength);
421
496
  if (deferReason) {
497
+ markBudgetCapApplied(collection, deferReason);
422
498
  collection.skipped.push({
423
499
  kind: options.kind,
424
500
  label: fileName,
@@ -465,10 +541,23 @@ async function collectGitDiffFiles(collection, repoRoot) {
465
541
  { label: "staged", args: ["diff", "--cached", "--no-ext-diff", "--"] },
466
542
  ];
467
543
  for (const target of diffTargets) {
544
+ recordScanned(collection, "git_diff");
468
545
  const diff = await runGitDiff(target.args, repoRoot);
469
- if (!diff.trim())
546
+ if (diff.truncated) {
547
+ markCapApplied(collection, "git_diff", diff.truncationCapType);
548
+ collection.truncated.push({
549
+ kind: "git_diff",
550
+ reason: diff.truncationReason,
551
+ ...(diff.truncationCapType === "max_bytes_per_diff"
552
+ ? { max_bytes: MAX_GIT_DIFF_BYTES }
553
+ : {}),
554
+ observed_bytes: diff.observedBytes,
555
+ included_bytes: Buffer.byteLength(diff.stdout, "utf8"),
556
+ });
557
+ }
558
+ if (!diff.stdout.trim())
470
559
  continue;
471
- if (containsSecretLikeContent(diff)) {
560
+ if (containsSecretLikeContent(diff.stdout)) {
472
561
  collection.skipped.push({
473
562
  kind: "git_diff",
474
563
  label: target.label,
@@ -476,7 +565,7 @@ async function collectGitDiffFiles(collection, repoRoot) {
476
565
  });
477
566
  continue;
478
567
  }
479
- const raw = Buffer.from(diff.slice(0, MAX_GIT_DIFF_BYTES), "utf8");
568
+ const raw = Buffer.from(diff.stdout, "utf8");
480
569
  const contentHash = sha256(raw);
481
570
  if (collection.skipContentHashes.has(contentHash)) {
482
571
  collection.reused.push({
@@ -489,6 +578,7 @@ async function collectGitDiffFiles(collection, repoRoot) {
489
578
  }
490
579
  const deferReason = admitToBudget(collection.budget, raw.byteLength);
491
580
  if (deferReason) {
581
+ markBudgetCapApplied(collection, deferReason);
492
582
  collection.skipped.push({
493
583
  kind: "git_diff",
494
584
  label: target.label,
@@ -507,7 +597,9 @@ async function collectGitDiffFiles(collection, repoRoot) {
507
597
  localPath: destination,
508
598
  relativePath,
509
599
  mediaType: "text/x-diff",
510
- redactedSummary: `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
600
+ redactedSummary: diff.truncated
601
+ ? `Raw git ${target.label} diff truncated to the capture cap and preserved locally with env/secret paths excluded.`
602
+ : `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
511
603
  bytes: raw,
512
604
  contentAddress: `git-diff/${target.label}-${contentHash.slice(0, 16)}.diff`,
513
605
  }));
@@ -526,12 +618,179 @@ async function runGitDiff(args, repoRoot) {
526
618
  ":(exclude)**/*.pem",
527
619
  ":(exclude)**/*.key",
528
620
  ];
529
- const { stdout } = await execFileAsync("git", [...args, ...pathspec], {
530
- cwd: repoRoot,
531
- timeout: 3_000,
532
- maxBuffer: MAX_GIT_DIFF_BYTES + 1024,
621
+ return new Promise((resolve, reject) => {
622
+ const child = spawn("git", [...args, ...pathspec], {
623
+ cwd: repoRoot,
624
+ stdio: ["ignore", "pipe", "pipe"],
625
+ });
626
+ const stdoutChunks = [];
627
+ const stderrChunks = [];
628
+ let observedBytes = 0;
629
+ let includedBytes = 0;
630
+ let truncated = false;
631
+ let timedOut = false;
632
+ const timeout = setTimeout(() => {
633
+ timedOut = true;
634
+ truncated = true;
635
+ child.kill("SIGTERM");
636
+ }, GIT_DIFF_TIMEOUT_MS);
637
+ child.stdout.on("data", (chunk) => {
638
+ observedBytes += chunk.byteLength;
639
+ if (includedBytes < MAX_GIT_DIFF_BYTES) {
640
+ const remaining = MAX_GIT_DIFF_BYTES - includedBytes;
641
+ const next = chunk.subarray(0, remaining);
642
+ stdoutChunks.push(next);
643
+ includedBytes += next.byteLength;
644
+ }
645
+ if (observedBytes > MAX_GIT_DIFF_BYTES) {
646
+ truncated = true;
647
+ child.kill("SIGTERM");
648
+ }
649
+ });
650
+ child.stderr.on("data", (chunk) => {
651
+ if (stderrChunks.reduce((sum, item) => sum + item.byteLength, 0) < 4096) {
652
+ stderrChunks.push(chunk.subarray(0, 4096));
653
+ }
654
+ });
655
+ child.on("error", (error) => {
656
+ clearTimeout(timeout);
657
+ reject(error);
658
+ });
659
+ child.on("close", (code, signal) => {
660
+ clearTimeout(timeout);
661
+ if (code === 0 || truncated || signal === "SIGTERM") {
662
+ resolve({
663
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
664
+ truncated,
665
+ observedBytes,
666
+ truncationReason: timedOut
667
+ ? "git_diff_timeout"
668
+ : "max_git_diff_bytes",
669
+ truncationCapType: timedOut ? "timeout_ms" : "max_bytes_per_diff",
670
+ });
671
+ return;
672
+ }
673
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
674
+ reject(new Error(stderr || `git diff failed with code ${code ?? signal}`));
675
+ });
676
+ });
677
+ }
678
+ function recordScanned(collection, source, count = 1) {
679
+ collection.scanned.set(source, (collection.scanned.get(source) ?? 0) + count);
680
+ }
681
+ function markBudgetCapApplied(collection, reason) {
682
+ markCapApplied(collection, "raw_evidence", reason === "deferred_object_budget" ? "object_budget" : "byte_budget");
683
+ }
684
+ function markCapApplied(collection, source, capType) {
685
+ const cap = collection.caps.find((candidate) => candidate.source === source && candidate.cap_type === capType);
686
+ if (cap)
687
+ cap.applied = true;
688
+ }
689
+ function makeEvidenceCompleteness(collection, options) {
690
+ const sources = new Set(collection.scanned.keys());
691
+ for (const entry of collection.entries)
692
+ sources.add(entry.kind);
693
+ for (const entry of collection.skipped)
694
+ sources.add(entry.kind);
695
+ for (const entry of collection.reused)
696
+ sources.add(entry.kind);
697
+ for (const entry of collection.truncated)
698
+ sources.add(entry.kind);
699
+ const sourceCounts = [...sources].sort().map((source) => {
700
+ const skipped = collection.skipped.filter((entry) => entry.kind === source);
701
+ return {
702
+ source,
703
+ scanned_count: collection.scanned.get(source) ?? 0,
704
+ included_count: collection.entries.filter((entry) => entry.kind === source).length,
705
+ skipped_count: skipped.length,
706
+ truncated_count: collection.truncated.filter((entry) => entry.kind === source).length,
707
+ deferred_count: skipped.filter((entry) => entry.reason.startsWith("deferred_")).length,
708
+ reused_count: collection.reused.filter((entry) => entry.kind === source).length,
709
+ };
710
+ });
711
+ const totals = sourceCounts.reduce((sum, count) => ({
712
+ scanned_count: sum.scanned_count + count.scanned_count,
713
+ included_count: sum.included_count + count.included_count,
714
+ skipped_count: sum.skipped_count + count.skipped_count,
715
+ truncated_count: sum.truncated_count + count.truncated_count,
716
+ deferred_count: sum.deferred_count + count.deferred_count,
717
+ reused_count: sum.reused_count + count.reused_count,
718
+ }), {
719
+ scanned_count: 0,
720
+ included_count: 0,
721
+ skipped_count: 0,
722
+ truncated_count: 0,
723
+ deferred_count: 0,
724
+ reused_count: 0,
725
+ });
726
+ const skipReasonCounts = new Map();
727
+ for (const skipped of collection.skipped) {
728
+ const key = `${skipped.kind}:${skipped.reason}`;
729
+ const existing = skipReasonCounts.get(key);
730
+ if (existing) {
731
+ existing.count += 1;
732
+ }
733
+ else {
734
+ skipReasonCounts.set(key, {
735
+ source: skipped.kind,
736
+ reason: skipped.reason,
737
+ count: 1,
738
+ });
739
+ }
740
+ }
741
+ const truncationCounts = new Map();
742
+ for (const truncated of collection.truncated) {
743
+ const key = `${truncated.kind}:${truncated.reason}`;
744
+ const existing = truncationCounts.get(key);
745
+ if (existing) {
746
+ existing.count += 1;
747
+ existing.observed_bytes = Math.max(existing.observed_bytes ?? 0, truncated.observed_bytes ?? 0);
748
+ existing.included_bytes = Math.max(existing.included_bytes ?? 0, truncated.included_bytes ?? 0);
749
+ }
750
+ else {
751
+ truncationCounts.set(key, {
752
+ source: truncated.kind,
753
+ reason: truncated.reason,
754
+ count: 1,
755
+ ...(truncated.max_bytes !== undefined
756
+ ? { max_bytes: truncated.max_bytes }
757
+ : {}),
758
+ ...(truncated.observed_bytes !== undefined
759
+ ? { observed_bytes: truncated.observed_bytes }
760
+ : {}),
761
+ ...(truncated.included_bytes !== undefined
762
+ ? { included_bytes: truncated.included_bytes }
763
+ : {}),
764
+ });
765
+ }
766
+ }
767
+ const hasGaps = totals.skipped_count > 0 ||
768
+ totals.truncated_count > 0 ||
769
+ totals.deferred_count > 0 ||
770
+ collection.caps.some((cap) => cap.applied);
771
+ const status = totals.included_count + totals.reused_count === 0 && !hasGaps
772
+ ? "empty"
773
+ : hasGaps
774
+ ? "partial"
775
+ : "complete";
776
+ return EvidenceCompletenessPayloadSchema.parse({
777
+ schema_version: "evidence-completeness.v1",
778
+ status,
779
+ generated_at: options.finishedAt,
780
+ scan_window: {
781
+ started_at: options.startedAt,
782
+ finished_at: options.finishedAt,
783
+ since_minutes: options.sinceMinutes,
784
+ },
785
+ source_counts: sourceCounts,
786
+ totals,
787
+ caps: collection.caps,
788
+ skip_reasons: [...skipReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
789
+ truncation_markers: [...truncationCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
790
+ notes: hasGaps
791
+ ? ["Evidence is incomplete; downstream analysis should lower confidence."]
792
+ : [],
533
793
  });
534
- return stdout;
535
794
  }
536
795
  async function walkJsonlFiles(dir, cutoffMs) {
537
796
  const out = [];
@@ -15,7 +15,7 @@ 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({ scopePath: options.scopePath });
19
19
  let existing = "";
20
20
  let existed = true;
21
21
  try {
@@ -24,13 +24,17 @@ async function installAgentRulesForHost(host, options = {}) {
24
24
  catch {
25
25
  existed = false;
26
26
  }
27
- const next = upsertManagedBlock(existing, block);
27
+ const prepared = prepareManagedBlockInstall(existing, block, options.scopePath);
28
+ if (!prepared.next) {
29
+ return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
30
+ }
31
+ const next = prepared.next;
28
32
  if (next === existing) {
29
- return agentRulesResult(host, rulesFile, "unchanged", block);
33
+ return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
30
34
  }
31
35
  await mkdir(path.dirname(rulesFile), { recursive: true });
32
36
  await writeFile(rulesFile, next, "utf8");
33
- return agentRulesResult(host, rulesFile, existed ? "updated" : "created", block);
37
+ return agentRulesResult(host, rulesFile, existed ? "updated" : "created", block, "managed", prepared.state === "stale");
34
38
  }
35
39
  export async function uninstallCodexAgentRules(options = {}) {
36
40
  return uninstallAgentRulesForHost("codex", options);
@@ -50,14 +54,14 @@ async function uninstallAgentRulesForHost(host, options = {}) {
50
54
  existing = await readFile(rulesFile, "utf8");
51
55
  }
52
56
  catch {
53
- return agentRulesResult(host, rulesFile, "missing", block);
57
+ return agentRulesResult(host, rulesFile, "missing", block, "missing");
54
58
  }
55
59
  const next = removeManagedBlock(existing);
56
60
  if (next === existing) {
57
- return agentRulesResult(host, rulesFile, "missing", block);
61
+ return agentRulesResult(host, rulesFile, "missing", block, "missing");
58
62
  }
59
63
  await writeFile(rulesFile, next, "utf8");
60
- return agentRulesResult(host, rulesFile, "updated", block);
64
+ return agentRulesResult(host, rulesFile, "updated", block, "missing");
61
65
  }
62
66
  export async function inspectCodexAgentRules(options = {}) {
63
67
  return inspectAgentRulesForHost("codex", options);
@@ -74,30 +78,39 @@ export async function inspectAgentRules(options = {}) {
74
78
  }
75
79
  async function inspectAgentRulesForHost(host, options = {}) {
76
80
  const rulesFile = agentRulesFile(host, options.homeDir);
77
- const block = cockpitAgentRulesBlock();
81
+ const block = cockpitAgentRulesBlock({ scopePath: options.scopePath });
78
82
  let existing = "";
79
83
  try {
80
84
  existing = await readFile(rulesFile, "utf8");
81
85
  }
82
86
  catch {
83
- return { ...agentRulesResult(host, rulesFile, "missing", block), installed: false };
87
+ return {
88
+ ...agentRulesResult(host, rulesFile, "missing", block, "missing"),
89
+ installed: false,
90
+ };
84
91
  }
85
- const installed = hasManagedBlock(existing);
92
+ const state = inspectAgentRulesContents(existing, block, options.scopePath);
93
+ const installed = state === "managed" || state === "equivalent";
86
94
  return {
87
- ...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block),
95
+ ...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block, state),
88
96
  installed,
89
97
  };
90
98
  }
91
- export function cockpitAgentRulesBlock() {
99
+ 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.";
92
104
  return [
93
105
  MANAGED_BLOCK_START,
94
106
  "## Cockpit Ticket Binding",
95
107
  "",
96
- "- For implementation, debugging, review, PR, or ship work tied to a clear Linear ticket, run `cockpit start --ticket <ticket-id> --repo \"$PWD\"` before the first code edit or mutating tool call. This starts attributing the session's work to that specific ticket in Cockpit.",
108
+ 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.",
97
110
  "- Use `--ticket`; do not invent `--ticketId` or other flag shapes.",
98
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.",
99
112
  "- If there is truly no ticket, state that the work remains in general ambient capture and do not invent a ticket.",
100
- "- After the first meaningful checkpoint, run `cockpit sync --repo \"$PWD\" --json` so Cockpit has fresh ticket/session binding metadata.",
113
+ "- After the first meaningful checkpoint, run `cockpit sync --workspace \"$PWD\" --json` so Cockpit has fresh ticket/session binding metadata.",
101
114
  MANAGED_BLOCK_END,
102
115
  ].join("\n");
103
116
  }
@@ -118,19 +131,52 @@ export function removeManagedBlock(contents) {
118
131
  return contents;
119
132
  return contents.replace(managedBlockPattern(), "").replace(/\n{3,}/gu, "\n\n").trimEnd() + "\n";
120
133
  }
134
+ function prepareManagedBlockInstall(contents, block, scopePath) {
135
+ if (!contents.trim())
136
+ return { next: `${block}\n`, state: "missing" };
137
+ if (hasManagedBlock(contents)) {
138
+ return {
139
+ next: upsertManagedBlock(contents, block),
140
+ state: extractManagedBlock(contents) === block ? "managed" : "stale",
141
+ };
142
+ }
143
+ if (hasEquivalentUnmanagedTicketBinding(contents, scopePath)) {
144
+ return { next: null, state: "equivalent" };
145
+ }
146
+ const staleBlock = findStaleUnmanagedTicketBindingBlock(contents, scopePath);
147
+ if (staleBlock) {
148
+ return {
149
+ next: replaceLineSpan(contents, staleBlock.startLine, staleBlock.endLine, block),
150
+ state: "stale",
151
+ };
152
+ }
153
+ return { next: `${contents.replace(/\s+$/u, "")}\n\n${block}\n`, state: "missing" };
154
+ }
155
+ function inspectAgentRulesContents(contents, block, scopePath) {
156
+ if (hasManagedBlock(contents)) {
157
+ return extractManagedBlock(contents) === block ? "managed" : "stale";
158
+ }
159
+ if (hasEquivalentUnmanagedTicketBinding(contents, scopePath))
160
+ return "equivalent";
161
+ if (findStaleUnmanagedTicketBindingBlock(contents, scopePath))
162
+ return "stale";
163
+ return "missing";
164
+ }
121
165
  function agentRulesFile(host, homeDir = os.homedir()) {
122
166
  if (host === "claude") {
123
167
  return path.join(homeDir, ".claude", "CLAUDE.md");
124
168
  }
125
169
  return path.join(homeDir, ".codex", "AGENTS.md");
126
170
  }
127
- function agentRulesResult(host, rulesFile, status, block) {
171
+ function agentRulesResult(host, rulesFile, status, block, state, staleBlockReplaced = false) {
128
172
  return {
129
173
  host,
130
174
  status,
131
175
  rules_file: rulesFile,
132
176
  agents_file: rulesFile,
133
177
  block,
178
+ state,
179
+ ...(staleBlockReplaced ? { stale_block_replaced: true } : {}),
134
180
  };
135
181
  }
136
182
  function targetHosts(hosts) {
@@ -151,6 +197,78 @@ function aggregateAgentRulesResult(targets) {
151
197
  function managedBlockPattern() {
152
198
  return new RegExp(`${escapeRegExp(MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegExp(MANAGED_BLOCK_END)}`, "u");
153
199
  }
200
+ function extractManagedBlock(contents) {
201
+ return contents.match(managedBlockPattern())?.[0] ?? null;
202
+ }
203
+ function hasEquivalentUnmanagedTicketBinding(contents, scopePath) {
204
+ const text = normalizeRuleText(contents);
205
+ if (!hasTicketBindingCues(text))
206
+ return false;
207
+ if (!hasRepoScopeGuard(text))
208
+ return false;
209
+ if (scopePath && !hasScopePath(text, scopePath))
210
+ return false;
211
+ const signals = [
212
+ /cockpit\s+start\s+--ticket\b/u,
213
+ /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
+ /general\s+ambient/u,
216
+ /cockpit\s+sync\s+--repo|fresh\s+ticket\/session\s+binding\s+metadata/u,
217
+ /use\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
218
+ ];
219
+ const score = signals.filter((signal) => signal.test(text)).length;
220
+ return score >= 5;
221
+ }
222
+ function hasScopePath(text, scopePath) {
223
+ return text.includes(normalizeRuleText(path.resolve(scopePath)));
224
+ }
225
+ function hasRepoScopeGuard(text) {
226
+ return (/only\s+applies\s+when\s+the\s+current\s+working\s+directory\s+is\s+inside/u.test(text) ||
227
+ /outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
228
+ /private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
229
+ }
230
+ function findStaleUnmanagedTicketBindingBlock(contents, scopePath) {
231
+ const lines = contents.split("\n");
232
+ for (let index = 0; index < lines.length; index += 1) {
233
+ if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
234
+ continue;
235
+ }
236
+ let endLine = lines.length;
237
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
238
+ if (/^#{1,6}\s+\S/u.test(lines[cursor] ?? "")) {
239
+ endLine = cursor;
240
+ break;
241
+ }
242
+ }
243
+ const candidate = lines.slice(index, endLine).join("\n");
244
+ const normalized = normalizeRuleText(candidate);
245
+ if (hasTicketBindingCues(normalized) &&
246
+ !hasEquivalentUnmanagedTicketBinding(candidate, scopePath)) {
247
+ return { startLine: index, endLine };
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+ function replaceLineSpan(contents, startLine, endLine, replacement) {
253
+ const lines = contents.split("\n");
254
+ const before = lines.slice(0, startLine).join("\n").trimEnd();
255
+ const after = lines.slice(endLine).join("\n").trimStart();
256
+ return [before, replacement, after]
257
+ .filter((part) => part.trim().length > 0)
258
+ .join("\n\n")
259
+ .replace(/\n{3,}/gu, "\n\n")
260
+ .trimEnd() + "\n";
261
+ }
262
+ function hasTicketBindingCues(text) {
263
+ return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear/u.test(text);
264
+ }
265
+ function normalizeRuleText(contents) {
266
+ return contents
267
+ .toLowerCase()
268
+ .replace(/[`"'<>]/gu, "")
269
+ .replace(/\s+/gu, " ")
270
+ .trim();
271
+ }
154
272
  function escapeRegExp(value) {
155
273
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
156
274
  }
@@ -5,6 +5,7 @@
5
5
  // Behavior-preserving extraction: functions moved verbatim, no logic change.
6
6
  import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
7
7
  import { DEFAULT_AUTOSTART_INTERVAL_SECONDS } from "../autostart.js";
8
+ import { IntentSourceSchema, WorkIntentSchema, WorkPhaseSchema, } from "@bli-cockpit/telemetry-core";
8
9
  const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
9
10
  export function parseLocalArgs(argv) {
10
11
  const command = argv[0];
@@ -165,6 +166,12 @@ function parseStartArgs(args) {
165
166
  "--workspace",
166
167
  "--branch",
167
168
  "--ticket",
169
+ "--topic",
170
+ "--topic-summary",
171
+ "--intent",
172
+ "--phase",
173
+ "--intent-source",
174
+ "--intent-confidence",
168
175
  "--operator-id",
169
176
  "--session-id",
170
177
  "--json",
@@ -177,6 +184,12 @@ function parseStartArgs(args) {
177
184
  "--workspace",
178
185
  "--branch",
179
186
  "--ticket",
187
+ "--topic",
188
+ "--topic-summary",
189
+ "--intent",
190
+ "--phase",
191
+ "--intent-source",
192
+ "--intent-confidence",
180
193
  "--operator-id",
181
194
  "--session-id",
182
195
  "--max-depth",
@@ -184,12 +197,28 @@ function parseStartArgs(args) {
184
197
  ],
185
198
  });
186
199
  assertNoPositionals(values.positionals, "start");
200
+ const topicLabel = optionalNonEmpty(values.flags.get("--topic"));
201
+ const topicSummaryRedacted = optionalNonEmpty(values.flags.get("--topic-summary"));
202
+ const workIntent = optionalSchemaValue(WorkIntentSchema, values.flags.get("--intent"), "--intent");
203
+ const workPhase = optionalSchemaValue(WorkPhaseSchema, values.flags.get("--phase"), "--phase");
204
+ const intentConfidence = optionalConfidence(values.flags.get("--intent-confidence"), "--intent-confidence");
205
+ const explicitIntentMetadata = Boolean(topicLabel ||
206
+ topicSummaryRedacted ||
207
+ workIntent ||
208
+ workPhase ||
209
+ intentConfidence !== undefined);
187
210
  return {
188
211
  kind: "start",
189
212
  homeDir: optionalNonEmpty(values.flags.get("--home")),
190
213
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
191
214
  branch: optionalNonEmpty(values.flags.get("--branch")),
192
215
  activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
216
+ topicLabel,
217
+ topicSummaryRedacted,
218
+ workIntent,
219
+ workPhase,
220
+ intentSource: optionalSchemaValue(IntentSourceSchema, values.flags.get("--intent-source"), "--intent-source") ?? (explicitIntentMetadata ? "explicit_user" : undefined),
221
+ intentConfidence,
193
222
  operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
194
223
  sessionId: optionalNonEmpty(values.flags.get("--session-id")),
195
224
  json: values.booleans.has("--json"),
@@ -345,8 +374,8 @@ function parseAutostartArgs(args) {
345
374
  }
346
375
  function parseAgentRulesArgs(args) {
347
376
  const values = parseNamedArgs(args, {
348
- allowedFlags: ["--home", "--host", "--json"],
349
- valueFlags: ["--home", "--host"],
377
+ allowedFlags: ["--home", "--host", "--repo", "--workspace", "--json"],
378
+ valueFlags: ["--home", "--host", "--repo", "--workspace"],
350
379
  });
351
380
  if (values.positionals.length > 1) {
352
381
  throw new Error("agent-rules accepts at most one action (install|uninstall|status).");
@@ -360,6 +389,7 @@ function parseAgentRulesArgs(args) {
360
389
  action,
361
390
  host: parseAgentRulesHost(values.flags.get("--host")),
362
391
  homeDir: optionalNonEmpty(values.flags.get("--home")),
392
+ repoRoot: optionalNonEmpty(workRootFlagValue(values)),
363
393
  json: values.booleans.has("--json"),
364
394
  };
365
395
  }
@@ -443,6 +473,25 @@ function optionalPositiveInteger(value, flag) {
443
473
  }
444
474
  return parsed;
445
475
  }
476
+ function optionalConfidence(value, flag) {
477
+ if (value === undefined)
478
+ return undefined;
479
+ const parsed = Number(value);
480
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
481
+ throw new Error(`${flag} must be a number between 0 and 1.`);
482
+ }
483
+ return parsed;
484
+ }
485
+ function optionalSchemaValue(schema, value, flag) {
486
+ const trimmed = optionalNonEmpty(value);
487
+ if (!trimmed)
488
+ return undefined;
489
+ const parsed = schema.safeParse(trimmed);
490
+ if (!parsed.success || parsed.data === undefined) {
491
+ throw new Error(`${flag} has an unsupported value.`);
492
+ }
493
+ return parsed.data;
494
+ }
446
495
  export function normalizeUrl(value) {
447
496
  const trimmed = value.trim().replace(/\/+$/, "");
448
497
  if (!trimmed)
@@ -80,13 +80,13 @@ export function localCommandHelp(command) {
80
80
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
81
81
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
82
82
  " cockpit logout",
83
- " cockpit start [--ticket <id>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
83
+ " cockpit start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
84
84
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
85
85
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
86
86
  " cockpit sessions [--source codex|claude] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
87
87
  " cockpit serve [--port <port>] [--workspace <path>]",
88
88
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
89
- " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--json]",
89
+ " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
90
90
  "",
91
91
  `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
92
92
  ].join("\n");
@@ -104,6 +104,7 @@ function localSubcommandHelp(command) {
104
104
  `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
105
105
  "Pass --dashboard-url only for staging/custom dashboards or to force a different pairing.",
106
106
  "Run with no flags in a terminal and it prompts for the dashboard email; pass --email to skip the prompt (and on shared/reused machines, where mismatched sessions are re-paired).",
107
+ "Interactive runs also offer to add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md after readiness proof.",
107
108
  ],
108
109
  ],
109
110
  [
@@ -137,11 +138,14 @@ function localSubcommandHelp(command) {
137
138
  [
138
139
  "start",
139
140
  [
140
- "Usage: cockpit start [--ticket <id>] [--workspace <path>] [--branch <name>] [--json]",
141
+ "Usage: cockpit start [--ticket <id>] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
141
142
  "",
142
143
  "Starts local ambient capture. Parent folders start each child git worktree.",
143
144
  "Add --ticket only when the work already has a visible ticket.",
144
- "`--repo <path>` remains supported and is still the canonical agent-rule spelling.",
145
+ "Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
146
+ "Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
147
+ "Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
148
+ "`--repo <path>` remains supported as a backward-compatible alias; use --workspace in agent guidance.",
145
149
  ],
146
150
  ],
147
151
  [
@@ -208,13 +212,12 @@ function localSubcommandHelp(command) {
208
212
  [
209
213
  "agent-rules",
210
214
  [
211
- "Usage: cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--json]",
215
+ "Usage: cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
212
216
  "",
213
217
  "Installs a managed Cockpit Ticket Binding block into ~/.codex/AGENTS.md",
214
218
  "and ~/.claude/CLAUDE.md by default. Pass --host to manage only one.",
215
- "This gives Codex and Claude agents a user-scope rule to run",
216
- "`cockpit start --ticket <id>` before ticketed implementation work, or ask",
217
- "once when the ticket ID is missing.",
219
+ "The block is scoped to --workspace, or the current directory when omitted,",
220
+ "so Codex and Claude only run Cockpit ticket binding inside that onboarded folder.",
218
221
  "Action defaults to `install`.",
219
222
  ],
220
223
  ],
@@ -310,6 +313,52 @@ async function maybeOfferAutostart(command, io) {
310
313
  : "Background autostart installed, but launchctl load reported a problem; check `cockpit autostart status`.");
311
314
  writeLine(io.stdout, `Plist: ${result.plist_path}`);
312
315
  }
316
+ async function maybeOfferAgentRules(command, io) {
317
+ if (command.json || !isInteractiveStdin(io))
318
+ return;
319
+ const scopePath = path.resolve(command.repoRoot ?? process.cwd());
320
+ const current = await inspectAgentRules({
321
+ homeDir: command.homeDir,
322
+ scopePath,
323
+ });
324
+ if (current.installed) {
325
+ writeLine(io.stdout, onboardAgentRulesAlreadyInstalledLine(current));
326
+ return;
327
+ }
328
+ const answer = (await readLine(io, `Add Cockpit ticket-binding rules scoped to ${scopePath} to AGENTS.md and CLAUDE.md? [Y/n] `))
329
+ .trim()
330
+ .toLowerCase();
331
+ if (answer === "n" || answer === "no") {
332
+ writeLine(io.stdout, "Skipped agent rules. Run `cockpit agent-rules install --workspace \"$PWD\"` anytime.");
333
+ return;
334
+ }
335
+ const result = await installAgentRules({ homeDir: command.homeDir, scopePath });
336
+ writeLine(io.stdout, `Agent rules: ${onboardAgentRulesInstallLine(result)}`);
337
+ for (const target of result.targets) {
338
+ writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
339
+ }
340
+ }
341
+ function onboardAgentRulesAlreadyInstalledLine(result) {
342
+ const hasEquivalent = result.targets.some((target) => target.state === "equivalent");
343
+ return hasEquivalent
344
+ ? "Agent rules: matching Cockpit ticket-binding guidance already exists."
345
+ : "Agent rules: already current in AGENTS.md and CLAUDE.md.";
346
+ }
347
+ function onboardAgentRulesInstallLine(result) {
348
+ if (result.targets.some((target) => target.stale_block_replaced)) {
349
+ return "updated; replaced stale Cockpit ticket-binding guidance.";
350
+ }
351
+ switch (result.status) {
352
+ case "created":
353
+ return "installed.";
354
+ case "updated":
355
+ return "updated.";
356
+ case "unchanged":
357
+ return "already current.";
358
+ case "missing":
359
+ return "not installed.";
360
+ }
361
+ }
313
362
  async function runOnboard(command, io) {
314
363
  let install = null;
315
364
  let pair = null;
@@ -383,8 +432,10 @@ async function runOnboard(command, io) {
383
432
  codex_sessions: multi.codex_sessions,
384
433
  }, null, 2));
385
434
  }
386
- if (multi.ok)
435
+ if (multi.ok) {
436
+ await maybeOfferAgentRules(command, io);
387
437
  await maybeOfferAutostart(command, io);
438
+ }
388
439
  return multi.ok ? 0 : 1;
389
440
  }
390
441
  const context = await startLocalWorkContext({
@@ -449,6 +500,7 @@ async function runOnboard(command, io) {
449
500
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
450
501
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
451
502
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
503
+ await maybeOfferAgentRules(command, io);
452
504
  await maybeOfferAutostart(command, io);
453
505
  return 0;
454
506
  }
@@ -730,7 +782,7 @@ function nextStepForOnboardBlocker(blocker) {
730
782
  case "install":
731
783
  return "Rerun `cockpit onboard` from the repo root; it will reinstall local config.";
732
784
  case "work_context":
733
- return "Run `cockpit start --ticket <id> --repo \"$PWD\"`, then retry `cockpit sync`.";
785
+ return "Run `cockpit start --ticket <id> --workspace \"$PWD\"`, then retry `cockpit sync`.";
734
786
  default:
735
787
  return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
736
788
  }
@@ -754,6 +806,12 @@ async function runStart(command, io) {
754
806
  repoRoot: worktree.repo_root,
755
807
  branch: command.branch,
756
808
  activeTicketId: command.activeTicketId,
809
+ topicLabel: command.topicLabel,
810
+ topicSummaryRedacted: command.topicSummaryRedacted,
811
+ workIntent: command.workIntent,
812
+ workPhase: command.workPhase,
813
+ intentSource: command.intentSource,
814
+ intentConfidence: command.intentConfidence,
757
815
  operatorId: command.operatorId,
758
816
  sessionId: command.sessionId,
759
817
  })));
@@ -776,6 +834,9 @@ async function runStart(command, io) {
776
834
  writeLine(io.stdout, `Repo: ${context.repo}`);
777
835
  writeLine(io.stdout, `Branch: ${context.branch}`);
778
836
  writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
837
+ if (context.topic_label || context.work_intent || context.work_phase) {
838
+ writeLine(io.stdout, `Topic: ${context.topic_label ?? "unlabeled"} · ${context.work_intent ?? "unknown"} · ${context.work_phase ?? "unknown"}`);
839
+ }
779
840
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
780
841
  return 0;
781
842
  }
@@ -1035,11 +1096,12 @@ async function runAutostart(command, io) {
1035
1096
  }
1036
1097
  async function runAgentRules(command, io) {
1037
1098
  const hosts = agentRuleHosts(command.host);
1099
+ const scopePath = path.resolve(command.repoRoot ?? process.cwd());
1038
1100
  const result = command.action === "install"
1039
- ? await installAgentRules({ homeDir: command.homeDir, hosts })
1101
+ ? await installAgentRules({ homeDir: command.homeDir, hosts, scopePath })
1040
1102
  : command.action === "uninstall"
1041
1103
  ? await uninstallAgentRules({ homeDir: command.homeDir, hosts })
1042
- : await inspectAgentRules({ homeDir: command.homeDir, hosts });
1104
+ : await inspectAgentRules({ homeDir: command.homeDir, hosts, scopePath });
1043
1105
  if (command.json) {
1044
1106
  writeLine(io.stdout, JSON.stringify(result, null, 2));
1045
1107
  return 0;
@@ -25,9 +25,9 @@ function cockpitHelp() {
25
25
  "Install/update: `npm install -g @bli-cockpit/cli@latest`.",
26
26
  "Intern path: run `cockpit onboard` from the repo root; add `--ticket <id>` only when work already has a ticket.",
27
27
  "Already onboarded: run `cockpit sync --workspace \"$PWD\" --json`.",
28
- "Agent setup: run `cockpit agent-rules install` so Codex asks for or binds Linear tickets before edits.",
28
+ "Agent setup: interactive `cockpit onboard` offers AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace \"$PWD\"` for repair/headless setup.",
29
29
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
30
- "Manual collector path: `install`, `login`, `start [--ticket <id>]`, `sync`, `status`, `agent-rules`.",
30
+ "Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
31
31
  ].join("\n");
32
32
  }
33
33
 
@@ -170,6 +170,12 @@ export async function startLocalWorkContext(options = {}) {
170
170
  updated_at: now.toISOString(),
171
171
  active_ticket_id: options.activeTicketId ?? undefined,
172
172
  ticket_binding_candidates: ticketBindingCandidates,
173
+ topic_label: options.topicLabel,
174
+ topic_summary_redacted: options.topicSummaryRedacted,
175
+ work_intent: options.workIntent,
176
+ work_phase: options.workPhase,
177
+ intent_source: options.intentSource,
178
+ intent_confidence: options.intentConfidence,
173
179
  pull_request_url: existingContext?.pull_request_url,
174
180
  provenance: {
175
181
  capture_source: "collector_runtime",
@@ -285,7 +291,7 @@ export async function readLocalWorkContextForRepo(paths, repoRoot) {
285
291
  path.resolve(active.repo) === identity.repo_root) {
286
292
  return active;
287
293
  }
288
- throw new Error(`Active work context missing for ${identity.worktree_label}. Run \`cockpit start --repo "${identity.repo_root}"\`.`);
294
+ throw new Error(`Active work context missing for ${identity.worktree_label}. Run \`cockpit start --workspace "${identity.repo_root}"\`.`);
289
295
  }
290
296
  async function readLocalWorkContextByFingerprint(paths, worktreeFingerprint) {
291
297
  return LocalWorkContextSchema.parse(await readJsonFile(workContextFile(paths, worktreeFingerprint)));
package/dist/upload.js CHANGED
@@ -575,6 +575,7 @@ function makeSourceScanCompletedEvent(options) {
575
575
  throw new Error("Upload work context is missing provenance.");
576
576
  }
577
577
  const rawEvidencePointers = options.rawEvidenceFacts?.pointers ?? [];
578
+ const evidenceCompleteness = options.rawEvidenceFacts?.evidence_completeness;
578
579
  const hasRawEvidence = rawEvidencePointers.length > 0;
579
580
  const eventPrivacyClassification = hasRawEvidence
580
581
  ? "redacted_summary"
@@ -617,6 +618,12 @@ function makeSourceScanCompletedEvent(options) {
617
618
  raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
618
619
  raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
619
620
  raw_evidence_reused_count: options.rawEvidenceFacts?.reused_count ?? 0,
621
+ evidence_scanned_count: evidenceCompleteness?.totals.scanned_count ?? 0,
622
+ evidence_included_count: evidenceCompleteness?.totals.included_count ?? 0,
623
+ evidence_skipped_count: evidenceCompleteness?.totals.skipped_count ?? 0,
624
+ evidence_truncated_count: evidenceCompleteness?.totals.truncated_count ?? 0,
625
+ evidence_deferred_count: evidenceCompleteness?.totals.deferred_count ?? 0,
626
+ evidence_reused_count: evidenceCompleteness?.totals.reused_count ?? 0,
620
627
  },
621
628
  attributes: {
622
629
  repo_label: options.context.repo,
@@ -634,7 +641,31 @@ function makeSourceScanCompletedEvent(options) {
634
641
  ? "remote_durable_raw_evidence"
635
642
  : "metadata_only",
636
643
  raw_payload_included: false,
644
+ evidence_completeness_schema_version: evidenceCompleteness?.schema_version ?? "evidence-completeness.v1",
645
+ evidence_completeness_status: evidenceCompleteness?.status ?? "unknown",
646
+ evidence_incomplete: evidenceCompleteness
647
+ ? evidenceCompleteness.status !== "complete"
648
+ : true,
649
+ ...(options.context.topic_label
650
+ ? { topic_label: options.context.topic_label }
651
+ : {}),
652
+ ...(options.context.topic_summary_redacted
653
+ ? { topic_summary_redacted: options.context.topic_summary_redacted }
654
+ : {}),
655
+ ...(options.context.work_intent
656
+ ? { work_intent: options.context.work_intent }
657
+ : {}),
658
+ ...(options.context.work_phase
659
+ ? { work_phase: options.context.work_phase }
660
+ : {}),
661
+ ...(options.context.intent_source
662
+ ? { intent_source: options.context.intent_source }
663
+ : {}),
664
+ ...(options.context.intent_confidence !== undefined
665
+ ? { intent_confidence: options.context.intent_confidence }
666
+ : {}),
637
667
  },
668
+ evidence_completeness: evidenceCompleteness,
638
669
  ticket_binding: options.ticketBinding ?? undefined,
639
670
  risk_flags: options.riskFlags,
640
671
  raw_evidence_pointers: rawEvidencePointers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.5"
29
+ "@bli-cockpit/telemetry-core": "0.1.6"
30
30
  }
31
31
  }