@bli-cockpit/cli 0.2.242 → 0.2.244

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.
Files changed (135) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-budget-admission.js +63 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +6 -50
  4. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  5. package/dist/agent-rules.js +8 -0
  6. package/dist/autostart-contract.js +12 -0
  7. package/dist/autostart-darwin-plist.js +2 -2
  8. package/dist/autostart-darwin.js +1 -1
  9. package/dist/autostart-remind.js +245 -0
  10. package/dist/autostart-self-heal.js +17 -1
  11. package/dist/autostart-windows-scripts.js +7 -7
  12. package/dist/autostart.js +16 -15
  13. package/dist/commands/approvals-check-where.js +14 -0
  14. package/dist/commands/approvals-render.js +8 -1
  15. package/dist/commands/approvals.js +3 -2
  16. package/dist/commands/autostart-command.js +10 -0
  17. package/dist/commands/autostart-heal.js +36 -1
  18. package/dist/commands/brain.js +1 -1
  19. package/dist/commands/cal-agenda.js +44 -0
  20. package/dist/commands/cal-availability-share.js +97 -0
  21. package/dist/commands/cal-booking.js +147 -0
  22. package/dist/commands/cal-join.js +62 -0
  23. package/dist/commands/cal-links.js +68 -0
  24. package/dist/commands/cal-people.js +136 -0
  25. package/dist/commands/cal-remind.js +176 -0
  26. package/dist/commands/cal-reminders.js +33 -0
  27. package/dist/commands/cal-suggest.js +89 -0
  28. package/dist/commands/cal.js +41 -1
  29. package/dist/commands/cycle-args.js +127 -0
  30. package/dist/commands/cycles.js +117 -0
  31. package/dist/commands/docs-comments-args.js +85 -0
  32. package/dist/commands/docs-comments.js +128 -0
  33. package/dist/commands/docs-files-args.js +49 -0
  34. package/dist/commands/docs-files.js +179 -0
  35. package/dist/commands/docs-formatting.js +45 -0
  36. package/dist/commands/docs-inbox-args.js +93 -0
  37. package/dist/commands/docs-inbox.js +126 -0
  38. package/dist/commands/docs-notion.js +5 -0
  39. package/dist/commands/docs-read.js +2 -1
  40. package/dist/commands/docs-recovery.js +173 -0
  41. package/dist/commands/docs-refs-args.js +104 -0
  42. package/dist/commands/docs-refs.js +153 -0
  43. package/dist/commands/docs-search-args.js +79 -0
  44. package/dist/commands/docs-search.js +64 -0
  45. package/dist/commands/docs-sync-args.js +82 -0
  46. package/dist/commands/docs-sync.js +197 -0
  47. package/dist/commands/docs-tree.js +136 -0
  48. package/dist/commands/docs.js +85 -51
  49. package/dist/commands/doctor-update.js +4 -1
  50. package/dist/commands/inbox-args.js +81 -0
  51. package/dist/commands/inbox.js +88 -0
  52. package/dist/commands/issue-batch.js +61 -0
  53. package/dist/commands/issue-relations.js +151 -0
  54. package/dist/commands/issue-write.js +38 -5
  55. package/dist/commands/issue.js +32 -1
  56. package/dist/commands/local-args-collector-sources.js +2 -1
  57. package/dist/commands/local-args-tower-approvals.js +16 -4
  58. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  59. package/dist/commands/local-args-tower-cal-links.js +42 -0
  60. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  61. package/dist/commands/local-args-tower-cal-share.js +41 -0
  62. package/dist/commands/local-args-tower-cal.js +81 -8
  63. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  64. package/dist/commands/local-args-tower-msg.js +222 -10
  65. package/dist/commands/local-args-tower-search.js +32 -5
  66. package/dist/commands/local-args-tower-work.js +110 -34
  67. package/dist/commands/local-auth.js +1 -1
  68. package/dist/commands/local-help-commands-tower-ops.js +6 -4
  69. package/dist/commands/local-help-commands-tower.js +47 -31
  70. package/dist/commands/local-help-commands.js +3 -1
  71. package/dist/commands/local-help-cycles.js +9 -0
  72. package/dist/commands/local-help-docs-files.js +10 -0
  73. package/dist/commands/local-help-docs-formatting.js +7 -0
  74. package/dist/commands/local-help-docs-inbox.js +10 -0
  75. package/dist/commands/local-help-docs-recovery.js +10 -0
  76. package/dist/commands/local-help-docs-refs.js +11 -0
  77. package/dist/commands/local-help-docs-sync.js +9 -0
  78. package/dist/commands/local-help-inbox.js +9 -0
  79. package/dist/commands/local-help-project.js +21 -0
  80. package/dist/commands/local-help-templates.js +9 -0
  81. package/dist/commands/local-help-views.js +10 -0
  82. package/dist/commands/local-help-work-notify.js +6 -0
  83. package/dist/commands/local-help.js +3 -3
  84. package/dist/commands/manager-hints.js +4 -2
  85. package/dist/commands/manager-limits.json +3 -1
  86. package/dist/commands/manager-read-reasons.js +1 -0
  87. package/dist/commands/manager-render-home.js +13 -5
  88. package/dist/commands/manager-render.js +3 -0
  89. package/dist/commands/manager.js +3 -1
  90. package/dist/commands/mcp-stdio-probe.js +1 -0
  91. package/dist/commands/msg-activity.js +134 -0
  92. package/dist/commands/msg-channel-args.js +92 -0
  93. package/dist/commands/msg-channel.js +125 -0
  94. package/dist/commands/msg-context-args.js +67 -0
  95. package/dist/commands/msg-context.js +65 -0
  96. package/dist/commands/msg-dm.js +28 -0
  97. package/dist/commands/msg-file.js +185 -0
  98. package/dist/commands/msg-mark-args.js +78 -0
  99. package/dist/commands/msg-mark.js +148 -0
  100. package/dist/commands/msg-notify-args.js +100 -0
  101. package/dist/commands/msg-notify.js +122 -0
  102. package/dist/commands/msg-schedule-args.js +114 -0
  103. package/dist/commands/msg-schedule.js +226 -0
  104. package/dist/commands/msg-search-args.js +71 -0
  105. package/dist/commands/msg-search.js +48 -0
  106. package/dist/commands/msg-write.js +15 -1
  107. package/dist/commands/msg.js +62 -39
  108. package/dist/commands/project-args.js +82 -0
  109. package/dist/commands/project.js +71 -7
  110. package/dist/commands/public-root.js +1 -1
  111. package/dist/commands/search.js +23 -0
  112. package/dist/commands/session-sync-failures.js +17 -4
  113. package/dist/commands/session-sync-health.js +7 -2
  114. package/dist/commands/session-sync-hold.js +42 -1
  115. package/dist/commands/session-sync-outside-roots.js +72 -14
  116. package/dist/commands/session-sync-upload.js +2 -0
  117. package/dist/commands/session-sync.js +1 -0
  118. package/dist/commands/sources-check.js +63 -2
  119. package/dist/commands/sources-discovery.js +1 -1
  120. package/dist/commands/sources-reconciliation.js +87 -0
  121. package/dist/commands/sync-doctor.js +14 -1
  122. package/dist/commands/sync-report.js +1 -1
  123. package/dist/commands/template-args.js +122 -0
  124. package/dist/commands/templates.js +162 -0
  125. package/dist/commands/view-args.js +131 -0
  126. package/dist/commands/views.js +138 -0
  127. package/dist/commands/work-notify-args.js +67 -0
  128. package/dist/commands/work-notify.js +63 -0
  129. package/dist/delivery-hold-notice.js +34 -0
  130. package/dist/evidence-transcripts-client.js +1 -0
  131. package/dist/log-rotation.js +2 -2
  132. package/dist/notifier.js +83 -0
  133. package/dist/process-runner.js +4 -0
  134. package/dist/repo-identity-git.js +1 -0
  135. package/package.json +4 -4
