@sabaiway/agent-workflow-kit 12.0.0 → 12.1.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.
@@ -8,7 +8,7 @@ The reader + consent-gated **writer** for the **host-level** bridge settings fil
8
8
 
9
9
  | key | bridge | values | note |
10
10
  |---|---|---|---|
11
- | `CODEX_SERVICE_TIER` | codex | `priority` | **SPEND KNOB** — the "Fast" tier: ~1.5× token speed at a **2.5× credit rate** on gpt-5.6-sol, quality-neutral (same model). Default unset ⇒ standard tier. |
11
+ | `CODEX_SERVICE_TIER` | codex | `priority` | **SPEND KNOB** — the "Fast" tier: "2x speed, **increased usage**" in the codex catalog's own words for gpt-6-astra (cache fetched 2026-09-05); it states no credit-rate figure; quality-neutral (same model). Default unset ⇒ standard tier. |
12
12
  | `CODEX_HARD_TIMEOUT` | codex | integer `1..86400` | hard wall-clock cap (seconds) via `timeout(1)`. |
13
13
  | `CODEX_REVIEW_MAX_TOTAL_BYTES` | codex | integer `1..100000000` | codex-review payload size above which the diff rides a temp file (never truncated). |
14
14
  | `AGY_HARD_TIMEOUT` | agy | duration `5m`/`30m`/`90s` (unit required, nonzero) | hard wall-clock cap via `timeout(1)`. |
@@ -25,7 +25,7 @@ The reader + consent-gated **writer** for the **host-level** bridge settings fil
25
25
 
26
26
  **Refusals (the guarded contract):** an **unknown key** or an **invalid / out-of-range value** → exit `2` (nothing read of the file, nothing written). A file that already carries **duplicate keys** → exit `1`, named and left **byte-untouched** (fix the duplicates by hand first — the writer never edits blindly around them). A **symlinked / non-regular / unreadable** settings file → exit `1` (refuses to write through it). `--apply` with `--dry-run`, a duplicate op for one key, or a malformed `--set` → usage exit `2`.
27
27
 
28
- **Spend consent (D4):** enabling `CODEX_SERVICE_TIER=priority` costs a **2.5× credit rate** — a per-host, consented act, never a default. The preview shows the caveat (from the manifest `effect`); confirm with the user before `--apply`. The tool also warns when an env var currently shadows the key you are writing (the env value wins for that session until unset).
28
+ **Spend consent (D4):** enabling `CODEX_SERVICE_TIER=priority` costs **increased usage** (the catalog states no credit-rate figure) — a per-host, consented act, never a default. The preview shows the caveat (from the manifest `effect`); confirm with the user before `--apply`. The tool also warns when an env var currently shadows the key you are writing (the env value wins for that session until unset).
29
29
 
30
30
  Output is **English/structured** — **localize it to the user's conversational language** when you narrate.
31
31
 
