@sentropic/track 0.86.0 → 0.88.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 (79) hide show
  1. package/dist/cli/bin.js +14 -1
  2. package/dist/cli/bin.js.map +1 -1
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +206 -66
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/install-skills.js +10 -10
  7. package/dist/events/types.d.ts +1 -1
  8. package/dist/events/types.d.ts.map +1 -1
  9. package/dist/events/types.js +3 -0
  10. package/dist/events/types.js.map +1 -1
  11. package/dist/focus-vendor/cli/index.d.ts +1 -1
  12. package/dist/ingest/contract.d.ts +2 -2
  13. package/dist/ingest/contract.d.ts.map +1 -1
  14. package/dist/ingest/contract.js +13 -2
  15. package/dist/ingest/contract.js.map +1 -1
  16. package/dist/ingest/focus-l4.d.ts +1 -1
  17. package/dist/ingest/focus-l4.d.ts.map +1 -1
  18. package/dist/ingest/focus-l4.js +10 -2
  19. package/dist/ingest/focus-l4.js.map +1 -1
  20. package/dist/ingest/ingest.d.ts.map +1 -1
  21. package/dist/ingest/ingest.js +8 -0
  22. package/dist/ingest/ingest.js.map +1 -1
  23. package/dist/ingest/map.d.ts.map +1 -1
  24. package/dist/ingest/map.js +3 -0
  25. package/dist/ingest/map.js.map +1 -1
  26. package/dist/mcp/server.d.ts +2 -2
  27. package/dist/mcp/server.js +2 -2
  28. package/dist/mcp/server.js.map +1 -1
  29. package/dist/model/decision.d.ts +11 -0
  30. package/dist/model/decision.d.ts.map +1 -1
  31. package/dist/model/decision.js +66 -0
  32. package/dist/model/decision.js.map +1 -1
  33. package/dist/read/commands.d.ts +8 -14
  34. package/dist/read/commands.d.ts.map +1 -1
  35. package/dist/read/commands.js +52 -14
  36. package/dist/read/commands.js.map +1 -1
  37. package/dist/read/contract.d.ts +29 -6
  38. package/dist/read/contract.d.ts.map +1 -1
  39. package/dist/read/contract.js +46 -8
  40. package/dist/read/contract.js.map +1 -1
  41. package/dist/report/build.d.ts +26 -1
  42. package/dist/report/build.d.ts.map +1 -1
  43. package/dist/report/build.js +42 -45
  44. package/dist/report/build.js.map +1 -1
  45. package/dist/report/directive.d.ts.map +1 -1
  46. package/dist/report/directive.js +6 -4
  47. package/dist/report/directive.js.map +1 -1
  48. package/dist/report/format.d.ts +185 -11
  49. package/dist/report/format.d.ts.map +1 -1
  50. package/dist/report/format.js +924 -120
  51. package/dist/report/format.js.map +1 -1
  52. package/dist/report/html.d.ts +3 -3
  53. package/dist/report/html.d.ts.map +1 -1
  54. package/dist/report/html.js +41 -44
  55. package/dist/report/html.js.map +1 -1
  56. package/dist/report/index.d.ts +1 -1
  57. package/dist/report/index.d.ts.map +1 -1
  58. package/dist/report/index.js +1 -1
  59. package/dist/report/index.js.map +1 -1
  60. package/dist/report/rollup.d.ts +14 -0
  61. package/dist/report/rollup.d.ts.map +1 -1
  62. package/dist/report/rollup.js +32 -0
  63. package/dist/report/rollup.js.map +1 -1
  64. package/dist/report/snapshot.d.ts +22 -2
  65. package/dist/report/snapshot.d.ts.map +1 -1
  66. package/dist/report/snapshot.js +16 -3
  67. package/dist/report/snapshot.js.map +1 -1
  68. package/dist/report/status-by-level.d.ts +1 -1
  69. package/dist/report/status-by-level.d.ts.map +1 -1
  70. package/dist/report/status-by-level.js +13 -30
  71. package/dist/report/status-by-level.js.map +1 -1
  72. package/dist/state/fold.js +10 -0
  73. package/dist/state/fold.js.map +1 -1
  74. package/dist/track.d.ts +6 -4
  75. package/dist/track.d.ts.map +1 -1
  76. package/dist/track.js +92 -38
  77. package/dist/track.js.map +1 -1
  78. package/package.json +2 -2
  79. package/skills/track-operation/SKILL.md +62 -88
package/dist/cli/bin.js CHANGED
@@ -5,6 +5,17 @@
5
5
  // bin/ — so the installed `track` silently did nothing. A separate entry that just runs is the
6
6
  // same posture as track-mcp's cli.ts and cannot regress that way. `index.ts` stays import-only.
7
7
  import { runCli } from './index.js';