@@ -1,3 +1,25 @@
1
+ /**
2
+ * `cockpit sources check [--older-than <days>] [--json]` — does Tower hold
3
+ * each agent transcript on this machine? (BLI-11122)
4
+ *
5
+ * On 2026-09-30 an agent freeing disk ran `find ~/.codex/... -mtime +60
6
+ * -delete` on the premise that the files were already in Tower; 349 of 1,491
7
+ * had no copy. This door is the machine answer that was missing: for every
8
+ * local Codex and Claude transcript it asks Tower which current objects it
9
+ * holds and answers `stored_full`, `stored_slim` (BLI-17273: Tower holds only the
10
+ * slim form, keep the file), `stored_partial` or `not_stored`, with the reason
11
+ * (`sources-verdict.ts`), judged against THIS machine's image mode.
12
+ *
13
+ * READ-ONLY, and nothing else: Tower never deletes a person's raw transcripts.
14
+ * Deleting one is the person's own act, outside Tower. This command reads the
15
+ * files and the server's list; it never writes, moves or deletes a file under
16
+ * `~/.codex` or `~/.claude`. `docs/runbooks/freeing-disk-on-a-collector-machine.md`.
17
+ *
18
+ * A server that cannot be asked is a failure with its reason (exit 1), never
19
+ * "not stored". A list cut short (`capped`, `failed_partial`) still answers,
20
+ * marked incomplete: a missing page can only understate what Tower holds.
21
+ */
22
+ import { fetchSourceInventory, reconcileSource } from "./sources-reconciliation.js";
1
23
  import { writeLine } from "./cli-io.js";
2
24
  import { readDescribedImageKeys } from "../image-description-state.js";
3
25
  import { resolveImageHandlingMode } from "../image-mode-state.js";
