@bli-cockpit/cli 0.1.19 → 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
@@ -46,19 +46,21 @@ On machines where Codex or Claude agents will do ticketed work, interactive
46
46
  `cockpit onboard` checks `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after
47
47
  harvest proof. If equivalent Cockpit ticket-binding guidance already exists, it
48
48
  leaves the files alone. If guidance is missing or clearly stale, it asks whether
49
- to install or replace it.
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.
50
52
 
51
53
  ```bash
52
54
  # Repair/manual path, or headless/json onboarding where Cockpit cannot prompt.
53
- cockpit agent-rules install
55
+ cockpit agent-rules install --workspace "$PWD"
54
56
  ```
55
57
 
56
58
  The direct command updates `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` with
57
59
  the Cockpit rule to bind known Linear tickets before edits, or ask once when
58
- the ticket ID is missing. That binding starts attributing the session's work to
59
- the specific ticket in Cockpit. It checks for managed or equivalent guidance
60
- first, so current files are left unchanged and stale Cockpit ticket-binding
61
- sections are replaced.
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.
62
64
 
63
65
  Parent mode scans child git repos/worktrees (3 folder levels deep, up to 50
64
66
  repos by default — tune with `--max-depth` / `--max-repos`; a warning prints
@@ -113,6 +115,30 @@ cockpit sync \
113
115
  No ticket is required for setup, chatting, planning, or general ambient capture.
114
116
  Only pass `--ticket` when the work really belongs to a visible ticket.
115
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
+
116
142
  ## What gets saved
117
143
 
118
144
  Local files:
@@ -180,3 +206,5 @@ This public package intentionally excludes Cockpit admin bootstrap commands,
180
206
  service-role credential handling, source maps, tests, and internal runbooks.
181
207
  Clean `npm pack` and `npm publish` run the public CLI build before packaging so
182
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,7 +24,7 @@ async function installAgentRulesForHost(host, options = {}) {
24
24
  catch {
25
25
  existed = false;
26
26
  }
27
- const prepared = prepareManagedBlockInstall(existing, block);
27
+ const prepared = prepareManagedBlockInstall(existing, block, options.scopePath);
28
28
  if (!prepared.next) {
29
29
  return agentRulesResult(host, rulesFile, "unchanged", block, prepared.state);
30
30
  }
@@ -78,7 +78,7 @@ export async function inspectAgentRules(options = {}) {
78
78
  }
79
79
  async function inspectAgentRulesForHost(host, options = {}) {
80
80
  const rulesFile = agentRulesFile(host, options.homeDir);
81
- const block = cockpitAgentRulesBlock();
81
+ const block = cockpitAgentRulesBlock({ scopePath: options.scopePath });
82
82
  let existing = "";
83
83
  try {
84
84
  existing = await readFile(rulesFile, "utf8");
@@ -89,23 +89,28 @@ async function inspectAgentRulesForHost(host, options = {}) {
89
89
  installed: false,
90
90
  };
91
91
  }
92
- const state = inspectAgentRulesContents(existing);
92
+ const state = inspectAgentRulesContents(existing, block, options.scopePath);
93
93
  const installed = state === "managed" || state === "equivalent";
94
94
  return {
95
95
  ...agentRulesResult(host, rulesFile, installed ? "unchanged" : "missing", block, state),
96
96
  installed,
97
97
  };
98
98
  }
99
- 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.";
100
104
  return [
101
105
  MANAGED_BLOCK_START,
102
106
  "## Cockpit Ticket Binding",
103
107
  "",
104
- "- 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.",
105
110
  "- Use `--ticket`; do not invent `--ticketId` or other flag shapes.",
106
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.",
107
112
  "- If there is truly no ticket, state that the work remains in general ambient capture and do not invent a ticket.",
108
- "- 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.",
109
114
  MANAGED_BLOCK_END,
110
115
  ].join("\n");
111
116
  }
@@ -126,16 +131,19 @@ export function removeManagedBlock(contents) {
126
131
  return contents;
127
132
  return contents.replace(managedBlockPattern(), "").replace(/\n{3,}/gu, "\n\n").trimEnd() + "\n";
128
133
  }
129
- function prepareManagedBlockInstall(contents, block) {
134
+ function prepareManagedBlockInstall(contents, block, scopePath) {
130
135
  if (!contents.trim())
131
136
  return { next: `${block}\n`, state: "missing" };
132
137
  if (hasManagedBlock(contents)) {
133
- return { next: upsertManagedBlock(contents, block), state: "managed" };
138
+ return {
139
+ next: upsertManagedBlock(contents, block),
140
+ state: extractManagedBlock(contents) === block ? "managed" : "stale",
141
+ };
134
142
  }
135
- if (hasEquivalentUnmanagedTicketBinding(contents)) {
143
+ if (hasEquivalentUnmanagedTicketBinding(contents, scopePath)) {
136
144
  return { next: null, state: "equivalent" };
137
145
  }
138
- const staleBlock = findStaleUnmanagedTicketBindingBlock(contents);
146
+ const staleBlock = findStaleUnmanagedTicketBindingBlock(contents, scopePath);
139
147
  if (staleBlock) {
140
148
  return {
141
149
  next: replaceLineSpan(contents, staleBlock.startLine, staleBlock.endLine, block),
@@ -144,12 +152,13 @@ function prepareManagedBlockInstall(contents, block) {
144
152
  }
145
153
  return { next: `${contents.replace(/\s+$/u, "")}\n\n${block}\n`, state: "missing" };
146
154
  }
147
- function inspectAgentRulesContents(contents) {
148
- if (hasManagedBlock(contents))
149
- return "managed";
150
- if (hasEquivalentUnmanagedTicketBinding(contents))
155
+ function inspectAgentRulesContents(contents, block, scopePath) {
156
+ if (hasManagedBlock(contents)) {
157
+ return extractManagedBlock(contents) === block ? "managed" : "stale";
158
+ }
159
+ if (hasEquivalentUnmanagedTicketBinding(contents, scopePath))
151
160
  return "equivalent";
152
- if (findStaleUnmanagedTicketBindingBlock(contents))
161
+ if (findStaleUnmanagedTicketBindingBlock(contents, scopePath))
153
162
  return "stale";
154
163
  return "missing";
155
164
  }
@@ -188,10 +197,17 @@ function aggregateAgentRulesResult(targets) {
188
197
  function managedBlockPattern() {
189
198
  return new RegExp(`${escapeRegExp(MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegExp(MANAGED_BLOCK_END)}`, "u");
190
199
  }
191
- function hasEquivalentUnmanagedTicketBinding(contents) {
200
+ function extractManagedBlock(contents) {
201
+ return contents.match(managedBlockPattern())?.[0] ?? null;
202
+ }
203
+ function hasEquivalentUnmanagedTicketBinding(contents, scopePath) {
192
204
  const text = normalizeRuleText(contents);
193
205
  if (!hasTicketBindingCues(text))
194
206
  return false;
207
+ if (!hasRepoScopeGuard(text))
208
+ return false;
209
+ if (scopePath && !hasScopePath(text, scopePath))
210
+ return false;
195
211
  const signals = [
196
212
  /cockpit\s+start\s+--ticket\b/u,
197
213
  /before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
@@ -203,7 +219,15 @@ function hasEquivalentUnmanagedTicketBinding(contents) {
203
219
  const score = signals.filter((signal) => signal.test(text)).length;
204
220
  return score >= 5;
205
221
  }
206
- function findStaleUnmanagedTicketBindingBlock(contents) {
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) {
207
231
  const lines = contents.split("\n");
208
232
  for (let index = 0; index < lines.length; index += 1) {
209
233
  if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
@@ -218,7 +242,8 @@ function findStaleUnmanagedTicketBindingBlock(contents) {
218
242
  }
219
243
  const candidate = lines.slice(index, endLine).join("\n");
220
244
  const normalized = normalizeRuleText(candidate);
221
- if (hasTicketBindingCues(normalized) && !hasEquivalentUnmanagedTicketBinding(candidate)) {
245
+ if (hasTicketBindingCues(normalized) &&
246
+ !hasEquivalentUnmanagedTicketBinding(candidate, scopePath)) {
222
247
  return { startLine: index, endLine };
223
248
  }
224
249
  }
@@ -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");
@@ -138,11 +138,14 @@ function localSubcommandHelp(command) {
138
138
  [
139
139
  "start",
140
140
  [
141
- "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]",
142
142
  "",
143
143
  "Starts local ambient capture. Parent folders start each child git worktree.",
144
144
  "Add --ticket only when the work already has a visible ticket.",
145
- "`--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.",
146
149
  ],