@@ -0,0 +1,30 @@
1
+ ### Mode: now
2
+
3
+ <!-- opt-in-capability: none — an inspection surface that configures nothing -->
4
+
5
+ Read-only. The single answer to where the work stands. It writes no file, cache, receipt, lock or git state; it never commits and never runs a subscription CLI.
6
+
7
+ Run `node ${CLAUDE_SKILL_DIR}/tools/now-cli.mjs --dir <project> --json`. For a direct terminal render, use `--format=plain|ansi|json`; `--json` is strict sugar for `--format=json`.
8
+
9
+ Render the returned envelope compactly in the user's conversational language. Never paste the JSON and never expose an internal field name. Preserve every fact, evidence label, annotation and withheld cause while translating only the prose around source-language commands, paths and identifiers.
10
+
11
+ The four blocks keep this order:
12
+
13
+ 1. **NOW** — every plan in flight, its derived phase, current and next step, then the tree fingerprint, clean state, review verdict lines, final run and red-proof currency. No plan and several plans are rendered states; never pick one silently.
14
+ 2. **STEPS** — every ledger row with one of `landed`, `in progress`, `pending` or `unjudged`. The rows are the steps; invent no numbering.
15
+ 3. **QUEUE** — every work-carrying row (live, parked, ambiguous) under the full line-numbered heading path it carries, with per-class counts covering every row of the bucket, terminal and record included, and names rather than ids. Only a path through `## Pending / backlog` calls its position priority; every other bucket is archive and calls it position.
16
+ 4. **CAMPAIGN** — source-size config state (`absent`, `authored`, `incomplete`, `minted`), caps, roots, recorded over-cap files and aggregate lines. An absent opt-in config is a rendered state.
17
+
18
+ Evidence alone sets a ledger status: the row's path and verb, then the path fact, changed set and exclusion fact. The plan's prose never sets a status. A checked box or terminal marker is only a claim; when evidence contradicts it, print the claim beside the evidence-derived status. Keep anomaly and current red-proof annotations beside the same status too. Every status line names its evidence source.
19
+
20
+ A block that cannot be derived is `WITHHELD` and prints its cause on one line. NOW has two halves, plans and tree, each independently a value or `WITHHELD`; NOW itself is `WITHHELD` only when both are. Render every other block normally.
21
+
22
+ Exit codes:
23
+
24
+ | Code | Meaning |
25
+ |------|---------|
26
+ | 0 | Anything rendered, including default states, contradictions and a WITHHELD block. |
27
+ | 1 | Nothing rendered: every block withheld, each cause printed on stderr; the usual cause is a `--dir` that is not a git work tree. |
28
+ | 2 | Usage: an unknown flag, an invalid or missing value, conflicting `--json` / `--format`, or `--help` combined with anything. |
29
+
30
+ **Invariants:** read-only · the plan's prose never sets status · evidence is shown beside every status · compact user-language render with no JSON or internal field names · `process.exitCode`, never `process.exit()`.
@@ -273,7 +273,7 @@ export const main = (argv = [], ctx = {}) => {
273
273
  const body = joinLines(newLines);
274
274
 
275
275
  // The machine surface carries the SAME spend/credit-rate caveats the human preview prints (D4) — a
276
- // --json consumer must not miss the 2.5x-credit consent warning the human render shows.
276
+ // --json consumer must not miss the spend consent warning the human render shows.
277
277
  const jsonBody = (wrote) => ({ path, wrote, noop, changes: changes.map((c) => ({ key: c.key, kind: c.kind, before: c.before, after: c.after })), envShadows, caveats });
278
278
 
279
279
  if (!apply || noop) {
@@ -84,6 +84,13 @@ const CATALOG = [
84
84
  kind: READ_ONLY,
85
85
  oneLine: 'Show what is installed and at what version, your settings and backends, and what is deployed in this project.',
86
86
  },
87
+ {
88
+ key: 'now',
89
+ invocation: invocationOf('now'),
90
+ group: 'Inspect',
91
+ kind: READ_ONLY,
92
+ oneLine: 'Show where work stands: NOW answers the plan and tree, STEPS the evidence-derived ledger state, QUEUE its named positions, and CAMPAIGN its size-practice figures; the plan’s own text never sets a status.',
93
+ },
87
94
  {
88
95
  key: 'backends',
89
96
  invocation: invocationOf('backends'),
@@ -18,7 +18,11 @@ const NOTES_HEADING = '## Notes';
18
18
  const TABLE_HEADER = '| # | Claim | Evidence | Verdict | Disposition |';
19
19
  const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu;
20
20
  const ANCHOR = /^`([^`,\s]+):([1-9][0-9]*)(?:-([1-9][0-9]*))?`$/u;
21
- const ROW_ID = '[A-Z0-9][A-Z0-9-]*';
21
+ // The row id a checked record hands over, and the ONE home of that grammar: the queue's name
22
+ // judge reads it back for the EXPLICITLY marked form (`— id <ID>`), so every id this writer may
23
+ // legally emit round-trips through the judge — including `FOO`, `TASK-123` and `1-TASK`, which
24
+ // the judge's own heuristic deliberately does not read unmarked.
25
+ export const ROW_ID = '[A-Z0-9][A-Z0-9-]*';
22
26
  const QUEUE = new RegExp(`^queue (${ROW_ID})$`, 'u');
23
27
  const ALREADY_QUEUED = new RegExp(`^already-queued (${ROW_ID})$`, 'u');
24
28
  const REASONED_DISPOSITION = /^(?:declined|folded): (.+)$/u;
@@ -227,7 +231,7 @@ export const renderRows = (record, { date, recordPath }) => {
227
231
  return ids.map((id) => {
228
232
  const claims = record.claims.filter((claim) => QUEUE.exec(claim.disposition)?.[1] === id);
229
233
  return [
230
- `- [ ] ${id}: ${claims[0].claim} (${date})`,
234
+ `- [ ] ${claims[0].claim} — id ${id} (${date})`,
231
235
  ...claims.map((claim) => ` - ${claim.claim}; verdict ${claim.verdict}; evidence ${claim.anchors.map(rendersAnchor).join(', ')}`),
232
236
  ` - record: ${recordPath}`,
233
237
  ` - head: ${record.head}`,
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // The CLI half of `now`: argv, the io seam and the pipeline. The rules live in now-status.mjs, the
3
+ // facts in now-facts.mjs, the phrases in now-render.mjs.
4
+
5
+ import { resolve } from 'node:path';
6
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
7
+ import { isDirectRun } from './direct-run.mjs';
8
+ import { FORMATS, detectSurface } from './surface.mjs';
9
+ import { gatherNowFacts } from './now-facts.mjs';
10
+ import { isWithheld } from './now-status.mjs';
11
+ import { renderNow, toNowViewModel } from './now-render.mjs';
12
+
13
+ const BLOCKS = Object.freeze(['now', 'steps', 'queue', 'campaign']);
14
+ const SCHEMA = 1;
15
+ const FORMAT_FLAG = '--format=';
16
+ const HELP = `now — read-only: where the work stands, in four blocks.
17
+
18
+ Usage:
19
+ node now-cli.mjs [--dir <project>] [--format=<${FORMATS.join('|')}>] [--json]
20
+
21
+ --dir the project to read (default: the current directory).
22
+ --format the output surface; --json is strict sugar for --format=json, and naming both with
23
+ different values is usage.
24
+ --help answered only when it is the whole invocation.
25
+
26
+ Blocks: NOW (the plans in flight, the phase, the current and the next step, the tree), STEPS (the
27
+ ledger rows, one status each), QUEUE (the backlog folded under each row's heading path), CAMPAIGN
28
+ (the size practice). A block that cannot be derived is WITHHELD with its cause on one line.
29
+
30
+ A status is a function of the row's (path, verb) pair and the evidence at that path; the row's prose
31
+ is printed as a claim beside a status it never moves.
32
+
33
+ Exit codes: 0 anything rendered, a withheld block included; 1 nothing rendered at all (a --dir that
34
+ is not a git work tree); 2 usage.
35
+
36
+ Reads only: no store, no receipt, no cache, no lock.`;
37
+
38
+ const valueAt = (argv, index, flag) => {
39
+ const value = argv[index + 1];
40
+ if (value === undefined || value === '' || value.startsWith('-')) throw fail(2, `${flag} takes a value`);
41
+ return value;
42
+ };
43
+
44
+ export const parseArgv = (argv) => {
45
+ if (argv.includes('--help') || argv.includes('-h')) {
46
+ if (argv.length !== 1) throw fail(2, '--help is answered only when it is the whole invocation');
47
+ return { help: true, dir: null, formats: [] };
48
+ }
49
+ const parsed = argv.reduce((state, arg, index) => {
50
+ if (state.skip) return { ...state, skip: false };
51
+ if (arg === '--dir') return { ...state, dir: valueAt(argv, index, arg), skip: true };
52
+ if (arg === '--format') return { ...state, formats: [...state.formats, valueAt(argv, index, arg)], skip: true };
53
+ if (arg.startsWith(FORMAT_FLAG)) return { ...state, formats: [...state.formats, arg.slice(FORMAT_FLAG.length)] };
54
+ if (arg === '--json') return { ...state, formats: [...state.formats, 'json'], json: true };
55
+ throw fail(2, `unknown argument "${arg}" — run with --help`);
56
+ }, { help: false, dir: null, formats: [], json: false, skip: false });
57
+ if (parsed.json && parsed.formats.some((format) => format !== 'json')) {
58
+ throw fail(2, '--json is strict sugar for --format=json — naming both with different values is usage');
59
+ }
60
+ return parsed;
61
+ };
62
+
63
+ const resolveSurface = (formats, env, io) => detectSurface({
64
+ argv: formats.map((format) => `${FORMAT_FLAG}${format}`),
65
+ env,
66
+ isTTY: io.isTTY ?? Boolean(process.stdout.isTTY),
67
+ columns: io.columns ?? process.stdout.columns,
68
+ platform: io.platform ?? process.platform,
69
+ });
70
+
71
+ const toEnvelope = (dir, vm) => ({ schema: SCHEMA, command: 'now', dir, ...vm });
72
+
73
+ export const main = (argv, io = {}) => {
74
+ const log = io.log ?? console.log;
75
+ const error = io.error ?? console.error;
76
+ const env = io.env ?? process.env;
77
+ const start = (() => {
78
+ try {
79
+ const options = parseArgv(argv);
80
+ return options.help ? { help: true } : { options, surface: resolveSurface(options.formats, env, io) };
81
+ } catch (err) {
82
+ error(err?.message ?? String(err));
83
+ return { code: err?.exitCode ?? 2 };
84
+ }
85
+ })();
86
+ if (start.code !== undefined) return start.code;
87
+ if (start.help) {
88
+ log(HELP);
89
+ return 0;
90
+ }
91
+ const cwd = resolve(io.cwd ?? process.cwd(), start.options.dir ?? '.');
92
+ // The composed readers spawn git under process.env alone, so the flag must reach them there too.
93
+ process.env.GIT_OPTIONAL_LOCKS = '0';
94
+ const vm = toNowViewModel(gatherNowFacts({ cwd, env: { ...env, GIT_OPTIONAL_LOCKS: '0' } }));
95
+ const blocks = BLOCKS.map((name) => vm[name]);
96
+ if (blocks.every(isWithheld)) {
97
+ for (const cause of new Set(blocks.map(({ withheld }) => withheld))) error(cause);
98
+ return 1;
99
+ }
100
+ log(start.surface.mode === 'json' ? JSON.stringify(toEnvelope(cwd, vm), null, 2) : renderNow(vm, start.surface));
101
+ return 0;
102
+ };
103
+
104
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,67 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { buildSummaryState, isTreeClean } from './core-evidence.mjs';
4
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
5
+ import { resolveGitLocation } from './git-env.mjs';
6
+ import { auditQueue } from './queue-audit.mjs';
7
+ import { loadSourceSizeConfig, practiceFacts } from './source-size-config.mjs';
8
+ import { gatherPlanFacts, readSummaryFacts } from './now-plan-facts.mjs';
9
+ import { isWithheld } from './now-status.mjs';
10
+
11
+ const QUEUE_REL = 'docs/plans/queue.md';
12
+ const BLOCKS = Object.freeze(['plans', 'tree', 'queue', 'campaign']);
13
+
14
+ const attempt = (label, read) => {
15
+ try {
16
+ return read();
17
+ } catch (error) {
18
+ return { withheld: `${label}: ${error?.message ?? error}` };
19
+ }
20
+ };
21
+
22
+ const readQueueFacts = (cwd, deps) => {
23
+ const read = readRegularFileNoFollow(join(cwd, QUEUE_REL), deps.io ?? {});
24
+ if (read.outcome !== 'ok') throw new Error(`${QUEUE_REL} is not a readable regular file (${read.className ?? read.code ?? read.outcome})`);
25
+ const { rows, counts, total } = auditQueue(read.content, { label: QUEUE_REL });
26
+ return { path: QUEUE_REL, rows, counts, total };
27
+ };
28
+
29
+ // The practice is opt-in: an absent declaration is a rendered STATE, only an unreadable one is withheld.
30
+ const readCampaignFacts = (cwd, deps) => {
31
+ const loaded = loadSourceSizeConfig(cwd, deps);
32
+ return loaded.state === 'absent' ? { state: loaded.state } : { state: loaded.state, ...practiceFacts(loaded.config) };
33
+ };
34
+
35
+ // A store the summary cannot trust never attests: the tree is withheld with the store's own cause.
36
+ const describeUntrustedStore = (summary) => {
37
+ if (summary.evidenceUnavailable) return `the core-evidence store is malformed or unreadable (${summary.storeReadError ?? `${summary.storeMalformed} malformed line(s)`})`;
38
+ if (summary.receiptsUnavailable) return `the review receipts store is malformed or unreadable (${summary.receiptsReadError ?? `${summary.receiptsMalformed} malformed line(s)`})`;
39
+ return null;
40
+ };
41
+
42
+ const toTreeFacts = (summary, clean) => {
43
+ const untrusted = describeUntrustedStore(summary);
44
+ if (untrusted) return { withheld: untrusted };
45
+ const { redProofs, receipts } = readSummaryFacts(summary);
46
+ return { fingerprint: summary.fingerprint, clean, verdicts: receipts.verdicts, finalRun: receipts.finalRun, redProofs };
47
+ };
48
+
49
+ export const gatherNowFacts = ({ cwd, env: given = process.env, deps = {} }) => {
50
+ // Every reader honouring an env argument spawns under this one: a plain `git diff` rewrites .git/index without the flag.
51
+ const env = { ...given, GIT_OPTIONAL_LOCKS: '0' };
52
+ const location = resolveGitLocation(cwd, { spawn: deps.spawn ?? spawnSync, env });
53
+ if (location.state !== 'work-tree') {
54
+ const withheld = `${cwd} is not a git work tree (${location.state}: ${location.cause})`;
55
+ return Object.fromEntries(BLOCKS.map((block) => [block, { withheld }]));
56
+ }
57
+ const root = location.top;
58
+ const summary = attempt('the evidence summary', () => deps.summary ?? buildSummaryState({ cwd: root, env }));
59
+ return {
60
+ plans: attempt('the plans in flight', () => ({
61
+ entries: gatherPlanFacts({ cwd: root, env, deps }).plans,
62
+ })),
63
+ tree: isWithheld(summary) ? summary : attempt('the tree state', () => toTreeFacts(summary, isTreeClean(root, { env }))),
64
+ queue: attempt('the queue', () => readQueueFacts(root, deps)),
65
+ campaign: attempt('the practice', () => readCampaignFacts(root, deps)),
66
+ };
67
+ };
@@ -0,0 +1,129 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join, posix } from 'node:path';
3
+ import { computeWorkingState, coverageQualifierFor, describeMissingReviewAttestation } from './core-evidence.mjs';
4
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
5
+ import { GIT_MAX_BUFFER, stripGitLocationEnv, withGitPath } from './git-env.mjs';
6
+ import { PLANS_REL, plansInFlight } from './plan-files.mjs';
7
+ import { isSweep, parseLedger, unique } from './plan-shape.mjs';
8
+ import { buildFacts, expandPatterns } from './plan-shape-facts.mjs';
9
+
10
+ const VERIFICATION = '## Verification';
11
+ const NUL = String.fromCharCode(0);
12
+ const STAGED_ARGV = Object.freeze(['diff', '--cached', '--name-only', '-z', '--no-renames']);
13
+ const IGNORE_ARGV = Object.freeze(['check-ignore', '-z', '--stdin']);
14
+
15
+ const detailOf = (result) => {
16
+ const stderr = String(result.stderr ?? '').trim();
17
+ return stderr ? ` (${stderr})` : '';
18
+ };
19
+
20
+ // Every spawn outcome is named — an error, a signal, an unaccepted status, a throw — never an empty success read as "nothing is staged".
21
+ export const readGitPaths = (argv, { cwd, env, spawn = spawnSync, input, accept = [0] }) => {
22
+ const label = `git ${argv[0]}`;
23
+ const runEnv = withGitPath({ ...stripGitLocationEnv(env), LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0' });
24
+ const result = (() => {
25
+ try {
26
+ return spawn('git', argv, { cwd, env: runEnv, input, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
27
+ } catch (error) {
28
+ throw new Error(`${label} threw synchronously (${error?.message ?? error})`);
29
+ }
30
+ })();
31
+ if (result === null || typeof result !== 'object') throw new Error(`${label} returned no result`);
32
+ if (result.error) throw new Error(`${label} could not run (${result.error.code ?? result.error.message ?? result.error})`);
33
+ if (result.signal) throw new Error(`${label} was killed by ${result.signal}`);
34
+ if (!accept.includes(result.status)) throw new Error(`${label} exited ${result.status}${detailOf(result)}`);
35
+ return String(result.stdout ?? '').split(NUL).filter(Boolean);
36
+ };
37
+
38
+ const refused = (path, parseError) => ({ path, parsed: null, parseError, rows: [] });
39
+
40
+ const readPlanEntry = (cwd, name) => {
41
+ const path = `${PLANS_REL}/${name}`;
42
+ const read = readRegularFileNoFollow(join(cwd, PLANS_REL, name));
43
+ if (read.outcome !== 'ok') return refused(path, `${path} is not a readable regular file (${read.className ?? read.code ?? read.outcome})`);
44
+ try {
45
+ const parsed = parseLedger(read.content);
46
+ const verification = parsed.document.headings.find(({ text }) => text === VERIFICATION);
47
+ return verification
48
+ ? { path, parsed, parseError: null, rows: [] }
49
+ : refused(path, `${path} carries no "${VERIFICATION}" heading, so its ledger cannot be located`);
50
+ } catch (error) {
51
+ return refused(path, `${path} cannot be parsed (${error?.message ?? error})`);
52
+ }
53
+ };
54
+
55
+ const describeCurrency = ({ state, detail }) => detail ? `${state} (${detail})` : state;
56
+ const toVerdict = ({ backend, summary }) => {
57
+ const missing = describeMissingReviewAttestation(summary);
58
+ return { backend, line: `${backend}: ${missing ?? `${summary.receipt.verdict} (attesting, ${summary.receipt.timestamp})`}` };
59
+ };
60
+
61
+ const describeFinalRun = (record) => {
62
+ if (!record) return null;
63
+ const qualifier = coverageQualifierFor(record);
64
+ return {
65
+ status: String(record.status).toUpperCase(),
66
+ gates: Array.isArray(record.results) ? `${record.results.filter(({ ok }) => ok === true).length}/${record.results.length}` : 'unknown',
67
+ fingerprintBefore: record.fingerprintBefore,
68
+ timestamp: record.timestamp,
69
+ coverage: qualifier ? qualifier.replace(/^ \u2014 /, '') : `coverage=${record.coverage ?? 'unknown'}`,
70
+ };
71
+ };
72
+
73
+ // The summary's review half, mapped ONCE: the tree block and the per-row red-proof annotation read the same records.
74
+ export const readSummaryFacts = (summary) => ({
75
+ redProofs: summary.redProofs.map((record) => ({ path: record.file, currency: describeCurrency(record.currency) })),
76
+ receipts: { path: summary.receiptsPath, verdicts: summary.verdicts.map(toVerdict), finalRun: describeFinalRun(summary.finalRun) },
77
+ });
78
+
79
+ // A sweep row is judged as a SET: presence from the expansion on disk, each changed-set flag from the glob over that FULL git set (a deleted member still counts), exclusion only when every member is excluded.
80
+ // git names a path in one spelling; a ledger may write `./src/a.mjs` or `src//a.mjs`, so every lookup uses the canonical form.
81
+ const canonical = (path) => posix.normalize(path).replace(/\/$/, '');
82
+ const matchesAny = (pattern, paths) => expandPatterns(paths, [pattern])[pattern].length > 0;
83
+ const sweepEvidenceFor = (pattern, expansions, sets) => {
84
+ const members = expansions[pattern] ?? [];
85
+ return {
86
+ pathFact: members.length > 0 ? { kind: 'sweep', members: members.length } : { kind: 'absent' },
87
+ staged: matchesAny(pattern, sets.staged),
88
+ unstaged: matchesAny(pattern, sets.unstaged),
89
+ untracked: matchesAny(pattern, sets.untracked),
90
+ excluded: members.length > 0 && members.every((member) => sets.excluded.includes(member)),
91
+ };
92
+ };
93
+ // `path` is the canonical form every reader of the row's evidence keys on; a row the ledger grammar refused carries none.
94
+ const hasPath = (row) => typeof row.path === 'string' && row.path.length > 0;
95
+ const evidenceFor = (row, pathFacts, expansions, sets) => {
96
+ if (!hasPath(row)) return { path: null, pathFact: null, staged: false, unstaged: false, untracked: false, excluded: false };
97
+ const path = canonical(row.path);
98
+ if (isSweep(path)) return { path, ...sweepEvidenceFor(path, expansions, sets) };
99
+ return {
100
+ path,
101
+ pathFact: pathFacts[path] ?? null,
102
+ staged: sets.staged.includes(path),
103
+ unstaged: sets.unstaged.includes(path),
104
+ untracked: sets.untracked.includes(path),
105
+ excluded: sets.excluded.includes(path),
106
+ };
107
+ };
108
+
109
+ export const gatherPlanFacts = ({ cwd, env = process.env, deps = {} }) => {
110
+ const entries = plansInFlight(cwd, deps.readdir).map((name) => readPlanEntry(cwd, name));
111
+ const ledgerPaths = unique(entries.flatMap(({ parsed }) => (parsed ? parsed.rows : []))
112
+ .filter(hasPath)
113
+ .map(({ path }) => canonical(path)));
114
+ const { pathFacts, expansions } = buildFacts(cwd, { paths: ledgerPaths });
115
+ const concretePaths = unique([...ledgerPaths.filter((path) => !isSweep(path)), ...Object.values(expansions).flat()]);
116
+ const working = computeWorkingState(cwd, { env });
117
+ if (working === null) throw new Error(`the working state of ${cwd} is not decidable`);
118
+ const staged = readGitPaths(STAGED_ARGV, { cwd, env, spawn: deps.spawn });
119
+ const excluded = concretePaths.length === 0
120
+ ? []
121
+ : readGitPaths(IGNORE_ARGV, { cwd, env, spawn: deps.spawn, accept: [0, 1], input: `${concretePaths.join(NUL)}${NUL}` });
122
+ const changed = { staged, unstaged: working.unstagedPaths, untracked: working.untrackedPaths };
123
+ const sets = { staged, unstaged: changed.unstaged, untracked: changed.untracked, excluded };
124
+ const plans = entries.map((entry) => ({
125
+ ...entry,
126
+ rows: (entry.parsed?.rows ?? []).map((row) => ({ row, raw: row.raw, evidence: evidenceFor(row, pathFacts, expansions, sets) })),
127
+ }));
128
+ return { plans, pathFacts, changed, excluded };
129
+ };
@@ -0,0 +1,162 @@
1
+ import { glyphsFor } from './presentation.mjs';
2
+ import { visibleLength } from './renderers.mjs';
3
+ import { foldQueue, isWithheld, judgeRow, derivePhase } from './now-status.mjs';
4
+
5
+ const BLOCK_TITLE = Object.freeze({ now: 'NOW', steps: 'STEPS', queue: 'QUEUE', campaign: 'CAMPAIGN' });
6
+ const STATUS_GLYPH = Object.freeze({
7
+ landed: 'present',
8
+ 'in progress': 'note',
9
+ pending: 'missing',
10
+ unjudged: 'unknown',
11
+ });
12
+ const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
13
+
14
+ const getHeading = (title, color) => color ? `${SGR.bold}${title}${SGR.reset}` : title;
15
+ const getStepName = (step) => step?.row ? `${step.row.id} · ${step.row.path}` : step?.name ?? 'none';
16
+ const getRedProof = (tree, path) => isWithheld(tree) ? null : tree.redProofs.find((proof) => proof.path === path) ?? null;
17
+
18
+ const getPlanView = (plan, tree) => {
19
+ if (!plan.parsed) return { path: plan.path, parseError: plan.parseError, phase: null, rows: [] };
20
+ const rows = plan.rows.map(({ row, raw, evidence }) => judgeRow({ ...row, raw }, {
21
+ ...evidence,
22
+ redProof: getRedProof(tree, evidence.path),
23
+ }));
24
+ return { path: plan.path, parseError: plan.parseError, phase: derivePhase(plan.parsed, rows), rows };
25
+ };
26
+
27
+ const getPhaseMarker = (step) => step?.row
28
+ ? { row: { id: step.row.id, path: step.row.path } }
29
+ : step ? { name: step.name } : null;
30
+ const getPhaseView = (phase) => ({
31
+ name: phase.name,
32
+ current: getPhaseMarker(phase.current),
33
+ next: getPhaseMarker(phase.next),
34
+ inProgress: phase.inProgress,
35
+ });
36
+
37
+ const getPlanBlocks = (plans, tree) => {
38
+ if (isWithheld(plans)) return { plans: plans, steps: plans };
39
+ const entries = plans.entries.map((plan) => getPlanView(plan, tree));
40
+ return {
41
+ plans: { entries: entries.map(({ path, parseError, phase }) => ({ path, parseError, phase: phase ? getPhaseView(phase) : null })) },
42
+ steps: { entries: entries.map(({ path, parseError, rows }) => ({ path, parseError, rows })) },
43
+ };
44
+ };
45
+
46
+ export const toNowViewModel = (facts) => {
47
+ const planBlocks = getPlanBlocks(facts.plans, facts.tree);
48
+ const plans = isWithheld(planBlocks.plans) ? planBlocks.plans : planBlocks.plans.entries;
49
+ const tree = isWithheld(facts.tree) ? facts.tree : { ...facts.tree };
50
+ const now = isWithheld(plans) && isWithheld(tree)
51
+ ? { withheld: [...new Set([plans.withheld, tree.withheld])].join(' \u00b7 ') }
52
+ : { plans, tree };
53
+ const queue = isWithheld(facts.queue)
54
+ ? facts.queue
55
+ : { path: facts.queue.path, ...foldQueue(facts.queue.rows) };
56
+ return { now, steps: planBlocks.steps, queue, campaign: isWithheld(facts.campaign) ? facts.campaign : { ...facts.campaign } };
57
+ };
58
+
59
+ const getWithheldLines = (title, block, color) => [getHeading(title, color), `${title} — WITHHELD: ${block.withheld}`];
60
+ const getAnnotationText = (annotation) => annotation.kind === 'red-proof'
61
+ ? `red-proof ${annotation.text}`
62
+ : annotation.text;
63
+ const getStatusTail = (step) => {
64
+ const detail = step.detail ? ` (${step.detail})` : '';
65
+ const annotations = step.annotations.map((annotation) => ` (${getAnnotationText(annotation)})`).join('');
66
+ return ` [${step.evidenceSource}]${detail}${annotations}`;
67
+ };
68
+
69
+ const renderNowBlock = (block, color, glyph) => {
70
+ if (isWithheld(block)) return getWithheldLines(BLOCK_TITLE.now, block, color);
71
+ const lines = [getHeading(BLOCK_TITLE.now, color)];
72
+ if (isWithheld(block.plans)) lines.push(` plans \u2014 WITHHELD: ${block.plans.withheld}`);
73
+ else if (block.plans.length === 0) lines.push(' no plan in flight');
74
+ for (const plan of isWithheld(block.plans) ? [] : block.plans) {
75
+ lines.push(` plan: ${plan.path}`);
76
+ if (plan.parseError) {
77
+ lines.push(` ${glyph.unknown} could not parse: ${plan.parseError}`);
78
+ continue;
79
+ }
80
+ lines.push(` phase: ${plan.phase.name}`);
81
+ lines.push(` current: ${getStepName(plan.phase.current)}`);
82
+ lines.push(` next: ${getStepName(plan.phase.next)}`);
83
+ for (const step of plan.phase.inProgress) lines.push(` ${glyph.note} in progress: ${getStepName(step)} — ${step.status}${getStatusTail(step)}`);
84
+ }
85
+ const tree = block.tree;
86
+ if (isWithheld(tree)) {
87
+ lines.push(` tree \u2014 WITHHELD: ${tree.withheld}`);
88
+ return lines;
89
+ }
90
+ const clean = tree.clean === null ? 'unknown' : tree.clean ? 'clean' : 'changed';
91
+ lines.push(` tree: ${tree.fingerprint} · ${clean}`);
92
+ for (const verdict of tree.verdicts) lines.push(` ${glyph.note} ${verdict.line}`);
93
+ lines.push(tree.finalRun
94
+ ? ` final run: ${tree.finalRun.status} ${tree.finalRun.gates} gates · ${tree.finalRun.fingerprintBefore} · ${tree.finalRun.timestamp} · ${tree.finalRun.coverage}`
95
+ : ' final run: none');
96
+ for (const proof of tree.redProofs) lines.push(` red-proof: ${proof.path} · ${proof.currency}`);
97
+ return lines;
98
+ };
99
+
100
+ const renderStepsBlock = (block, color, glyph) => {
101
+ if (isWithheld(block)) return getWithheldLines(BLOCK_TITLE.steps, block, color);
102
+ const lines = [getHeading(BLOCK_TITLE.steps, color)];
103
+ if (block.entries.length === 0) lines.push(' no steps — no plan in flight');
104
+ for (const plan of block.entries) {
105
+ lines.push(` plan: ${plan.path}`);
106
+ if (plan.parseError) {
107
+ lines.push(` WITHHELD: ${plan.parseError}`);
108
+ continue;
109
+ }
110
+ for (const step of plan.rows) {
111
+ lines.push(` ${glyph[STATUS_GLYPH[step.status]]} ${getStepName(step)} — ${step.status}${getStatusTail(step)}`);
112
+ }
113
+ }
114
+ return lines;
115
+ };
116
+
117
+ const renderQueueBlock = (block, color) => {
118
+ if (isWithheld(block)) return getWithheldLines(BLOCK_TITLE.queue, block, color);
119
+ const counts = Object.entries(block.counts).map(([klass, count]) => `${klass} ${count}`).join(' · ');
120
+ const lines = [getHeading(BLOCK_TITLE.queue, color), ` ${block.path} · ${block.total} rows · ${counts}`];
121
+ for (const bucket of block.buckets) {
122
+ const domain = bucket.order === 'priority' ? 'priority' : 'archive';
123
+ const bucketCounts = Object.entries(bucket.counts).map(([klass, count]) => `${klass} ${count}`).join(' · ');
124
+ lines.push(` ${domain} · ${bucket.label} · ${bucketCounts}`);
125
+ for (const row of bucket.rows) lines.push(` ${domain} · ${bucket.label} · ${bucket.order} ${row.position}: ${row.name} — ${row.klass}`);
126
+ }
127
+ return lines;
128
+ };
129
+
130
+ const getFigure = (value) => value === null || value === undefined ? 'not recorded' : String(value);
131
+ const renderCampaignBlock = (block, color) => {
132
+ if (isWithheld(block)) return getWithheldLines(BLOCK_TITLE.campaign, block, color);
133
+ const lines = [getHeading(BLOCK_TITLE.campaign, color), ` state: ${block.state}`];
134
+ if (block.state !== 'absent') {
135
+ lines.push(` caps: ${block.maxLines} lines · ${block.maxLineBytes} bytes per line`);
136
+ lines.push(` roots: ${block.roots} · recorded files: ${getFigure(block.recordedFiles)} · aggregate lines: ${getFigure(block.aggregateLines)}`);
137
+ }
138
+ return lines;
139
+ };
140
+
141
+ const padLine = (line, width) => {
142
+ const visible = visibleLength(line);
143
+ return line === '' || visible >= width ? line : line + ' '.repeat(width - visible);
144
+ };
145
+
146
+ export const renderNow = (vm, surface = {}) => {
147
+ if (surface.mode === 'json') return '';
148
+ const color = surface.mode === 'ansi' && Boolean(surface.color);
149
+ const glyph = glyphsFor(Boolean(surface.ascii));
150
+ const lines = [
151
+ ...renderNowBlock(vm.now, color, glyph),
152
+ '',
153
+ ...renderStepsBlock(vm.steps, color, glyph),
154
+ '',
155
+ ...renderQueueBlock(vm.queue, color),
156
+ '',
157
+ ...renderCampaignBlock(vm.campaign, color),
158
+ ];
159
+ return surface.mode === 'ansi'
160
+ ? lines.map((line) => padLine(line, surface.width ?? 80)).join('\n')
161
+ : lines.join('\n');
162
+ };