8
+ // Keep the natural drain that preserves large reports, while treating a downstream closed pipe (for
9
+ // example `track report | head`) as normal CLI termination rather than an uncaught stream error.
10
+ for (const stream of [process.stdout, process.stderr]) {
11
+ stream.on('error', (error) => {
12
+ if (error.code === 'EPIPE') {
13
+ process.exitCode ??= 0;
14
+ return;
15
+ }
16
+ throw error;
17
+ });
18
+ }
8
19
  // `runCli` returns `number | Promise<number>` — the `focus` command is async (it dynamically imports the
9
20
  // integrated focus); every other command stays sync and returns a plain number. `Promise.resolve`
10
21
  // normalizes both into one exit path, so a sync command still exits with no added microtask churn beyond a
@@ -13,5 +24,7 @@ Promise.resolve(runCli(process.argv.slice(2), {
13
24
  cwd: process.cwd(),
14
25
  out: (s) => process.stdout.write(s),
15
26
  err: (s) => process.stderr.write(s),
16
- })).then((rc) => process.exit(rc));
27
+ })).then((rc) => {
28
+ process.exitCode = rc;
29
+ });
17
30
  //# sourceMappingURL=bin.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":";AACA,yFAAyF;AACzF,8FAA8F;AAC9F,+FAA+F;AAC/F,+FAA+F;AAC/F,gGAAgG;AAChG,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAEnC,yGAAyG;AACzG,kGAAkG;AAClG,2GAA2G;AAC3G,yBAAyB;AACzB,OAAO,CAAC,OAAO,CACb,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE;IAC5B,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE;IAClB,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACnC,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;CACpC,CAAC,CACH,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA"}
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":";AACA,yFAAyF;AACzF,8FAA8F;AAC9F,+FAA+F;AAC/F,+FAA+F;AAC/F,gGAAgG;AAChG,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAEnC,oGAAoG;AACpG,iGAAiG;AACjG,KAAK,MAAM,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACtD,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAA4B,EAAE,EAAE;QAClD,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YAC3B,OAAO,CAAC,QAAQ,KAAK,CAAC,CAAA;YACtB,OAAM;QACR,CAAC;QACD,MAAM,KAAK,CAAA;IACb,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,yGAAyG;AACzG,kGAAkG;AAClG,2GAA2G;AAC3G,yBAAyB;AACzB,OAAO,CAAC,OAAO,CACb,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE;IAC5B,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE;IAClB,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACnC,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;CACpC,CAAC,CAIH,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE;IACZ,OAAO,CAAC,QAAQ,GAAG,EAAE,CAAA;AACvB,CAAC,CAAC,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AA+CA,MAAM,WAAW,KAAK;IACpB,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACxB,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACxB,kGAAkG;IAClG,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;CACxB;AAuRD,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuI7E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AA6CA,MAAM,WAAW,KAAK;IACpB,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACxB,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IACxB,kGAAkG;IAClG,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;CACxB;AAqTD,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA8J7E"}
package/dist/cli/index.js CHANGED
@@ -9,20 +9,22 @@ import { cmdEventsContains } from './events-contains.js';
9
9
  import { cmdInstallSkills } from './install-skills.js';
10
10
  import { initTrackDir, resolveTrackDir, resolveTrackDirOrNull } from './resolve.js';
11
11
  import { DomainError } from '../model/item.js';
12
- import { formatRows } from '../report/format.js';
12
+ import { displayText, formatRows } from '../report/format.js';
13
13
  import { Track } from '../track.js';
14
- import { BLOCKER_KINDS, BLOCKER_SCOPES, DECISION_KINDS, DISPOSITIONS, EVIDENCE_KINDS, GATES, ITEM_KINDS, ITEM_ROLES, OUTCOMES, REALIZE_TARGETS, RESOLUTION_RULES, RESULTS, ROLE_CHANGE_TARGETS, SPEC_TARGETS, } from '../ingest/contract.js';
14
+ import { BLOCKER_KINDS, BLOCKER_SCOPES, DECISION_KINDS, DISPOSITIONS, EVIDENCE_KINDS, GATES, ITEM_KINDS, ITEM_ROLES, REALIZE_TARGETS, RESOLUTION_RULES, RESULTS, ROLE_CHANGE_TARGETS, SPEC_TARGETS, } from '../ingest/contract.js';
15
15
  import { ingest } from '../ingest/ingest.js';
16
16
  import { applyRestructurePlan } from './restructure-apply.js';
17
17
  import { TrackReader } from '../read/contract.js';
18
- import { queryText, reportText, statusText } from '../read/commands.js';
18
+ import { queryText, reportHtml, reportInline, reportText, resolveHandle, statusText } from '../read/commands.js';
19
19
  import { STATUS_LEVELS } from '../report/status-by-level.js';
20
20
  import { renderSnapshot } from '../report/snapshot.js';
21
- import { generateAiReport } from '../report/ai-report.js';
22
21
  import { VERSION } from '../version.js';