147
150
  ],
148
151
  [
@@ -209,13 +212,12 @@ function localSubcommandHelp(command) {
209
212
  [
210
213
  "agent-rules",
211
214
  [
212
- "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]",
213
216
  "",
214
217
  "Installs a managed Cockpit Ticket Binding block into ~/.codex/AGENTS.md",
215
218
  "and ~/.claude/CLAUDE.md by default. Pass --host to manage only one.",
216
- "This gives Codex and Claude agents a user-scope rule to run",
217
- "`cockpit start --ticket <id>` before ticketed implementation work, or ask",
218
- "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.",
219
221
  "Action defaults to `install`.",
220
222
  ],
221
223
  ],
@@ -314,19 +316,23 @@ async function maybeOfferAutostart(command, io) {
314
316
  async function maybeOfferAgentRules(command, io) {
315
317
  if (command.json || !isInteractiveStdin(io))
316
318
  return;
317
- const current = await inspectAgentRules({ homeDir: command.homeDir });
319
+ const scopePath = path.resolve(command.repoRoot ?? process.cwd());
320
+ const current = await inspectAgentRules({
321
+ homeDir: command.homeDir,
322
+ scopePath,
323
+ });
318
324
  if (current.installed) {
319
325
  writeLine(io.stdout, onboardAgentRulesAlreadyInstalledLine(current));
320
326
  return;
321
327
  }
322
- const answer = (await readLine(io, "Add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md? [Y/n] "))
328
+ const answer = (await readLine(io, `Add Cockpit ticket-binding rules scoped to ${scopePath} to AGENTS.md and CLAUDE.md? [Y/n] `))
323
329
  .trim()
324
330
  .toLowerCase();
325
331
  if (answer === "n" || answer === "no") {
326
- writeLine(io.stdout, "Skipped agent rules. Run `cockpit agent-rules install` anytime.");
332
+ writeLine(io.stdout, "Skipped agent rules. Run `cockpit agent-rules install --workspace \"$PWD\"` anytime.");
327
333
  return;
328
334
  }
329
- const result = await installAgentRules({ homeDir: command.homeDir });
335
+ const result = await installAgentRules({ homeDir: command.homeDir, scopePath });
330
336
  writeLine(io.stdout, `Agent rules: ${onboardAgentRulesInstallLine(result)}`);
331
337
  for (const target of result.targets) {
332
338
  writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
@@ -776,7 +782,7 @@ function nextStepForOnboardBlocker(blocker) {
776
782
  case "install":
777
783
  return "Rerun `cockpit onboard` from the repo root; it will reinstall local config.";
778
784
  case "work_context":
779
- 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`.";
780
786
  default:
781
787
  return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
782
788
  }
@@ -800,6 +806,12 @@ async function runStart(command, io) {
800
806
  repoRoot: worktree.repo_root,
801
807
  branch: command.branch,
802
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,
803
815
  operatorId: command.operatorId,
804
816
  sessionId: command.sessionId,
805
817
  })));
@@ -822,6 +834,9 @@ async function runStart(command, io) {
822
834
  writeLine(io.stdout, `Repo: ${context.repo}`);
823
835
  writeLine(io.stdout, `Branch: ${context.branch}`);
824
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
+ }
825
840
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
826
841
  return 0;
827
842
  }
@@ -1081,11 +1096,12 @@ async function runAutostart(command, io) {
1081
1096
  }
1082
1097
  async function runAgentRules(command, io) {
1083
1098
  const hosts = agentRuleHosts(command.host);
1099
+ const scopePath = path.resolve(command.repoRoot ?? process.cwd());
1084
1100
  const result = command.action === "install"
1085
- ? await installAgentRules({ homeDir: command.homeDir, hosts })
1101
+ ? await installAgentRules({ homeDir: command.homeDir, hosts, scopePath })
1086
1102
  : command.action === "uninstall"
1087
1103
  ? await uninstallAgentRules({ homeDir: command.homeDir, hosts })
1088
- : await inspectAgentRules({ homeDir: command.homeDir, hosts });
1104
+ : await inspectAgentRules({ homeDir: command.homeDir, hosts, scopePath });
1089
1105
  if (command.json) {
1090
1106
  writeLine(io.stdout, JSON.stringify(result, null, 2));
1091
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: interactive `cockpit onboard` offers AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install` for repair/headless setup.",
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.19",
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
  }