@@ -12,6 +34,10 @@ export async function runSourcesCheck(command, io, now = new Date()) {
12
34
  const startedAt = Date.now();
13
35
  const paths = getCollectorRuntimePaths(command.homeDir);
14
36
  const config = await readLocalCollectorConfig(paths).catch(() => null);
37
+ if (command.reconcile && !config) {
38
+ console.error("[collector sources] reconciliation not run", JSON.stringify({ reason: "local_config_unavailable_opt_out_unknown" }));
39
+ return fail(io, command, "local_config_unavailable_opt_out_unknown", "Local collection preferences could not be read; no transcript store was scanned.");
40
+ }
15
41
  const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
16
42
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
17
43
  if (!session || session.session_state !== "valid" || typeof session.device_token !== "string" || !session.device_token) {
@@ -25,7 +51,9 @@ export async function runSourcesCheck(command, io, now = new Date()) {
25
51
  }
26
52
  const homeDir = command.homeDir ?? paths.home_dir;
27
53
  const olderThanMs = command.olderThanDays === null ? null : now.getTime() - command.olderThanDays * DAY_MS;
28
- const local = await discoverLocalTranscripts({ homeDir, olderThanMs });
54
+ const optOutAll = command.reconcile === true && config?.raw_evidence_upload === "disabled";
55
+ const optOutClaude = command.reconcile === true && config?.collect_claude_jsonl === false;
56
+ const local = await discoverLocalTranscripts({ homeDir, olderThanMs, skipAll: optOutAll, skipClaude: optOutClaude });
29
57
  const cursors = {
30
58
  codex: await readRawEvidenceCursor(paths),
31
59
  claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
@@ -38,6 +66,36 @@ export async function runSourcesCheck(command, io, now = new Date()) {
38
66
  console.error("[collector sources] judging against this machine's image mode", JSON.stringify({ reason: `image_mode_from_${imageMode.source}`, mode: imageMode.mode, slim_aware: imageMode.slimAware }));
39
67
  const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
40
68
  await judgeLocalTranscripts(local.files, remote, cursors, imageMode, describedKeys, report);
69
+ if (command.reconcile) {
70
+ const inventory = await fetchSourceInventory({ dashboardUrl, deviceToken: session.device_token, fetchImpl: io.fetch });
71
+ const { rows: _rows, ...inventoryCounts } = inventory;
72
+ const counts = { missing_row: 0, missing_pointer: 0, stale_revision: 0, unavailable_evidence: 0, explicit_opt_out: 0, unverifiable: 0, revision_verified: 0 };
73
+ const optOutSources = optOutAll ? ["codex", "claude_code"] : optOutClaude ? ["claude_code"] : [];
74
+ counts.explicit_opt_out = optOutSources.length;
75
+ for (const file of local.files) {
76
+ const finding = await reconcileSource({ file, inventory, remote,
77
+ cursor: file.kind === "claude_subagent" ? null : cursors[file.source].sessions[file.session_id] ?? null,
78
+ facts: fileFacts(file, undefined, { mode: imageMode.mode, describedKeys, slimAware: imageMode.slimAware }),
79
+ }).catch(() => ({ state: "unavailable_evidence", reason: "local_revision_unreadable" }));
80
+ counts[finding.state]++;
81
+ const entry = report.files.find(entry => entry.path === file.file_path);
82
+ if (entry)
83
+ entry.reconciliation = finding;
84
+ }
85
+ report.reconciliation = { counts, inventory: inventoryCounts,
86
+ scan: { started_at: new Date(startedAt).toISOString(), finished_at: new Date().toISOString(),
87
+ roots: ["codex/sessions", "codex/archived_sessions", "claude/projects"], older_than_days: command.olderThanDays, opt_out_sources: optOutSources },
88
+ limitations: ["Snapshot scan, not a live completeness guarantee; absent local sources cannot be discovered.", "Missing historical source identifiers remain unverifiable.", "Pointer metadata does not prove bucket bytes are readable; no storage bodies fetched.", "explicit_opt_out counts sources, not undiscovered files; opted-out stores are not scanned."],
89
+ };
90
+ if (inventory.status !== "exhausted" || report.local.unreadable > 0 || counts.unavailable_evidence > 0) {
91
+ report.status = "incomplete";
92
+ report.reason = "reconciliation_partial";
93
+ }
94
+ }
95
+ if (report.local.unreadable > 0) {
96
+ report.status = "incomplete";
97
+ report.reason = "local_scan_incomplete";
98
+ }
41
99
  reportSourcesCheck(report, remote, startedAt);
42
100
  if (command.json)
43
101
  writeLine(io.stdout, JSON.stringify(report, null, 2));
@@ -93,6 +151,7 @@ function reportSourcesCheck(report, remote, startedAt) {
93
151
  stored_partial: report.counts.stored_partial,
94
152
  not_stored: report.counts.not_stored,
95
153
  reasons: report.reasons,
154
+ ...(report.reconciliation ? { reconciliation: report.reconciliation.counts } : {}),
96
155
  elapsed_ms: Date.now() - startedAt,
97
156
  }));
98
157
  }
@@ -135,6 +194,8 @@ export function sourcesCheckLines(report) {
135
194
  ` stored in part ${report.counts.stored_partial.files} file(s), ${mb(report.counts.stored_partial.bytes)} MB`,
136
195
  ` not stored ${report.counts.not_stored.files} file(s), ${mb(report.counts.not_stored.bytes)} MB`,
137
196
  ];
197
+ if (report.reconciliation)
198
+ lines.push(` reconciliation ${Object.entries(report.reconciliation.counts).map(([state, count]) => `${state} ${count}`).join(", ")}`, ` row inventory ${report.reconciliation.inventory.status}; ${report.reconciliation.inventory.unique} unique of ${report.reconciliation.inventory.returned} returned (${report.reconciliation.inventory.requested} requested)`, ` scan ${report.reconciliation.scan.started_at} to ${report.reconciliation.scan.finished_at}; roots ${report.reconciliation.scan.roots.join(", ")}; opted out ${report.reconciliation.scan.opt_out_sources.join(", ") || "none"}`, ...report.reconciliation.limitations.map(reason => ` limitation ${reason}`));
138
199
  const reasons = Object.entries(report.reasons).sort((a, b) => b[1] - a[1]);
139
200
  if (reasons.length > 0)
140
201
  lines.push(` why ${reasons.map(([reason, count]) => `${reason} ${count}`).join(", ")}`);
@@ -144,7 +205,7 @@ export function sourcesCheckLines(report) {
144
205
  lines.push(` unreadable ${report.local.unreadable} file(s) could not be read and are not answered`);
145
206
  lines.push(` Tower's list ${report.remote.status}: ${report.remote.unique} current transcript object(s) over ${report.remote.pages} page(s)`);
146
207
  if (report.status === "incomplete") {
147
- lines.push(` INCOMPLETE Tower's list was cut short (${report.reason}); "not stored" and "stored in part" may be understated. Run it again.`);
208
+ lines.push(` INCOMPLETE The scan or evidence list is incomplete (${report.reason}); missing evidence is inconclusive. Run it again.`);
148
209
  }
149
210
  lines.push("Tower never deletes these files. `--json` lists every file with its path and verdict. Only a \"stored in full\" file is safe to delete by your own hand (never \"stored slim\"); see docs/runbooks/freeing-disk-on-a-collector-machine.md.");
150
211
  return lines;
@@ -153,7 +153,7 @@ function shapeOrder(shape) {
153
153
  }
154
154
  async function findTranscriptFiles(options, roots, result, recordFailure) {
155
155
  const found = [];
156
- const stack = [...roots.codex, roots.claudeProjects];
156
+ const stack = options.skipAll ? [] : [...roots.codex, ...(options.skipClaude ? [] : [roots.claudeProjects])];
157
157
  const seen = new Set();
158
158
  while (stack.length > 0) {
159
159
  const dir = stack.pop();
@@ -0,0 +1,87 @@
1
+ /** Metadata reconciliation only. Never writes cursors, starts collection or fetches bodies. */
2
+ import fs from "node:fs/promises";
3
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
4
+ export async function fetchSourceInventory(options) {
5
+ const rows = new Map();
6
+ const result = { status: "capped", reason: "page_limit", requested: 0, returned: 0, unique: 0, pages: 0, rows: [] };
7
+ let after = null;
8
+ try {
9
+ for (let page = 0; page < (options.maxPages ?? 400); page++) {
10
+ result.requested += 500;
11
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/transcripts?inventory=1`, {
12
+ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.deviceToken}` },
13
+ body: JSON.stringify({ schema_version: "evidence-transcripts.v1", after, limit: 500 }),
14
+ signal: AbortSignal.timeout(30_000),
15
+ });
16
+ if (!response.ok)
17
+ throw new Error(`http_${response.status}`);
18
+ const body = await response.json();
19
+ if (body.schema_version !== "source-reconciliation.v1" || !Array.isArray(body.rows) || body.rows.length > 500 ||
20
+ !(body.next_after === null || (typeof body.next_after === "string" && uuid.test(body.next_after))))
21
+ throw new Error("invalid_response");
22
+ const valid = body.rows.every((row) => row && typeof row.id === "string" && uuid.test(row.id) &&
23
+ typeof row.source === "string" && (row.codex_session_id === null || typeof row.codex_session_id === "string") &&
24
+ (row.raw_evidence_pointer_id === null || (typeof row.raw_evidence_pointer_id === "string" && uuid.test(row.raw_evidence_pointer_id))));
25
+ if (!valid)
26
+ throw new Error("invalid_response");
27
+ const next = body.next_after;
28
+ if (next && (next <= (after ?? "") || body.rows.length === 0 || next !== body.rows.at(-1).id))
29
+ throw new Error("cursor_not_advancing");
30
+ result.pages++;
31
+ result.returned += body.rows.length;
32
+ for (const row of body.rows)
33
+ rows.set(row.id, row);
34
+ if (!next) {
35
+ result.status = "exhausted";
36
+ result.reason = "ok";
37
+ break;
38
+ }
39
+ after = next;
40
+ }
41
+ }
42
+ catch (error) {
43
+ result.status = result.pages ? "failed_partial" : "unavailable";
44
+ result.reason = error instanceof Error && /^(http_\d+|invalid_response|cursor_not_advancing)$/.test(error.message) ? error.message : "request_failed";
45
+ }
46
+ result.rows = [...rows.values()];
47
+ result.unique = rows.size;
48
+ console.error("[collector sources] row inventory", JSON.stringify({ reason: result.reason, status: result.status, requested: result.requested, returned: result.returned, unique: result.unique, pages: result.pages }));
49
+ return result;
50
+ }
51
+ export async function reconcileSource(input) {
52
+ const answer = (state, reason) => ({ state, reason });
53
+ const { file, inventory, remote, cursor, facts } = input;
54
+ if (file.session_id_source === "path_hash")
55
+ return answer("unverifiable", "source_identifier_missing");
56
+ const rows = inventory.rows.filter(row => row.source === file.source && row.codex_session_id === file.session_id);
57
+ if (!rows.length && inventory.rows.some(row => (!row.source || row.source === file.source) && !row.codex_session_id?.trim())) {
58
+ return answer("unverifiable", "historical_source_identifier_missing");
59
+ }
60
+ if (!rows.length)
61
+ return inventory.status === "exhausted"
62
+ ? answer("missing_row", "source_row_absent") : answer("unavailable_evidence", `row_inventory_${inventory.status}`);
63
+ // Duplicate device rows are expected; any usable pointer can answer this file.
64
+ const pointers = new Set(rows.map(row => row.raw_evidence_pointer_id).filter(Boolean));
65
+ if (!pointers.size)
66
+ return inventory.status === "exhausted"
67
+ ? answer("missing_pointer", "source_rows_without_pointer") : answer("unavailable_evidence", `row_inventory_${inventory.status}`);
68
+ const objects = (remote.files.get(file.key) ?? []).filter(object => !object.stub &&
69
+ (file.kind === "claude_subagent" || (object.ref_id && pointers.has(object.ref_id))));
70
+ if (!objects.length)
71
+ return answer("unavailable_evidence", remote.status === "exhausted" ? "pointer_not_in_current_transcripts_or_stub" : `transcripts_${remote.status}`);
72
+ const split = facts.slimSplit ? await facts.slimSplit() : null;
73
+ const form = split?.slim ?? (facts.slimAware && facts.wholeForm ? await facts.wholeForm() : await facts.sanitized());
74
+ const stat = await fs.stat(file.file_path);
75
+ if (stat.size !== file.byte_size || stat.mtimeMs !== file.mtime_ms)
76
+ return answer("unavailable_evidence", "local_file_changed_during_check");
77
+ if (form && objects.some(object => object.content_hash_sha256 === form.sha256 || object.content_hash_sha256 === split?.full.sha256))
78
+ return answer("revision_verified", "current_redacted_hash_matches_pointer");
79
+ // Sync refreshes the observed file hash even when growth damping retains an older
80
+ // durable upload. uploaded_byte_size is the raw source size at that upload.
81
+ if (form && objects.every(object => object.content_hash_sha256 !== null) && cursor?.uploaded_object_key &&
82
+ objects.some(object => object.object_key === cursor.uploaded_object_key || object.ref_id === cursor.uploaded_object_key) &&
83
+ cursor.uploaded_byte_size !== null && cursor.uploaded_byte_size < file.byte_size) {
84
+ return answer("stale_revision", "local_tail_newer_than_cursor_upload");
85
+ }
86
+ return answer("unverifiable", form ? "revision_hash_mismatch_or_legacy_hash_missing" : "redacted_revision_unavailable");
87
+ }
@@ -14,6 +14,7 @@ import { doctorNeedsPerson, sanitizeEventCode } from "./doctor-report.js";
14
14
  import { runSelfUpdate } from "./install-update.js";
15
15
  import { reportInstallEventsBestEffort } from "./install-receipts.js";
16
16
  import { clearDoctorRequest, requestedDoctorId } from "./sync-doctor-request.js";
17
+ import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
17
18
  export async function runDoctorAfterSync(command, io, dashboardUrl, overrides = {}, facts) {
18
19
  if (shouldSuppressFleetReceipts({ dashboardUrl }).suppressed) {
19
20
  console.error("[sync-doctor] checkout does not repair the fleet machine", JSON.stringify({ reason: "receipt_suppressed:dev_build" }));
@@ -69,11 +70,23 @@ export async function runDoctorAfterSync(command, io, dashboardUrl, overrides =
69
70
  await lock.handle.release();
70
71
  }
71
72
  }
73
+ /**
74
+ * Ticket 21952. launchd hands the tick `PATH=/usr/bin:/bin:/usr/sbin:/sbin`, where neither `node` nor `npm` lives, so
75
+ * the tick doctor's `npm view` and `npm install` could not start on any Mac: every macOS receipt read
76
+ * `latest_version_unknown -> fail:npm_install_failed` while already on the newest CLI, and Windows (whose scheduler
77
+ * keeps the user's PATH) read `already_latest`. The scheduled self-update already runs npm with the running node's bin
78
+ * dir on PATH (`envWithNodeRuntimeOnPath`); the tick doctor now does the same. A caller's explicit env still wins.
79
+ */
80
+ export function withNodeRuntimeOnPath(io) {
81
+ const env = envWithNodeRuntimeOnPath(io.env ?? process.env);
82
+ const wrap = (runner) => runner ? (cmd, args, options) => runner(cmd, args, { ...options, env: options?.env ?? env }) : undefined;
83
+ return { exec: wrap(io.exec), interactiveExec: wrap(io.interactiveExec) };
84
+ }
72
85
  async function collectDoctorReceipt(command, io, dashboardUrl, overrides, facts, paths, requestId) {
73
86
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
74
87
  let receipt = null;
75
88
  const quiet = new Writable({ write(_chunk, _encoding, done) { done(); } });
76
- const doctorIo = { ...io, stdout: quiet };
89
+ const doctorIo = { ...io, stdout: quiet, ...withNodeRuntimeOnPath(io) };
77
90
  await runDoctor({ kind: "doctor", alias: "doctor", automaticTick: true, homeDir: command.homeDir, dashboardUrl, json: true, dryRun: false }, doctorIo, {
78
91
  selfUpdate: (updateIo, options) => runSelfUpdate({ ...updateIo, stdout: quiet }, options),
79
92
  runLogin: async () => { throw new Error("automatic_doctor_cannot_prompt"); },
@@ -152,7 +152,7 @@ function syncResult(run) {
152
152
  notice: run.notice,
153
153
  // BLI-4022: unlike the reasons, the hold survives a successful run — it is
154
154
  // what the tick withheld, not why it failed.
155
- holdNotice: run.hold?.notice ?? null,
155
+ holdNotice: run.hold?.notice ?? run.budgetHold?.notice ?? null,
156
156
  sessionsObserved: run.sessions_observed,
157
157
  sessionsOutsideRoot: run.sessions_outside_root,
158
158
  outsideRootDirectories: run.outside_root_directories,
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `cockpit ticket templates [list|show|versions|save|update|apply]` argument parsing (BLI-21779, P11).
3
+ * `templates` is an `issue` action; its flags are read here so `local-args-tower-work.ts` only dispatches,
4
+ * the way `view-args.ts` does for views.
5
+ *
6
+ * Property flags mean the same on save, update and apply: `--description <text|->` (`-` reads stdin),
7
+ * `--state`, `--priority 0-4`, `--size S|M|L`, `--project <name|id>`, `--label <name>` (repeatable).
8
+ * On save and update, the value `none` clears a preset; on apply, `none` clears what the template would
9
+ * have copied. Nothing here carries a title except `apply`, and nothing stores one.
10
+ */
11
+ import { estimateForSize } from "./issue-contracts.js";
12
+ import { optionalNonEmpty, optionalUrl } from "./local-arg-values.js";
13
+ export const TEMPLATE_VERBS = ["list", "show", "versions", "save", "update", "apply"];
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const STATES = ["backlog", "todo", "in_progress", "in_review", "done", "canceled"];
16
+ const NONE = (value) => value.trim().toLowerCase() === "none";
17
+ export function parseTemplateArgs(values, rest) {
18
+ const first = rest[0];
19
+ const verb = (first === undefined ? "list" : first);
20
+ if (!TEMPLATE_VERBS.includes(verb)) {
21
+ throw new Error(`Unknown templates command: ${first}. Try list, show, versions, save, update or apply.`);
22
+ }
23
+ const positional = rest.slice(first === undefined ? 0 : 1);
24
+ const needsRef = verb !== "list" && verb !== "save";
25
+ if (needsRef && positional.length !== 1)
26
+ throw new Error(`ticket templates ${verb} takes exactly one template id or name.`);
27
+ if (!needsRef && positional.length > 0)
28
+ throw new Error(`ticket templates ${verb} does not take "${positional[0]}".`);
29
+ const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
30
+ const writes = verb === "save" || verb === "update" || verb === "apply";
31
+ for (const flag of ["--description", "--state", "--priority", "--size", "--project", "--label"]) {
32
+ if (given(flag) && !writes)
33
+ throw new Error(`${flag} belongs to \`cockpit ticket templates save\`, \`update\` and \`apply\`.`);
34
+ }
35
+ if (given("--name") && verb !== "save" && verb !== "update")
36
+ throw new Error("--name belongs to `cockpit ticket templates save` and `update`.");
37
+ if (verb !== "update" && (given("--archive") || given("--restore") || given("--expected-updated-at"))) {
38
+ throw new Error("--archive, --restore and --expected-updated-at belong to `cockpit ticket templates update`.");
39
+ }
40
+ if (verb !== "apply" && (given("--title") || given("--assignee") || given("--parent") || given("--due"))) {
41
+ throw new Error("--title, --assignee, --parent and --due belong to `cockpit ticket templates apply`; a template never stores them.");
42
+ }
43
+ if (verb !== "list" && given("--include-archived"))
44
+ throw new Error("--include-archived belongs to `cockpit ticket templates list`.");
45
+ if (given("--archive") && given("--restore"))
46
+ throw new Error("Give --archive or --restore, not both.");
47
+ if ((verb === "save" || verb === "apply") === false && given("--request-id")) {
48
+ throw new Error("--request-id belongs to `cockpit ticket templates save` and `apply`.");
49
+ }
50
+ if (given("--expected-revision") || given("--limit") || given("--shared") || given("--private")) {
51
+ throw new Error("That flag does not belong to ticket templates.");
52
+ }
53
+ const name = optionalNonEmpty(values.flags.get("--name"));
54
+ if (verb === "save" && !name)
55
+ throw new Error('ticket templates save needs --name "<name>".');
56
+ const rawDescription = values.flags.get("--description");
57
+ const rawState = optionalNonEmpty(values.flags.get("--state"));
58
+ if (rawState && !NONE(rawState) && !STATES.includes(rawState))
59
+ throw new Error(`--state must be one of: ${STATES.join(", ")} (or none).`);
60
+ const rawPriority = optionalNonEmpty(values.flags.get("--priority"));
61
+ let priority;
62
+ if (rawPriority !== undefined) {
63
+ if (NONE(rawPriority))
64
+ priority = null;
65
+ else {
66
+ priority = Number(rawPriority);
67
+ if (!Number.isInteger(priority) || priority < 0 || priority > 4) {
68
+ throw new Error("--priority must be 0-4 (0 none, 1 urgent, 2 high, 3 medium, 4 low), or the word none to clear it.");
69
+ }
70
+ }
71
+ }
72
+ const rawSize = optionalNonEmpty(values.flags.get("--size"));
73
+ const estimate = rawSize === undefined ? undefined : NONE(rawSize) ? null : estimateForSize(rawSize);
74
+ const rawProject = optionalNonEmpty(values.flags.get("--project"));
75
+ const labelValues = (values.flagValues.get("--label") ?? []).map((v) => v.trim()).filter(Boolean);
76
+ const labels = labelValues.length === 1 && NONE(labelValues[0]) ? [] : labelValues;
77
+ const assignee = optionalNonEmpty(values.flags.get("--assignee"));
78
+ if (assignee !== undefined && assignee !== "me" && !UUID.test(assignee))
79
+ throw new Error('--assignee must be "me" or a person\'s uuid.');
80
+ const due = optionalNonEmpty(values.flags.get("--due"));
81
+ if (due && !NONE(due) && !/^\d{4}-\d{2}-\d{2}$/.test(due))
82
+ throw new Error("--due must be YYYY-MM-DD (or none).");
83
+ const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
84
+ if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
85
+ throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
86
+ }
87
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
88
+ if (requestId && !UUID.test(requestId))
89
+ throw new Error("--request-id must be a UUID.");
90
+ const changes = rawDescription !== undefined || rawState !== undefined || priority !== undefined || estimate !== undefined
91
+ || rawProject !== undefined || labelValues.length > 0 || name !== undefined;
92
+ if (verb === "update" && !changes && !given("--archive") && !given("--restore")) {
93
+ throw new Error("ticket templates update needs a field to change: --name, --description, --state, --priority, --size, --project, --label, --archive or --restore.");
94
+ }
95
+ const templates = {
96
+ verb,
97
+ ...(needsRef ? { ref: positional[0] } : {}),
98
+ ...(name ? { name } : {}),
99
+ ...(rawDescription === undefined ? {} : { description: NONE(rawDescription) ? null : rawDescription }),
100
+ ...(rawState === undefined ? {} : { state: NONE(rawState) ? null : rawState }),
101
+ ...(priority === undefined ? {} : { priority }),
102
+ ...(estimate === undefined ? {} : { estimate }),
103
+ ...(rawProject === undefined ? {} : { project: NONE(rawProject) ? null : rawProject }),
104
+ ...(labelValues.length > 0 ? { labels } : {}),
105
+ ...(optionalNonEmpty(values.flags.get("--title")) ? { title: optionalNonEmpty(values.flags.get("--title")) } : {}),
106
+ ...(assignee ? { assignee } : {}),
107
+ ...(optionalNonEmpty(values.flags.get("--parent")) ? { parentRef: optionalNonEmpty(values.flags.get("--parent")) } : {}),
108
+ ...(due === undefined ? {} : { due: NONE(due) ? null : due }),
109
+ ...(given("--archive") ? { archived: true } : given("--restore") ? { archived: false } : {}),
110
+ ...(given("--include-archived") ? { includeArchived: true } : {}),
111
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
112
+ };
113
+ return {
114
+ kind: "issue",
115
+ action: "templates",
116
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
117
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
118
+ ...(requestId ? { requestId } : {}),
119
+ json: values.booleans.has("--json"),
120
+ templates,
121
+ };
122
+ }
@@ -0,0 +1,162 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
3
+ import { BODY_MAX_CHARS, READ_DEADLINE_MS, WRITE_DEADLINE_MS, resolveRefs, sizeForEstimate } from "./issue-contracts.js";
4
+ const TAG = "[ticket templates cli]";
5
+ const presetText = (t) => {
6
+ const parts = [];
7
+ if (t.state)
8
+ parts.push(`state ${t.state}`);
9
+ if (t.priority !== null)
10
+ parts.push(`priority ${t.priority}`);
11
+ if (t.estimate !== null)
12
+ parts.push(`size ${sizeForEstimate(t.estimate) ?? t.estimate}`);
13
+ if (t.project_id)
14
+ parts.push(`project ${t.project_id}`);
15
+ if (t.label_names.length > 0)
16
+ parts.push(`labels ${t.label_names.join(", ")}`);
17
+ return parts.length === 0 ? "no presets" : parts.join(", ");
18
+ };
19
+ const templateLine = (t) => `${t.id} ${t.name} [v${t.version}${t.archived_at ? ", archived" : ""}${t.mine ? ", yours" : ""}] ${presetText(t)}`;
20
+ /** `--json` rows get a `size` letter beside the raw estimate, like every ticket read. */
21
+ const withSize = (row) => ({ ...row, size: sizeForEstimate(row.estimate ?? null) });
22
+ export async function runTemplates(command, io, door) {
23
+ const t = command.templates;
24
+ const ref = t.ref ? encodeURIComponent(t.ref) : "";
25
+ switch (t.verb) {
26
+ case "list": {
27
+ const answer = await askAgentDoor(door, {
28
+ path: `/api/work/templates${t.includeArchived ? "?include_archived=true" : ""}`,
29
+ method: "GET", label: "ticket templates list", timeoutMs: READ_DEADLINE_MS,
30
+ });
31
+ if (!answer.ok)
32
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
33
+ const body = answer.body;
34
+ const templates = body.templates ?? [];
35
+ if (door.json)
36
+ return emitAgentDoor(door, { ok: true, ...body, templates: templates.map(withSize) });
37
+ if (templates.length === 0)
38
+ writeLine(door.io.stdout, "No templates yet. Save one with `cockpit ticket templates save --name <name>`.");
39
+ for (const row of templates)
40
+ writeLine(door.io.stdout, templateLine(row));
41
+ if (body.archived_hidden)
42
+ writeLine(door.io.stdout, `\n${body.archived_hidden} archived template(s) not shown (--include-archived).`);
43
+ return 0;
44
+ }
45
+ case "show": {
46
+ const answer = await askAgentDoor(door, { path: `/api/work/templates/${ref}`, method: "GET", label: "ticket templates show", timeoutMs: READ_DEADLINE_MS });
47
+ if (!answer.ok)
48
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
49
+ const body = answer.body;
50
+ if (door.json)
51
+ return emitAgentDoor(door, { ok: true, template: withSize(body.template) });
52
+ const row = body.template;
53
+ writeLine(door.io.stdout, templateLine(row));
54
+ writeLine(door.io.stdout, row.description ? `\n${row.description}` : "\n(no description)");
55
+ return 0;
56
+ }
57
+ case "versions": {
58
+ const answer = await askAgentDoor(door, { path: `/api/work/templates/${ref}/versions`, method: "GET", label: "ticket templates versions", timeoutMs: READ_DEADLINE_MS });
59
+ if (!answer.ok)
60
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
61
+ const body = answer.body;
62
+ if (door.json)
63
+ return emitAgentDoor(door, { ok: true, ...body, template: withSize(body.template), versions: (body.versions ?? []).map((v) => withSize(v)) });
64
+ writeLine(door.io.stdout, `${body.template.name}: ${body.versions?.length ?? 0} version(s), current v${body.template.version}.`);
65
+ for (const v of body.versions ?? [])
66
+ writeLine(door.io.stdout, `v${v.version} ${v.created_at} ${v.name}${v.archived ? " (archived)" : ""}`);
67
+ return 0;
68
+ }
69
+ case "save":
70
+ case "update":
71
+ return writeTemplate(command, io, door, ref);
72
+ case "apply":
73
+ return applyTemplate(command, io, door, ref);
74
+ }
75
+ }
76
+ /** `--description -` reads the text from stdin; anything else is the text itself. */
77
+ async function readDescription(value, io) {
78
+ if (value !== "-")
79
+ return { ok: true, text: value };
80
+ if (isInteractiveStdin(io))
81
+ return { ok: false, detail: "--description - reads the text from stdin, and nothing is piped in." };
82
+ try {
83
+ return { ok: true, text: await readPipedText(io.stdin, { maxChars: BODY_MAX_CHARS, overflowMessage: `A template description is limited to ${BODY_MAX_CHARS} characters.` }) };
84
+ }
85
+ catch (error) {
86
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
87
+ }
88
+ }
89
+ async function writeTemplate(command, io, door, ref) {
90
+ const t = command.templates;
91
+ const description = await readDescription(t.description, io);
92
+ if (!description.ok)
93
+ return failAgentDoor(door, TAG, "invalid_body", description.detail);
94
+ const payload = {
95
+ ...(t.verb === "save" && command.requestId ? { request_id: command.requestId } : {}),
96
+ ...(t.verb === "update" && t.expectedUpdatedAt ? { expected_updated_at: t.expectedUpdatedAt } : {}),
97
+ ...(t.name === undefined ? {} : { name: t.name }),
98
+ ...(description.text === undefined ? {} : { description: description.text }),
99
+ ...(t.state === undefined ? {} : { state: t.state }),
100
+ ...(t.priority === undefined ? {} : { priority: t.priority }),
101
+ ...(t.estimate === undefined ? {} : { estimate: t.estimate }),
102
+ ...(t.project === undefined ? {} : { project: t.project }),
103
+ ...(t.labels ? { label_names: t.labels } : {}),
104
+ ...(t.archived === undefined ? {} : { archived: t.archived }),
105
+ };
106
+ const answer = await askAgentDoor(door, {
107
+ path: t.verb === "save" ? "/api/work/templates" : `/api/work/templates/${ref}`,
108
+ method: t.verb === "save" ? "POST" : "PATCH",
109
+ label: `ticket templates ${t.verb}`,
110
+ timeoutMs: WRITE_DEADLINE_MS,
111
+ body: payload,
112
+ });
113
+ if (!answer.ok) {
114
+ const mutation = answer.body?.mutation;
115
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
116
+ }
117
+ const body = answer.body;
118
+ console.error(`${TAG} ${t.verb} done`, JSON.stringify({ template_id: body.template.id, version: body.template.version, replayed: Boolean(body.replayed) }));
119
+ if (door.json)
120
+ return emitAgentDoor(door, { ok: true, ...body, template: withSize(body.template) });
121
+ writeLine(door.io.stdout, `${t.verb === "save" ? (body.replayed ? "Already saved" : "Saved") : "Updated"}: ${body.template.name} (${body.template.id}), version ${body.template.version}${body.template.archived_at ? ", archived" : ""}.`);
122
+ return 0;
123
+ }
124
+ async function applyTemplate(command, io, door, ref) {
125
+ const t = command.templates;
126
+ const description = await readDescription(t.description, io);
127
+ if (!description.ok)
128
+ return failAgentDoor(door, TAG, "invalid_body", description.detail);
129
+ // A project name or a parent identifier is resolved to ids here, the way `ticket create` does.
130
+ const refs = await resolveRefs({ project: t.project ?? undefined, parentRef: t.parentRef }, door);
131
+ if (!refs.ok)
132
+ return failAgentDoor(door, TAG, refs.reason, refs.detail);
133
+ const payload = {
134
+ ...(command.requestId ? { request_id: command.requestId } : {}),
135
+ ...(t.title ? { title: t.title } : {}),
136
+ ...(description.text === undefined ? {} : { description: description.text }),
137
+ ...(t.state === undefined ? {} : { state: t.state }),
138
+ ...(t.priority === undefined ? {} : { priority: t.priority }),
139
+ ...(t.estimate === undefined ? {} : { estimate: t.estimate }),
140
+ ...(t.project === null ? { project_id: null } : refs.projectId ? { project_id: refs.projectId } : {}),
141
+ ...(t.labels ? { labels: t.labels } : {}),
142
+ ...(t.assignee ? { assignee_id: t.assignee } : {}),
143
+ ...(refs.parentId ? { parent_id: refs.parentId } : {}),
144
+ ...(t.due === undefined ? {} : { due_date: t.due }),
145
+ };
146
+ const answer = await askAgentDoor(door, {
147
+ path: `/api/work/templates/${ref}/apply`, method: "POST", label: "ticket templates apply", timeoutMs: WRITE_DEADLINE_MS, body: payload,
148
+ });
149
+ if (!answer.ok) {
150
+ const mutation = answer.body?.mutation;
151
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
152
+ }
153
+ const body = answer.body;
154
+ console.error(`${TAG} apply done`, JSON.stringify({ template_id: body.template?.id, version: body.template?.version, labels: body.labels?.status }));
155
+ if (door.json)
156
+ return emitAgentDoor(door, { ok: true, ...body, issue: withSize(body.issue) });
157
+ writeLine(door.io.stdout, `Created ${body.issue.identifier ?? ""} from template v${body.template?.version ?? "?"}: ${body.issue.title ?? ""}`);
158
+ if (body.labels?.status === "failed") {
159
+ writeLine(door.io.stderr, `The ticket exists but its labels (${(body.labels.names ?? []).join(", ")}) were not applied: ${body.labels.reason}. Add them with \`cockpit ticket label\`.`);
160
+ }
161
+ return 0;
162
+ }