23
22
  import { durableWorkspaceId } from '../workspace-id.js';
24
23
  import { desyncFindings } from './desync.js';
25
24
  const USAGE = `usage: track <command>
25
+ --help | help
26
+ global read/store override: --track-dir <directory-containing-events.jsonl> (or TRACK_DIR)
27
+ may appear before or after the command; it redirects reads AND writes to that directory
26
28
  --version | -v
27
29
  init
28
30
  item new --kind <feature|bug|chore> --title <t> --workspace <w> [--body <b>] [--parent <id>] [--role <workpackage|spec-phase|stream>] [--accountable <a>] [--responsible <a,a>] [--engagement-ref <e>]
@@ -35,9 +37,11 @@ const USAGE = `usage: track <command>
35
37
  item assign-code <itemId> --code <c> [--client-token <t>]
36
38
  item show <itemId>
37
39
  item ls [--workspace <w>] [--kind <feature|bug|chore>] [--format json|text|md]
38
- decision new --kind <orientation|commitment> --title <t> --workspace <w> --targets <id,id> [--context <c>] [--accountable <a>] [--engagement-ref <e>]
39
- decision outcome <decisionId> <go|no-go|deferred>
40
- decision dossier <decisionId> --context <c>
40
+ decision new --kind <orientation|commitment> --title <t> --workspace <w> --targets <id,id> --context <c> --options-json <json> --recommendation <optionId> --rationale <r> [--accountable <a>] [--engagement-ref <e>]
41
+ decision ls [--workspace <w>] [--outcome <pending|go|no-go|deferred>] [--format json|text|md] [--commit <sha>]
42
+ decision outcome <decisionId> deferred
43
+ decision select <decisionId> <optionId> [--outcome <go|no-go>]
44
+ decision dossier <decisionId> [--context <c>] [--options-json <json> --recommendation <optionId> --rationale <r>]
41
45
  decision disposition <itemId> <orientation|commitment> <required|skipped|not-applicable>
42
46
  decision add-artifact <decisionId> --kind <h2a-decision-dossier|rendered-view|mockup> [--negotiation-ref <n>] [--dossier-hash <h>] [--view-ref <v>] [--source-dossier-hash <h>] [--label <l>] [--client-token <t>]
43
47
  blocker raise --target <id> --kind <decision|dependency> [--ref <id>] [--reason <r>] [--rule <linked-done|linked-accepted|manual>] [--scope <intra|extra>] [--engagement-ref <e>]
@@ -50,7 +54,7 @@ const USAGE = `usage: track <command>
50
54
  accept waive <criterionId> --reason <r>
51
55
  consolidate --items <id,id> --commit <mergeCommit> [--client-token <t>]
52
56
  priority assess <itemId> --ubv <n> --tc <n> --rr <n> --js <n>
53
- report [--decisions] [--require-accepted] [--active-roster] [--wp|--flat] [--inline] [--width <n>] [--level <spec|plan|wp|lot|task>] [--raw] [--format json|text|md|html] [--commit <sha>]
57
+ report [--decisions] [--require-accepted] [--active-roster] [--wp|--flat] [--inline] [--width <n>] [--level <spec|plan|wp|lot|task>] [--raw] [--resolve <handle>] [--sub-wp] [--format json|text|md|html] [--commit <sha>] [--now <iso>]
54
58
  snapshot [--require-accepted] [--format json|text|md] [--commit <sha>]
55
59
  export-graph [--repo-key <repo:key>] [--source-id <id>] [--observed-at <iso>]
56
60
  query [--kind <k>] [--role <workpackage|spec-phase|stream>] [--workspace <w>] [--bucket <AWAITED|DROPPED|DONE|TO-DO>] [--realization <r>] [--acceptance <a>] [--format json|text|md] [--commit <sha>]
@@ -66,7 +70,7 @@ const USAGE = `usage: track <command>
66
70
  install-skills --host <claude|codex|gemini|agy|all> [--scope user|project] [--force]
67
71
  workspace-id [--cwd <path>]
68
72
  `;
69
- // Write enums (ITEM_KINDS, SPEC_TARGETS, REALIZE_TARGETS, DECISION_KINDS, OUTCOMES, GATES, DISPOSITIONS,
73
+ // Write enums (ITEM_KINDS, SPEC_TARGETS, REALIZE_TARGETS, DECISION_KINDS, GATES, DISPOSITIONS,
70
74
  // BLOCKER_KINDS, RESOLUTION_RULES, EVIDENCE_KINDS, RESULTS) are sourced from the ingest contract — the
71
75
  // SINGLE source, so the CLI's `oneOf` checks and the WorkEvent mapper cannot diverge on accepted values.
72
76
  // (`linked-accepted` openness is DERIVED at report/query time vs `--commit`, v2.2a hybrid-A; see
@@ -74,6 +78,7 @@ const USAGE = `usage: track <command>
74
78
  const REALIZATIONS = ['to-do', 'in-progress', 'done', 'cancelled', 'rejected'];
