amicus 4.6.2 → 4.7.0

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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +349 -0
  3. package/README.md +24 -13
  4. package/bin/amicus.js +31 -0
  5. package/docs/ROADMAP.md +172 -36
  6. package/docs/configuration.md +56 -6
  7. package/docs/council.md +63 -10
  8. package/docs/doc-system.md +8 -7
  9. package/docs/schemas.md +10 -1
  10. package/docs/troubleshooting.md +27 -1
  11. package/docs/usage.md +68 -15
  12. package/electron/workspace-ui/index.html +3 -0
  13. package/electron/workspace-ui/live-model.js +132 -21
  14. package/electron/workspace-ui/workspace-app.js +20 -4
  15. package/electron/workspace-ui/workspace-lazy.js +233 -0
  16. package/electron/workspace-ui/workspace-matrix.js +12 -1
  17. package/electron/workspace-ui/workspace-panels.js +24 -171
  18. package/electron/workspace-ui/workspace-render.js +15 -5
  19. package/electron/workspace-ui/workspace-seats.js +88 -5
  20. package/electron/workspace-ui/workspace-verbs.js +1 -1
  21. package/electron/workspace-ui/workspace.css +6 -0
  22. package/package.json +5 -2
  23. package/schemas/council-run.schema.json +1 -0
  24. package/schemas/council-stats.schema.json +9 -1
  25. package/schemas/run.schema.json +2 -1
  26. package/schemas/spend.schema.json +1 -1
  27. package/schemas/wave.schema.json +2 -1
  28. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +12 -0
  29. package/skills/second-opinion/MODEL-NOTES.md +5 -4
  30. package/skills/sidecar/SKILL.md +7 -2
  31. package/src/cli-council-run-bench.js +86 -0
  32. package/src/cli-handlers-council-run.js +65 -81
  33. package/src/cli-handlers-council.js +24 -3
  34. package/src/cli-handlers-doctor.js +9 -3
  35. package/src/cli-handlers-fanout.js +179 -0
  36. package/src/cli-handlers-pack.js +24 -10
  37. package/src/cli-handlers-run.js +19 -161
  38. package/src/cli-template-args.js +48 -0
  39. package/src/cli.js +39 -46
  40. package/src/council/debate.js +89 -10
  41. package/src/council/ledger.js +72 -11
  42. package/src/council/presets-cli.js +6 -2
  43. package/src/council/report.js +17 -6
  44. package/src/council/run-assemble.js +15 -3
  45. package/src/council/run-budget.js +2 -2
  46. package/src/council/run-chair.js +70 -11
  47. package/src/council/run-debate.js +51 -67
  48. package/src/council/run-launch.js +9 -2
  49. package/src/council/run-retry.js +4 -1
  50. package/src/council/run-stage1-launch.js +94 -0
  51. package/src/council/run-stage2.js +25 -4
  52. package/src/council/run-stages.js +79 -86
  53. package/src/council/run-state.js +10 -2
  54. package/src/council/run.js +26 -2
  55. package/src/council/tally.js +6 -2
  56. package/src/mcp-council-awareness.js +1 -0
  57. package/src/mcp-council-bench.js +4 -0
  58. package/src/mcp-council-run.js +10 -0
  59. package/src/mcp-server.js +114 -54
  60. package/src/mcp-tools.js +12 -5
  61. package/src/pack/pack-cli.js +1 -1
  62. package/src/pack/pack-forward.js +12 -4
  63. package/src/pack/pack-resolve.js +3 -0
  64. package/src/pack/pack-store.js +20 -3
  65. package/src/pack/pack-validate.js +5 -1
  66. package/src/session-manager.js +6 -2
  67. package/src/sidecar/budget.js +38 -4
  68. package/src/sidecar/fanout-budget.js +1 -2
  69. package/src/sidecar/fanout-leg-fallback.js +7 -3
  70. package/src/sidecar/fanout-wave-io.js +13 -1
  71. package/src/sidecar/fanout.js +11 -9
  72. package/src/sidecar/list-limit.js +50 -0
  73. package/src/sidecar/list-search.js +69 -0
  74. package/src/sidecar/read.js +90 -5
  75. package/src/sidecar/start-metadata.js +58 -0
  76. package/src/sidecar/start.js +8 -43
  77. package/src/sidecar/workspace-auto-open.js +2 -2
  78. package/src/spend-query.js +2 -1
  79. package/src/template/apply.js +7 -4
  80. package/src/template/render.js +6 -2
  81. package/src/template/store.js +1 -1
  82. package/src/utils/alias-audit.js +19 -0
  83. package/src/utils/cli-preflight.js +27 -1
  84. package/src/utils/config.js +15 -0
  85. package/src/utils/curated-models.js +43 -7
  86. package/src/utils/gateway-route-audit.js +16 -3
  87. package/src/utils/model-fetcher.js +8 -6
  88. package/src/utils/remediation-hints.js +14 -0
  89. package/src/utils/result-schema-rebuild.js +1 -0
  90. package/src/utils/result-schema.js +6 -1
  91. package/src/utils/session-index-tmp-sweep.js +18 -3
  92. package/src/utils/session-index.js +1 -0
  93. package/src/utils/session-metadata-tmp-sweep.js +156 -0
  94. package/src/utils/spend-ledger.js +11 -4
  95. package/src/utils/validators.js +16 -0