75
79
  const FROM_FORMATS = ['junit', 'json'];
76
80
  const BUCKETS_ARG = ['AWAITED', 'DROPPED', 'DONE', 'TO-DO'];
81
+ const DECISION_OUTCOMES = ['pending', 'go', 'no-go', 'deferred'];
77
82
  // `n/a` is decision-only; `query` projects non-decision rows, so it would never match.
78
83
  const ACCEPTANCES = ['fail', 'waived', 'unknown', 'stale', 'pass'];
79
84
  // The DossierArtifact discriminator (M5 §3.1). CLI-local: the union SHAPE is validated fail-closed in the
@@ -241,6 +246,19 @@ function num(flags, key) {
241
246
  throw new DomainError(`--${key} must be a number`);
242
247
  return n;
243
248
  }
249
+ function dossierOptions(raw) {
250
+ let parsed;
251
+ try {
252
+ parsed = JSON.parse(raw);
253
+ }
254
+ catch {
255
+ throw new DomainError('--options-json must be a JSON array of existing Option objects');
256
+ }
257
+ if (!Array.isArray(parsed)) {
258
+ throw new DomainError('--options-json must be a JSON array of existing Option objects');
259
+ }
260
+ return parsed;
261
+ }
244
262
  /** Validate a positional/flag against an allowed enum (CLI-boundary input validation). */
245
263
  function oneOf(value, allowed, name) {
246
264
  if (value === undefined || !allowed.includes(value)) {
@@ -290,10 +308,28 @@ function extractTrackDirFlag(argv) {
290
308
  }
291
309
  return trackDirFlag !== undefined ? { trackDirFlag, rest } : { rest };
292
310
  }
311
+ const REPORT_USAGE = `usage: track report [--raw] [--wp] [--flat] [--inline|--width <40..240>] [--decisions] [--active-roster] [--require-accepted] [--resolve <handle>] [--commit <sha>] [--now <iso>] [--sub-wp] [--format json|text|md|html] [--track-dir <directory-containing-events.jsonl>]
312
+
313
+ --resolve <handle> resolves a report handle (a positional [n.m] row handle, or a D#/Q# dossier number) back to its item id. It is the one command the report documents for acting on a row without printing a ULID in a column. Handles are positional and per-report: resolve them against the same log and baseline the report was rendered from.
314
+
315
+ --sub-wp lists sub-WP rows beside their parent. Without it, sub-levels are aggregated into their parent on a long window (>= 14 days) and listed on a short one — the WP is the reading unit of a long report.
316
+
317
+ --now <iso> pins the window's upper bound (default: the wall clock). The report's period always runs from the first recorded event to that bound; pin it to reproduce a committed fixture byte for byte.
318
+
319
+ --track-dir is a global override and may appear before or after the command. It selects the directory that contains events.jsonl; it is especially useful for a read-only fixture. TRACK_DIR is the environment equivalent.
320
+ `;
293
321
  export function runCli(rawArgv, io) {
294
322
  const { trackDirFlag, rest: argv } = extractTrackDirFlag(rawArgv);
295
323
  const cmd = argv[0];
296
324
  const rest = argv.slice(1);
325
+ if (cmd === '--help' || cmd === 'help' || cmd === undefined) {
326
+ io.out(USAGE);
327
+ return 0;
328
+ }
329
+ if (cmd === 'report' && rest.length === 1 && rest[0] === '--help') {
330
+ io.out(REPORT_USAGE);
331
+ return 0;
332
+ }
297
333
  const trackDirEnv = process.env['TRACK_DIR'];
298
334
  const resolveOpts = {
299
335
  cwd: io.cwd,
@@ -301,6 +337,19 @@ export function runCli(rawArgv, io) {
301
337
  ...(trackDirEnv !== undefined ? { env: trackDirEnv } : {}),
302
338
  };
303
339
  try {
340
+ // `decision ls` is an inspection surface, not a write verb. Route it through the same serve-empty
341
+ // read path as report/query so it never creates a sidecar and can be used by a fresh reporting agent.
342
+ if (cmd === 'decision' && rest[0] === 'ls') {
343
+ const trackDir = resolveTrackDirOrNull(resolveOpts);
344
+ if (trackDir === null) {
345
+ io.err(`track: no .track resolved from ${io.cwd}. Run \`track init\` to create one ` +
346
+ `(the ONLY command that does), or pass --track-dir / TRACK_DIR. Serving an empty view.\n`);
347
+ }
348
+ return cmdDecisionLs(rest, {
349
+ io,
350
+ eventsPath: trackDir !== null ? eventsPathOf(trackDir) : eventsPathOf(join(resolve(io.cwd), '.track')),
351
+ });
352
+ }
304
353
  switch (cmd) {
305
354
  case '--version':
306
355
  case '-v':
@@ -659,7 +708,12 @@ function cmdDecision(args, ctx) {
659
708
  title: req(flags, 'title'),
660
709
  workspace: req(flags, 'workspace'),
661
710
  targets: req(flags, 'targets').split(',').map((s) => s.trim()).filter(Boolean),
662
- dossier: { context: opt(flags, 'context') ?? '', options: [], qa: [] },
711
+ dossier: {
712
+ context: req(flags, 'context'),
713
+ options: dossierOptions(req(flags, 'options-json')),
714
+ qa: [],
715
+ recommendation: { optionId: req(flags, 'recommendation'), rationale: req(flags, 'rationale') },
716
+ },
663
717
  ...(opt(flags, 'accountable') !== undefined ? { accountable: req(flags, 'accountable') } : {}),
664
718
  ...(opt(flags, 'engagement-ref') !== undefined ? { engagementRef: req(flags, 'engagement-ref') } : {}),
665
719
  });
@@ -667,16 +721,37 @@ function cmdDecision(args, ctx) {
667
721
  return 0;
668
722
  }
669
723
  if (sub === 'outcome') {
670
- track.setOutcome(positional[0], oneOf(positional[1], OUTCOMES, 'outcome'));
724
+ track.setOutcome(positional[0], oneOf(positional[1], ['deferred'], 'outcome'));
671
725
  io.out('ok\n');
672
726
  return 0;
673
727
  }
674
728
  if (sub === 'dossier') {
675
- // merge: a context-only edit must not erase existing options/qa/recommendation
729
+ // Merge: a context-only edit preserves a structured dossier. A legacy dossier must provide the
730
+ // full options/recommendation triplet here; the facade validates it fail-closed.
676
731
  const current = track.state().decisions.get(positional[0])?.dossier;
677
732
  if (current === undefined)
678
733
  throw new DomainError(`unknown decision ${positional[0]}`);
679
- track.reviseDossier(positional[0], { ...current, context: opt(flags, 'context') ?? current.context });
734
+ const optionJson = opt(flags, 'options-json');
735
+ const recommendation = opt(flags, 'recommendation');
736
+ const rationale = opt(flags, 'rationale');
737
+ if ((recommendation === undefined) !== (rationale === undefined)) {
738
+ throw new DomainError('--recommendation and --rationale must be supplied together');
739
+ }
740
+ if (optionJson !== undefined && recommendation === undefined && current.recommendation === undefined) {
741
+ throw new DomainError('migrating a legacy dossier requires --recommendation and --rationale');
742
+ }
743
+ track.reviseDossier(positional[0], {
744
+ ...current,
745
+ context: opt(flags, 'context') ?? current.context,
746
+ ...(optionJson !== undefined ? { options: dossierOptions(optionJson) } : {}),
747
+ ...(recommendation !== undefined ? { recommendation: { optionId: recommendation, rationale: rationale } } : {}),
748
+ });
749
+ io.out('ok\n');
750
+ return 0;
751
+ }
752
+ if (sub === 'select') {
753
+ const outcome = opt(flags, 'outcome');
754
+ track.selectDecisionOption(positional[0], positional[1], outcome === undefined ? 'go' : oneOf(outcome, ['go', 'no-go'], '--outcome'));
680
755
  io.out('ok\n');
681
756
  return 0;
682
757
  }
@@ -719,7 +794,7 @@ function cmdDecision(args, ctx) {
719
794
  io.out('ok\n');
720
795
  return 0;
721
796
  }
722
- io.err('usage: track decision <new|outcome|dossier|disposition|add-artifact>\n');
797
+ io.err('usage: track decision <new|outcome|select|dossier|disposition|add-artifact>\n');
723
798
  return 2;
724
799
  }
725
800
  function cmdBlocker(args, ctx) {
@@ -857,61 +932,58 @@ function cmdReport(args, ctx) {
857
932
  const { positional, flags } = parseFlags(args);
858
933
  if (positional.length > 0)
859
934
  throw new DomainError(`unexpected report argument(s): ${positional.join(' ')}`);
860
- for (const name of ['commit', 'format', 'level', 'width'])
935
+ for (const name of ['commit', 'format', 'level', 'width', 'resolve', 'now'])
861
936
  assertValueFlag(flags, name);
937
+ // Criterion 10b — the one documented command that turns a short report handle back into an item.
938
+ if (opt(flags, 'resolve') !== undefined) {
939
+ assertOnlyFlags(flags, ['resolve', 'commit', 'require-accepted']);
940
+ io.out(resolveHandle(new TrackReader(ctx.eventsPath), {
941
+ baselineCommit: resolveCommit(io.cwd, opt(flags, 'commit')),
942
+ requireAccepted: assertBooleanFlag(flags, 'require-accepted'),
943
+ }, req(flags, 'resolve')));
944
+ return 0;
945
+ }
862
946
  const raw = assertBooleanFlag(flags, 'raw');
863
947
  if (raw) {
864
948
  assertOnlyFlags(flags, ['raw', 'commit', 'require-accepted', 'format']);
865
949
  return emitSnapshot(flags, ctx, true);
866
950
  }
867
- // Reads go through the shared TrackReader command layer (same path the MCP server uses).
868
- const reader = new TrackReader(ctx.eventsPath);
869
951
  // Scope §A/§B — `--level <spec|plan|wp|lot|task>` switches to the status(level) projection.
870
- // Otherwise 0.19.1 prefers the WP/table conductor view; `--flat` is the deprecated legacy opt-out.
952
+ // Otherwise the WP/table conductor is the default; `--flat` is the legacy opt-out.
871
953
  if (opt(flags, 'level') !== undefined) {
872
954
  assertOnlyFlags(flags, ['level', 'commit', 'require-accepted', 'format']);
873
955
  assertBooleanFlag(flags, 'require-accepted');
874
- io.out(statusText(reader, oneOf(req(flags, 'level'), STATUS_LEVELS, '--level'), {
956
+ io.out(statusText(new TrackReader(ctx.eventsPath), oneOf(req(flags, 'level'), STATUS_LEVELS, '--level'), {
875
957
  baselineCommit: resolveCommit(io.cwd, opt(flags, 'commit')),
876
958
  requireAccepted: assertBooleanFlag(flags, 'require-accepted'),
877
959
  }, fmt(flags)));
878
960
  return 0;
879
961
  }
962
+ assertOnlyFlags(flags, [
963
+ 'commit', 'require-accepted', 'decisions', 'active-roster', 'wp', 'flat', 'inline', 'width', 'format', 'now',
964
+ 'sub-wp',
965
+ ]);
880
966
  const rawFormat = opt(flags, 'format');
967
+ if (rawFormat !== undefined && !['json', 'text', 'md', 'html'].includes(rawFormat)) {
968
+ throw new DomainError('--format must be one of: json|text|md|html');
969
+ }
881
970
  const widthArg = opt(flags, 'width');
882
971
  const inlineFlag = assertBooleanFlag(flags, 'inline');
883
972
  const inline = inlineFlag || widthArg !== undefined;
884
- if (inline && rawFormat !== undefined && rawFormat !== 'text') {
973
+ const format = oneOf(rawFormat ?? 'text', ['json', 'text', 'md', 'html'], '--format');
974
+ if (inline && format !== 'text')
885
975
  throw new DomainError('--inline/--width accepts no --format, or --format text');
886
- }
887
- if (rawFormat === 'html' && inline)
888
- throw new DomainError('--format html rejects --inline/--width');
889
- // Frozen legacy path. Keep this call and option derivation byte-for-byte equivalent to the former JSON
890
- // branch: it never enters context collection or the adapter.
891
- if (rawFormat === 'json') {
892
- assertOnlyFlags(flags, ['commit', 'require-accepted', 'decisions', 'active-roster', 'wp', 'flat', 'format']);
893
- io.out(reportText(reader, {
894
- baselineCommit: resolveCommit(io.cwd, opt(flags, 'commit')),
895
- requireAccepted: flags['require-accepted'] === true,
896
- decisions: flags['decisions'] === true,
897
- wpTree: flags['wp'] === true,
898
- activeRoster: flags['active-roster'] === true,
899
- }, 'json'));
900
- return 0;
901
- }
902
- assertOnlyFlags(flags, [
903
- 'commit', 'require-accepted', 'decisions', 'active-roster', 'wp', 'flat', 'inline', 'width', 'format',
904
- ]);
905
- if (rawFormat !== undefined && !['text', 'md', 'html'].includes(rawFormat)) {
906
- throw new DomainError('--format must be one of: json|text|md|html');
907
- }
908
976
  const requireAccepted = assertBooleanFlag(flags, 'require-accepted');
909
- const decisions = assertBooleanFlag(flags, 'decisions');
977
+ const requestedDecisions = assertBooleanFlag(flags, 'decisions');
910
978
  const activeRoster = assertBooleanFlag(flags, 'active-roster');
911
979
  const wp = assertBooleanFlag(flags, 'wp');
912
980
  const flat = assertBooleanFlag(flags, 'flat');
913
981
  if (wp && flat)
914
982
  throw new DomainError('--wp and --flat are mutually exclusive');
983
+ if (format === 'json' && flat)
984
+ throw new DomainError('--flat is only meaningful for text or md reports');
985
+ if (format === 'html' && flat)
986
+ throw new DomainError('--format html is always the deterministic conductor and rejects --flat');
915
987
  let width;
916
988
  if (widthArg !== undefined) {
917
989
  if (!/^\d+$/u.test(widthArg))
@@ -920,28 +992,41 @@ function cmdReport(args, ctx) {
920
992
  if (width < 40 || width > 240)
921
993
  throw new DomainError('--width must be an integer in [40,240]');
922
994
  }
923
- const baselineInput = opt(flags, 'commit') ?? 'HEAD';
924
- const baselineCommit = resolveCommit(io.cwd, baselineInput);
925
- const format = inline
926
- ? 'inline'
927
- : rawFormat === 'md' || rawFormat === 'html'
928
- ? rawFormat
929
- : 'text';
930
- io.out(generateAiReport({
931
- reader,
932
- cwd: io.cwd,
933
- request: {
934
- baselineInput,
935
- baselineCommit,
936
- format,
937
- emphasis: wp ? 'workpackages' : flat ? 'flat' : 'default',
938
- requireAccepted,
939
- decisionEmphasis: decisions ? 'all' : 'open-only',
940
- activeRoster,
941
- },
942
- ...(width !== undefined ? { width } : {}),
943
- ...(io.env !== undefined ? { env: io.env } : {}),
944
- }).output);
995
+ // Every format is a deterministic read over the folded log. The default human view is
996
+ // the conductor; --flat explicitly requests the legacy bucket projection. JSON preserves
997
+ // its established flat machine contract unless --wp is explicit, so --flat is rejected there
998
+ // instead of being silently accepted as a no-op. Contextual prose remains advisory agent work;
999
+ // no report format invokes an adapter, gateway, subprocess, or model.
1000
+ const options = {
1001
+ baselineCommit: resolveCommit(io.cwd, opt(flags, 'commit')),
1002
+ requireAccepted,
1003
+ // The owner-facing conductor always classifies decision dossiers. JSON keeps its
1004
+ // established opt-in decision payload, while --flat retains the legacy opt-in.
1005
+ decisions: requestedDecisions || (!flat && format !== 'json'),
1006
+ wpTree: wp || (!flat && format !== 'json'),
1007
+ activeRoster,
1008
+ };
1009
+ // Criterion 21 — the report's window runs from the first recorded event to NOW. The clock is injected
1010
+ // HERE (the same boundary pattern as `workspace-activity --now`) so the library stays clockless and a
1011
+ // committed fixture stays byte-reproducible by pinning `--now`.
1012
+ const nowArg = opt(flags, 'now');
1013
+ if (nowArg !== undefined && Number.isNaN(new Date(nowArg).getTime())) {
1014
+ throw new DomainError('--now must be an ISO timestamp');
1015
+ }
1016
+ const now = nowArg ?? new Date().toISOString();
1017
+ // Criterion 25 — the EXPLICIT owner request for sub-WP rows. Without it the reading unit follows the
1018
+ // window: the WP on a long one, the sub-level on a short one.
1019
+ const subWp = assertBooleanFlag(flags, 'sub-wp');
1020
+ const reader = new TrackReader(ctx.eventsPath);
1021
+ if (inline) {
1022
+ io.out(reportInline(reader, options, width === undefined ? {} : { width }));
1023
+ }
1024
+ else if (format === 'html') {
1025
+ io.out(reportHtml(reader, options, now, subWp));
1026
+ }
1027
+ else {
1028
+ io.out(reportText(reader, options, format, now, subWp));
1029
+ }
945
1030
  return 0;
946
1031
  }
947
1032
  function emitSnapshot(flags, ctx, allowRaw) {
@@ -996,6 +1081,43 @@ function cmdQuery(args, ctx) {
996
1081
  }, { baselineCommit: resolveCommit(io.cwd, opt(flags, 'commit')) }, fmt(flags)));
997
1082
  return 0;
998
1083
  }
1084
+ /**
1085
+ * List decision dossiers without a renderer cap. `structure` makes the prose-only migration state explicit;
1086
+ * only a structured dossier has durable alternatives + a durable recommendation to render as a choice.
1087
+ */
1088
+ function cmdDecisionLs(args, ctx) {
1089
+ const { io } = ctx;
1090
+ const { positional, flags } = parseFlags(args.slice(1));
1091
+ if (positional.length > 0)
1092
+ throw new DomainError(`unexpected decision ls argument(s): ${positional.join(' ')}`);
1093
+ assertOnlyFlags(flags, ['workspace', 'outcome', 'format', 'commit']);
1094
+ for (const name of ['workspace', 'outcome', 'format', 'commit'])
1095
+ assertValueFlag(flags, name);
1096
+ const format = fmt(flags);
1097
+ const baselineCommit = resolveCommit(io.cwd, opt(flags, 'commit'));
1098
+ const reader = new TrackReader(ctx.eventsPath);
1099
+ const workspace = opt(flags, 'workspace');
1100
+ const outcome = opt(flags, 'outcome') !== undefined ? oneOf(req(flags, 'outcome'), DECISION_OUTCOMES, '--outcome') : undefined;
1101
+ const rows = reader.decisionDossiers({ baselineCommit })
1102
+ .filter((decision) => (workspace === undefined || decision.workspace === workspace) && (outcome === undefined || decision.outcome === outcome))
1103
+ .map(({ dossier, ...decision }) => ({
1104
+ ...decision,
1105
+ options: dossier.options,
1106
+ ...(dossier.recommendation !== undefined ? { recommendation: dossier.recommendation } : {}),
1107
+ }));
1108
+ if (format === 'json') {
1109
+ io.out(`${JSON.stringify(rows, null, 2)}\n`);
1110
+ return 0;
1111
+ }
1112
+ const lines = rows.map((decision) => {
1113
+ const safe = (value) => displayText(value, format);
1114
+ const recommendation = safe(decision.recommendation?.optionId ?? '-');
1115
+ const line = `${safe(decision.id)} · ${safe(decision.workspace)} · ${safe(decision.outcome)} · ${safe(decision.structure ?? 'unstructured')} · options:${decision.options.length} · recommendation:${recommendation} — ${safe(decision.title)}`;
1116
+ return format === 'md' ? `- ${line}` : line;
1117
+ });
1118
+ io.out(lines.length > 0 ? `${lines.join('\n')}\n` : '');
1119
+ return 0;
1120
+ }
999
1121
  /**
1000
1122
  * `track workspace-activity --workspace <id> [--baseline-commit <sha>] [--now <iso>] [--idle-ms <ms>]
1001
1123
  * [--format json|text]` — a poll surface over the shipped, CLOCKLESS `TrackReader.workspaceActivity`
@@ -1079,6 +1201,15 @@ async function cmdFocus(args, ctx, _noStore) {
1079
1201
  const { io } = ctx;
1080
1202
  const FOCUS_USAGE = 'usage: track focus <decision-id> --workspace <w> [--format terminal|md|html] [--baseline-commit <sha>]\n';
1081
1203
  const { positional, flags } = parseFlags(args);
1204
+ try {
1205
+ assertOnlyFlags(flags, ['workspace', 'format', 'baseline-commit']);
1206
+ for (const name of ['workspace', 'format', 'baseline-commit'])
1207
+ assertValueFlag(flags, name);
1208
+ }
1209
+ catch (error) {
1210
+ io.err(`error: ${error instanceof Error ? error.message : String(error)}\n`);
1211
+ return 2;
1212
+ }
1082
1213
  // decision-id is positional + REQUIRED; --workspace is a REQUIRED flag (both gate at the CLI boundary
1083
1214
  // with rc=2 + usage, never reaching focus). Validate BEFORE the dynamic import so a usage error is
1084
1215
  // independent of whether focus is installed.
@@ -1098,6 +1229,15 @@ async function cmdFocus(args, ctx, _noStore) {
1098
1229
  io.err(`error: ${error instanceof Error ? error.message : String(error)}\n`);
1099
1230
  return 2;
1100
1231
  }
1232
+ const baselineCommit = resolveCommit(io.cwd, opt(flags, 'baseline-commit'));
1233
+ const decision = new TrackReader(ctx.eventsPath)
1234
+ .report({ baselineCommit, decisions: true })
1235
+ .decisions
1236
+ ?.find((candidate) => candidate.id === decisionId);
1237
+ if (decision !== undefined && decision.workspace !== workspace) {
1238
+ io.err(`error: decision ${decisionId} belongs to workspace ${decision.workspace}, not ${workspace}\n`);
1239
+ return 3;
1240
+ }
1101
1241
  // Load the focus render binding + core. focus is an integrated dependency → a MODULE_NOT_FOUND means the
1102
1242
  // integrated focus renderer is unavailable: map it to rc=1 + a helpful hint (NOT a stack trace).
1103
1243
  let focusTrack;
@@ -1118,7 +1258,7 @@ async function cmdFocus(args, ctx, _noStore) {
1118
1258
  try {
1119
1259
  // PURE / read-only / clockless: `readAt` is supplied at the CLI boundary (track holds no clock), the
1120
1260
  // baseline commit resolves HEAD/refs/short-SHA → 40-char, and `ctx.eventsPath` is the single store.
1121
- const doc = focusTrack.readDecisionDossier(ctx.eventsPath, { workspace, baselineCommit: resolveCommit(io.cwd, opt(flags, 'baseline-commit')), decisionId }, new Date().toISOString());
1261
+ const doc = focusTrack.readDecisionDossier(ctx.eventsPath, { workspace, baselineCommit, decisionId }, new Date().toISOString());
1122
1262
  const rendered = format === 'md' ? core.renderMd(doc) : format === 'html' ? core.renderHtml(doc, HTML_HOOKS) : core.renderTerminal(doc);
1123
1263
  io.out(rendered.endsWith('\n') ? rendered : `${rendered}\n`);
1124
1264
  return 0;