@@ -8,17 +8,19 @@
8
8
  const https = require('https');
9
9
 
10
10
  /**
11
- * Hardcoded Anthropic models (no public listing endpoint). This is the
12
- * DIRECT-API floor only Fable is OpenRouter-only (see curated-models.js
13
- * DIVERGENT_VENDORS / CARDLESS 'fable' entry, which has no `anthropic` route)
14
- * and must never appear here: classifyModel() returns 'valid' on a floor HIT
15
- * before it ever checks `authoritative`, so listing an OR-only model here
16
- * would mislabel a direct-API request for it as valid.
11
+ * Hardcoded Anthropic floor: the anthropic/ rows a KEYLESS user (or a
12
+ * failed live fetch) gets. Every id here must be one the direct API
13
+ * GENUINELY serves classifyModel() returns 'valid' on a floor HIT before
14
+ * it ever checks `authoritative`, so a speculative row would mislabel a
15
+ * dead direct-API request as valid. (fable joined 2026-08-05 after live
16
+ * verification /v1/models lists claude-fable-5 and a direct smoke leg
17
+ * served; v4.6.3 spec §3.)
17
18
  */
18
19
  const ANTHROPIC_MODELS = [
19
20
  { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', contextLength: null, pricing: null },
20
21
  { id: 'anthropic/claude-opus-4-8', name: 'Claude Opus 4.8', contextLength: null, pricing: null },
21
22
  { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', contextLength: null, pricing: null },
23
+ { id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', contextLength: null, pricing: null },
22
24
  { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
23
25
  // Dated snapshot: the id Anthropic's /v1/models actually lists, and the
24
26
  // `haiku` direct route curated-models.js authors. Without it the floor
@@ -87,6 +87,20 @@ const REMEDIATION_HINTS = Object.freeze({
87
87
  */
88
88
  sweepSessionIndexTmp:
89
89
  'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
90
+
91
+ /**
92
+ * Orphaned per-session metadata.json.*.tmp files (v4.6.3 PR3 Task 3 / D8):
93
+ * same producer shape as sweepSessionIndexTmp above, one level down — a
94
+ * kill between an atomic write's tmp-write and rename leaves a stray temp
95
+ * file in a session directory forever. `doctor --fix` sweeps files older
96
+ * than 60s (never a live writer's ms-lived tmp).
97
+ *
98
+ * Voice ruling (Christian, 2026-08-03, sweepSessionIndexTmp above): applies
99
+ * verbatim here — the cause is definitional, not a guess, so this hint also
100
+ * keeps its confident voice rather than the unverified-cause voice.
101
+ */
102
+ sweepSessionMetadataTmp:
103
+ 'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
90
104
  });
91
105
 
92
106
  module.exports = REMEDIATION_HINTS;
@@ -91,6 +91,7 @@ function buildWaveResultFromSession(project, waveId) {
91
91
  legs,
92
92
  promptMeta: meta.promptMeta || null,
93
93
  ...(meta.pack ? { pack: meta.pack } : {}), // v4.5 Task 13: absent-not-null, mirrors promptMeta's sourcing above.
94
+ ...(meta.tag ? { tag: meta.tag } : {}), // v4.7 F8 (D13): absent-not-null, same idiom as pack above.
94
95
  createdAt: meta.createdAt || null,
95
96
  completedAt: meta.completedAt || null,
96
97
  });
@@ -47,6 +47,7 @@ function durationBetween(createdAt, completedAt) {
47
47
  * @returns {object} run document; `pack` (v4.5 Task 13) is additive — present only when
48
48
  * metadata.pack was recorded (solo session launched via --pack), sourced straight off
49
49
  * `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
50
+ * `tag` (v4.7 F8/D13) is additive the same way — present only when metadata.tag was recorded.
50
51
  */
51
52
  function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
52
53
  const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
@@ -71,6 +72,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
71
72
  opencodeSessionId: metadata.opencodeSessionId || null,
72
73
  usage: usage !== null ? usage : (metadata.usage || null),
73
74
  ...(metadata.pack ? { pack: metadata.pack } : {}),
75
+ ...(metadata.tag ? { tag: metadata.tag } : {}),
74
76
  };
75
77
  }
76
78
 
@@ -127,9 +129,11 @@ function waveExitCode(waveStatus) {
127
129
  * @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
128
130
  * @param {{name: string, version: string, hash: string, source: string}|null} [opts.pack] - v4.5 Task 13:
129
131
  * additive — present only when the wave was launched via --pack (absent, never null, otherwise).
132
+ * @param {string|null} [opts.tag] - v4.7 F8 (D13): additive — present only when the wave was
133
+ * launched via --tag (absent, never null, otherwise).
130
134
  * @returns {object} wave document
131
135
  */
132
- function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null }) {
136
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null, tag = null }) {
133
137
  const { sumWaveUsage } = require('./pricing');
134
138
  // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
135
139
  // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
@@ -157,6 +161,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
157
161
  usage: sumWaveUsage(legs),
158
162
  notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
159
163
  ...(pack ? { pack } : {}),
164
+ ...(tag ? { tag } : {}),
160
165
  };
161
166
  }
162
167
 
@@ -33,9 +33,21 @@ function listSessionIndexTmpFiles() {
33
33
  return entries
34
34
  .filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
35
35
  .map((name) => {
36
- let mtimeMs = null;
37
- try { mtimeMs = fs.statSync(path.join(dir, name)).mtimeMs; } catch { /* raced away skip below */ }
38
- return { name, mtimeMs };
36
+ let st = null;
37
+ // statSync (not lstatSync) is a DECIDED policy, not an oversight (owner
38
+ // ruling, v4.7 PR7 — see BACKLOG.md "session-index-tmp-sweep.js follows
39
+ // symlinks... where its sibling deliberately does not"). This sweep's
40
+ // targets are index tmp files directly in the flat config dir, never
41
+ // traversed through a directory an attacker could redirect — unlike
42
+ // session-metadata-tmp-sweep.js's walk through per-session directories
43
+ // (see that file's "Symlink safety" paragraph for why THAT sweep must
44
+ // never follow). Following here means a symlink named like an orphaned
45
+ // tmp file is still swept (unlink removes only the link); the one real
46
+ // consequence is AGE_THRESHOLD_MS reading the TARGET's mtime, so a fresh
47
+ // link to an old file is swept with no grace window. Filed, not fixed —
48
+ // see the BACKLOG entry above for the full inclusion/unfiled-delta split.
49
+ try { st = fs.statSync(path.join(dir, name)); } catch { /* raced away */ }
50
+ return { name, mtimeMs: st && st.isFile() ? st.mtimeMs : null };
39
51
  })
40
52
  .filter((f) => f.mtimeMs !== null);
41
53
  }
@@ -52,6 +64,9 @@ function unlinkSessionIndexTmp(name) {
52
64
  * this in guard() the same way it wires the mcp-legacy check's inspect/migrate.
53
65
  * @param {{listSessionIndexTmpFiles: () => Array<{name:string, mtimeMs:number}>,
54
66
  * fix?: boolean, now: () => number, unlinkSessionIndexTmp: (name: string) => void}} d
67
+ * Its four `message` strings are byte-identical to
68
+ * session-metadata-tmp-sweep.js's evaluateSessionMetadataTmpSweep by design
69
+ * (only `id`/`name`/`fixDetail` differ) — reword both or neither.
55
70
  */
56
71
  function evaluateSessionIndexTmpSweep(d) {
57
72
  const id = 'sessions-index-tmp'; const name = 'Session index tmp files';
@@ -94,4 +94,5 @@ module.exports = {
94
94
  INDEX_FILENAME,
95
95
  recordSession,
96
96
  lookupSessionProject,
97
+ readIndex,
97
98
  };
@@ -0,0 +1,156 @@
1
+ // src/utils/session-metadata-tmp-sweep.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * v4.6.3 PR3 Task 3 (D8): orphaned per-session metadata.json.*.tmp sweep for
6
+ * `amicus doctor --fix`.
7
+ *
8
+ * A kill between writeFileAtomic's tmp-write and rename (any of the ~30
9
+ * metadata.json write sites — session-manager.js, sidecar/session-finalize.js,
10
+ * etc.) leaves a stray `.metadata.json.<pid>.<hex>.tmp` file in a session
11
+ * directory forever (the B09 orphan class). This module lists and removes
12
+ * them; src/cli-handlers-doctor.js composes the result into a check line.
13
+ * Structurally mirrors utils/session-index-tmp-sweep.js (B15's sibling).
14
+ *
15
+ * Enumeration decision (cwd-scoped, index rejected — recorded at plan time,
16
+ * docs/superpowers/plans/2026-08-05-v463-pr3-cli-doctor-odds.md): this walks
17
+ * `<process.cwd()>/.claude/amicus_sessions/` — each taskId dir plus its
18
+ * `subagents/<id>/` children — rather than consulting sessions-index.json.
19
+ * `amicus doctor` is a per-project surface; the index is best-effort and can
20
+ * point at OTHER projects (issue #40's cross-project fallback exists exactly
21
+ * because the index can be stale). Trusting it here to decide which
22
+ * directories a --fix sweep may unlink from risks touching an unrelated
23
+ * project on stale data — the wrong failure direction for a destructive
24
+ * operation. Walking the cwd-scoped tree directly can only ever find/remove
25
+ * files under the project doctor is already running against.
26
+ *
27
+ * Symlink safety (Task 3 review carry, v4.6.3 PR3): every stat in this walk
28
+ * is lstatSync, never statSync — this module never follows symlinks. A
29
+ * symlinked taskId or subagents directory could otherwise be traversed and
30
+ * have files unlinked through the link, effectively outside the sessions
31
+ * root; lstat closes that off at zero cost.
32
+ *
33
+ * Consequence of the SR-3 isFile() gate (listTmpIn, below): a SYMLINK whose
34
+ * basename matches the tmp pattern is now excluded from the list entirely —
35
+ * neither swept nor reported. Before SR-3 it was swept (unlink removes the
36
+ * link, never the target — a safe success). Deliberate: this module's
37
+ * never-follow policy applies to the entries it unlinks too. Note the sibling
38
+ * session-index-tmp-sweep.js diverges here — it uses statSync, so a
39
+ * symlink-to-a-file with the matching name IS still swept there.
40
+ */
41
+
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+ const HINTS = require('./remediation-hints');
45
+
46
+ /** Files older than this survive to the next --fix, never a live writer's ms-lived tmp. */
47
+ const AGE_THRESHOLD_MS = 60 * 1000;
48
+
49
+ /**
50
+ * The cwd-scoped sessions root: <cwd>/.claude/amicus_sessions.
51
+ * Reads process.cwd() directly, NOT doctor's injected getCwd
52
+ * (cli-handlers-doctor.js's realDeps().getCwd) — the
53
+ * listSessionMetadataTmpFiles/unlinkSessionMetadataTmp deps are wired
54
+ * argument-free in that same realDeps(), so that seam does not reach here.
55
+ * Thread cwd through those deps if a `doctor --cwd <dir>` mode ever lands.
56
+ */
57
+ function sessionsRoot() {
58
+ const { SESSIONS_DIR } = require('../session-manager');
59
+ return path.join(process.cwd(), '.claude', SESSIONS_DIR);
60
+ }
61
+
62
+ /** True when `basename` is an orphaned metadata tmp file (not e.g. progress.json.*.tmp). */
63
+ function isMetadataTmp(basename) {
64
+ return basename.startsWith('.metadata.json.') && basename.endsWith('.tmp');
65
+ }
66
+
67
+ /** List metadata tmp files directly inside `dir`, named relative to `root`. */
68
+ function listTmpIn(dir, root) {
69
+ let entries;
70
+ try { entries = fs.readdirSync(dir); } catch { return []; }
71
+ return entries
72
+ .filter(isMetadataTmp)
73
+ .map((basename) => {
74
+ let st = null;
75
+ try { st = fs.lstatSync(path.join(dir, basename)); } catch { /* raced away */ }
76
+ return { name: path.relative(root, path.join(dir, basename)), mtimeMs: st && st.isFile() ? st.mtimeMs : null };
77
+ })
78
+ .filter((f) => f.mtimeMs !== null);
79
+ }
80
+
81
+ /**
82
+ * List orphaned per-session metadata.json.*.tmp files under the cwd-scoped
83
+ * sessions root, covering both `<taskId>/` and `<taskId>/subagents/<id>/`.
84
+ * @returns {Array<{name: string, mtimeMs: number}>}
85
+ */
86
+ function listSessionMetadataTmpFiles() {
87
+ const { SUBAGENTS_DIR } = require('../session-manager');
88
+ const root = sessionsRoot();
89
+ let taskIds;
90
+ try { taskIds = fs.readdirSync(root); } catch { return []; }
91
+ const found = [];
92
+ for (const taskId of taskIds) {
93
+ const taskDir = path.join(root, taskId);
94
+ let stat;
95
+ try { stat = fs.lstatSync(taskDir); } catch { continue; }
96
+ if (!stat.isDirectory()) { continue; }
97
+ found.push(...listTmpIn(taskDir, root));
98
+
99
+ const subagentsDir = path.join(taskDir, SUBAGENTS_DIR);
100
+ let subIds;
101
+ try { subIds = fs.readdirSync(subagentsDir); } catch { continue; }
102
+ for (const subId of subIds) {
103
+ const subDir = path.join(subagentsDir, subId);
104
+ let subStat;
105
+ try { subStat = fs.lstatSync(subDir); } catch { continue; }
106
+ if (!subStat.isDirectory()) { continue; }
107
+ found.push(...listTmpIn(subDir, root));
108
+ }
109
+ }
110
+ return found;
111
+ }
112
+
113
+ /** Delete one orphaned tmp file by name (relative to the sessions root; never an absolute/caller path). */
114
+ function unlinkSessionMetadataTmp(name) {
115
+ const root = sessionsRoot();
116
+ fs.unlinkSync(path.join(root, name));
117
+ }
118
+
119
+ /**
120
+ * Compose the doctor check line for the metadata tmp-orphan sweep. Pure
121
+ * decision logic (list/sweep side effects come in via `d`); src/cli-handlers-doctor.js
122
+ * wraps this in guard() the same way it wires the sibling sessions-index-tmp check.
123
+ * @param {{listSessionMetadataTmpFiles: () => Array<{name:string, mtimeMs:number}>,
124
+ * fix?: boolean, now: () => number, unlinkSessionMetadataTmp: (name: string) => void}} d
125
+ * The four `message` strings below are byte-identical to the index sibling's
126
+ * (session-index-tmp-sweep.js's evaluateSessionIndexTmpSweep, same four
127
+ * ok/warn returns) on purpose — `id`/`name` and the `fixDetail` wording are
128
+ * the only disambiguators between the two rows. Reword one side and the
129
+ * pairing silently breaks: reword both, or neither.
130
+ */
131
+ function evaluateSessionMetadataTmpSweep(d) {
132
+ const id = 'session-metadata-tmp'; const name = 'Session metadata tmp files';
133
+ const files = d.listSessionMetadataTmpFiles() || [];
134
+ if (files.length === 0) {
135
+ return { id, name, status: 'ok', message: '0 orphaned tmp files', hint: null };
136
+ }
137
+ if (!d.fix) {
138
+ return { id, name, status: 'warn', message: `${files.length} orphaned tmp file(s) — run with --fix`, hint: HINTS.sweepSessionMetadataTmp };
139
+ }
140
+ const nowMs = d.now();
141
+ const sweepable = files.filter((f) => (nowMs - f.mtimeMs) > AGE_THRESHOLD_MS);
142
+ let swept = 0;
143
+ for (const f of sweepable) {
144
+ try { d.unlinkSessionMetadataTmp(f.name); swept += 1; } catch { /* best-effort — report what we got */ }
145
+ }
146
+ const remaining = files.length - swept;
147
+ if (remaining === 0) {
148
+ const fixFields = swept > 0 ? { fixed: true, fixDetail: `swept ${swept} orphaned session-metadata tmp file(s)` } : {};
149
+ return { id, name, status: 'ok', message: `swept ${swept} orphaned tmp file(s)`, hint: null, ...fixFields };
150
+ }
151
+ return { id, name, status: 'warn', message: `swept ${swept}, ${remaining} remaining (too fresh or unremovable)`, hint: HINTS.sweepSessionMetadataTmp };
152
+ }
153
+
154
+ module.exports = {
155
+ AGE_THRESHOLD_MS, listSessionMetadataTmpFiles, unlinkSessionMetadataTmp, evaluateSessionMetadataTmpSweep,
156
+ };
@@ -55,13 +55,17 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
55
55
  * @param {string} [opts.councilName] council name (additive attribution)
56
56
  * @param {string} [opts.project] project directory (additive attribution)
57
57
  * @param {string} [opts.gateway] resolved gateway ('direct'|'openrouter'|'local', additive attribution)
58
+ * @param {string} [opts.tag] user-chosen tag (v4.7 F8 D16, additive attribution) —
59
+ * nullable dim, same null-not-absent convention as councilRunId/project below;
60
+ * deliberately the OPPOSITE convention from createSessionMetadata's tag (D13,
61
+ * absent-not-null) — see the dim-list comment below.
58
62
  * @param {number} [opts.attempt] fallback attempt count (omitted if absent)
59
63
  * @param {string} [opts.substitutedFor] substituted model (omitted if absent)
60
64
  * @param {string} [opts.retryOfWaveId] wave id being retried (omitted if absent)
61
65
  * @param {{dir?:string}} [ctx] test seam — dir overrides getConfigDir()
62
66
  */
63
67
  function appendSpend({ taskId, waveId, model, mode, usage,
64
- op, status, councilRunId, councilName, project, gateway,
68
+ op, status, councilRunId, councilName, project, gateway, tag,
65
69
  attempt, substitutedFor, retryOfWaveId }, ctx = {}) {
66
70
  if (!usage) { return; }
67
71
  try {
@@ -76,15 +80,18 @@ function appendSpend({ taskId, waveId, model, mode, usage,
76
80
  mode: mode || null,
77
81
  tokens: usage.tokens || null,
78
82
  cost: usage.cost || null,
79
- // v4.3 additive attribution (spec 7.1). Nullable dimensions default to
80
- // null (so a row is always groupable); linkage fields are OMITTED unless
81
- // present (they only exist on fallback/retry rows).
83
+ // v4.3 additive attribution (spec 7.1), extended v4.7 F8 D16 with `tag`.
84
+ // Nullable dimensions (op/status/councilRunId/councilName/project/
85
+ // gateway/tag) default to null (so a row is always groupable); linkage
86
+ // fields are OMITTED unless present (they only exist on fallback/retry
87
+ // rows).
82
88
  op: op || null,
83
89
  status: status || null,
84
90
  councilRunId: councilRunId || null,
85
91
  councilName: councilName || null,
86
92
  project: project || null,
87
93
  gateway: gateway || null,
94
+ tag: tag || null,
88
95
  };
89
96
  if (attempt !== undefined) { row.attempt = attempt; }
90
97
  if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
@@ -24,6 +24,20 @@ const { PROVIDER_KEY_MAP } = require('./provider-registry');
24
24
  /** Task ID format: alphanumeric, hyphens, underscores, 1-64 chars */
25
25
  const TASK_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
26
26
 
27
+ const TAG_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
28
+ /**
29
+ * v4.7 F8 (D13): tag validation REJECTS (unlike sanitizeCouncilName, which
30
+ * cleans) — a stored tag is a user-chosen search key, so silent truncation
31
+ * or charset-stripping would make `--search`/`--group-by tag` miss it.
32
+ * Non-string guards the valueless `--tag` parse (cli.js turns it into true).
33
+ */
34
+ function validateTag(value) {
35
+ if (typeof value !== 'string' || !TAG_PATTERN.test(value)) {
36
+ return { ok: false, error: 'Invalid --tag: 1-64 chars, letters/digits/_/- only' };
37
+ }
38
+ return { ok: true, tag: value };
39
+ }
40
+
27
41
  /**
28
42
  * Validate a task ID format (safe for use in file paths)
29
43
  * @param {string} taskId
@@ -249,6 +263,8 @@ module.exports = {
249
263
  MODEL_THINKING_SUPPORT,
250
264
  TASK_ID_PATTERN,
251
265
  validateTaskId,
266
+ TAG_PATTERN,
267
+ validateTag,
252
268
  safeSessionDir,
253
269
  validatePromptContent,
254
270
  validateCwdPath,