codecartographer-pi 0.16.0 → 0.17.1

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 (77) hide show
  1. package/.codecarto/GUIDE.md +16 -3
  2. package/.codecarto/README.md +3 -0
  3. package/.codecarto/broadside/SKILL.md +143 -0
  4. package/.codecarto/broadside/config.yaml +104 -0
  5. package/.codecarto/findings/architecture/SKILL.md +1 -0
  6. package/.codecarto/findings/broadside-scout/README.md +20 -0
  7. package/.codecarto/findings/broadside-scout/SKILL.md +101 -0
  8. package/.codecarto/findings/contracts/SKILL.md +1 -0
  9. package/.codecarto/findings/defect-scan/SKILL.md +15 -1
  10. package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +1 -1
  11. package/.codecarto/findings/defect-scan/passes/02-error-handling.md +1 -1
  12. package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +1 -1
  13. package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +1 -1
  14. package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +2 -1
  15. package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +1 -1
  16. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +3 -2
  17. package/.codecarto/findings/defect-scan-semantic/SKILL.md +2 -0
  18. package/.codecarto/findings/porting/SKILL.md +2 -1
  19. package/.codecarto/findings/protocols/SKILL.md +1 -0
  20. package/.codecarto/findings/reimplementation-spec/SKILL.md +1 -1
  21. package/.codecarto/skills/spec-delta-application/SKILL.md +3 -1
  22. package/.codecarto/templates/architecture-map.md +1 -1
  23. package/.codecarto/templates/backlog-project.md +51 -0
  24. package/.codecarto/templates/broadside-scout-brief.md +97 -0
  25. package/.codecarto/templates/defect-report.md +23 -0
  26. package/.codecarto/templates/mechanical-defects.md +22 -0
  27. package/.codecarto/templates/reverse-engineering-bundle.md +2 -2
  28. package/.codecarto/templates/semantic-defects.md +26 -0
  29. package/.codecarto/{THREAD_LOG.md → templates/thread-log.md} +2 -5
  30. package/.codecarto/workflow/VALIDATE.md +1 -1
  31. package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
  32. package/.codecarto/workflow/pipeline-full-with-audit.yaml +3 -1
  33. package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +5 -1
  34. package/.codecarto/workflow/pipeline-scout-first.yaml +275 -0
  35. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  36. package/README.md +51 -6
  37. package/agent-skill/codecartographer/SKILL.md +3 -1
  38. package/agent-skill/codecartographer/references/broadside.md +115 -0
  39. package/agent-skill/codecartographer/references/deep-audit-synthesis.md +4 -1
  40. package/agent-skill/codecartographer/references/library.md +2 -2
  41. package/agent-skill/codecartographer/references/orchestration.md +1 -1
  42. package/agent-skill/codecartographer/references/pipeline-selection.md +14 -0
  43. package/dist/core/amendment.js +2 -2
  44. package/dist/core/broadside.d.ts +421 -0
  45. package/dist/core/broadside.js +2349 -0
  46. package/dist/core/completion.d.ts +5 -0
  47. package/dist/core/completion.js +38 -6
  48. package/dist/core/dashboard.js +5 -3
  49. package/dist/core/findings.d.ts +59 -0
  50. package/dist/core/findings.js +145 -0
  51. package/dist/core/index.d.ts +2 -0
  52. package/dist/core/index.js +2 -0
  53. package/dist/core/library.d.ts +88 -1
  54. package/dist/core/library.js +260 -7
  55. package/dist/core/orchestrator-config.js +5 -2
  56. package/dist/core/pipeline.js +16 -0
  57. package/dist/core/prompts.js +1 -1
  58. package/dist/core/status.js +23 -7
  59. package/dist/core/types.d.ts +6 -0
  60. package/dist/core/utils.d.ts +14 -0
  61. package/dist/core/utils.js +37 -1
  62. package/dist/core/workspace.d.ts +17 -0
  63. package/dist/core/workspace.js +79 -20
  64. package/dist/core/yaml.js +19 -4
  65. package/dist/extensions/codecarto/agent-runner.js +6 -0
  66. package/dist/extensions/codecarto/auto-runner.d.ts +2 -0
  67. package/dist/extensions/codecarto/broadside-flags.d.ts +26 -0
  68. package/dist/extensions/codecarto/broadside-flags.js +129 -0
  69. package/dist/extensions/codecarto/dashboard-narrator.js +1 -1
  70. package/dist/extensions/codecarto/index.js +270 -18
  71. package/dist/extensions/codecarto/phase-compaction.js +4 -0
  72. package/dist/mcp-server/server.d.ts +22 -0
  73. package/dist/mcp-server/server.js +282 -17
  74. package/package.json +11 -2
  75. package/.codecarto/BACKLOG.md +0 -184
  76. package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +0 -118
  77. package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +0 -111
@@ -3,11 +3,11 @@
3
3
  // + normalizes the per-project workspace state from disk, and provides the
4
4
  // atomic status-update primitive used by /codecarto-complete.
5
5
  import { existsSync, readFileSync } from "node:fs";
6
- import { appendFile, copyFile, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
6
+ import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
7
7
  import { basename, dirname, join, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { acquireLock, applyHandoff, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.js";
10
- import { pathExists } from "./utils.js";
10
+ import { compareDottedVersions, newlineIfUnterminated, pathExists } from "./utils.js";
11
11
  import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
12
12
  // Walk up from the current file to find the package root. Needed because the
13
13
  // source lives at <root>/core/workspace.ts (one level below the package root)
@@ -102,7 +102,71 @@ export async function getWorkspaceState(cwd) {
102
102
  export const ORCHESTRATOR_FILES = [
103
103
  { file: "CONVENTIONS.md", template: "conventions-template.md" },
104
104
  { file: "DECISIONS.md", template: "decisions-template.md" },
105
+ { file: "BACKLOG.md", template: "backlog-project.md" },
106
+ { file: "THREAD_LOG.md", template: "thread-log.md" },
105
107
  ];
108
+ /**
109
+ * Project state that must never travel from the packaged template into a new
110
+ * workspace.
111
+ *
112
+ * This repository's `.codecarto/` is two things at once: the template that gets
113
+ * copied into a user's repo, and CodeCartographer's own live workspace. The
114
+ * second role writes real project state into it — a backlog of framework
115
+ * deferrals, a thread log, closeouts of sessions where CodeCartographer
116
+ * analyzed itself. Copying the tree wholesale handed every new workspace ~40 KB
117
+ * of another project's history as its own, and the damage was not only clutter:
118
+ * GUIDE.md keys first-time-project setup on `closeouts/` being empty, so a
119
+ * shipped closeout told every new session it was not the first to touch the
120
+ * project, suppressing the orchestrator role that issues #97/#98 made the
121
+ * default.
122
+ *
123
+ * The four top-level files are seeded fresh from templates instead
124
+ * ({@link ORCHESTRATOR_FILES}); `closeouts/` is created empty.
125
+ */
126
+ const INIT_EXCLUDED_TOP_LEVEL = new Set(["BACKLOG.md", "THREAD_LOG.md", "CONVENTIONS.md", "DECISIONS.md"]);
127
+ const INIT_EXCLUDED_DIR_CONTENTS = new Set(["closeouts"]);
128
+ // broadside/ is machine-local scan state (state.json, timestamped run dirs)
129
+ // except for its two template files — the same carve-out .codecarto/.gitignore
130
+ // makes for this repository itself. Without this, init from a local checkout
131
+ // with live scan state handed every new workspace another project's runs.
132
+ // Literal rather than an import from broadside.ts, which imports this module.
133
+ const BROADSIDE_DIR_NAME = "broadside";
134
+ const INIT_BROADSIDE_TEMPLATE_FILES = new Set(["SKILL.md", "config.yaml"]);
135
+ /**
136
+ * Copy the packaged template into a target workspace, skipping this
137
+ * repository's own project state. Directories are still created, so a fresh
138
+ * workspace has an empty `closeouts/` rather than no `closeouts/`.
139
+ *
140
+ * @param targetWorkspaceDir - Absolute path to the `.codecarto/` to create or merge into.
141
+ * @param sourceWorkspaceDir - The template to copy from. Defaults to the packaged
142
+ * template; tests pass a synthetic directory so they can prove the state filter
143
+ * without mutating the repository's own live workspace mid-suite.
144
+ */
145
+ export async function copyPackagedWorkspace(targetWorkspaceDir, sourceWorkspaceDir = packagedWorkspaceDir) {
146
+ await cp(sourceWorkspaceDir, targetWorkspaceDir, {
147
+ recursive: true,
148
+ filter: (source) => {
149
+ const relativePath = relative(sourceWorkspaceDir, source);
150
+ if (!relativePath)
151
+ return true; // the workspace root itself
152
+ const segments = relativePath.split(/[\\/]/);
153
+ if (segments.length === 1)
154
+ return !INIT_EXCLUDED_TOP_LEVEL.has(segments[0]);
155
+ if (segments[0] === BROADSIDE_DIR_NAME) {
156
+ return segments.length === 2 && INIT_BROADSIDE_TEMPLATE_FILES.has(segments[1]);
157
+ }
158
+ // Keep the directory, drop what this repository wrote inside it.
159
+ return !INIT_EXCLUDED_DIR_CONTENTS.has(segments[0]);
160
+ },
161
+ });
162
+ // The published tarball carries no empty directories, so an excluded-contents
163
+ // directory may not exist to be copied at all. Create them either way: a
164
+ // workspace whose closeouts/ is missing rather than empty reads differently
165
+ // to anything that lists it.
166
+ for (const name of INIT_EXCLUDED_DIR_CONTENTS) {
167
+ await mkdir(join(targetWorkspaceDir, name), { recursive: true });
168
+ }
169
+ }
106
170
  /**
107
171
  * Seed the orchestrator-maintained files from the workspace's templates
108
172
  * (issue #98): orchestration is on by default, so a fresh workspace starts
@@ -131,7 +195,9 @@ export async function seedOrchestratorFiles(workspaceDir) {
131
195
  * Everything else present in the packaged template is framework-owned.
132
196
  */
133
197
  const REFRESH_EXCLUDED_TOP_LEVEL = new Set(["BACKLOG.md", "THREAD_LOG.md", "CONVENTIONS.md", "DECISIONS.md"]);
134
- const REFRESH_EXCLUDED_DIRS = new Set(["scratch", "inputs", "closeouts"]);
198
+ // broadside/ holds machine-local scout state (batch ids, API key config,
199
+ // generated results) — refresh must never overwrite it.
200
+ const REFRESH_EXCLUDED_DIRS = new Set(["scratch", "inputs", "closeouts", "broadside"]);
135
201
  const REFRESH_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", "config.yaml", ".usage.local.yaml"]);
136
202
  async function listTemplateFiles(dir, relativeDir = "") {
137
203
  const entries = await readdir(dir, { withFileTypes: true });
@@ -179,7 +245,15 @@ export async function refreshScaffold(cwd) {
179
245
  await copyFile(join(packagedWorkspaceDir, relativePath), target);
180
246
  }
181
247
  const entry = `- ${new Date().toISOString().slice(0, 10)} — scaffold-refresh — Refreshed ${files.length} framework-owned file(s) from the packaged template (${scaffoldVersionBefore ?? "unversioned"} → ${PACKAGE_VERSION}); project state, user config, and session outputs untouched.`;
182
- await appendFile(join(state.workspaceDir, "THREAD_LOG.md"), `${entry}\n`, "utf8");
248
+ const threadLogPath = join(state.workspaceDir, "THREAD_LOG.md");
249
+ let currentLog = "";
250
+ try {
251
+ currentLog = await readFile(threadLogPath, "utf8");
252
+ }
253
+ catch {
254
+ // Created by the append when absent (pre-template scaffolds).
255
+ }
256
+ await appendFile(threadLogPath, `${newlineIfUnterminated(currentLog)}${entry}\n`, "utf8");
183
257
  return {
184
258
  written: files,
185
259
  ...(scaffoldVersionBefore !== undefined && { scaffoldVersionBefore }),
@@ -187,21 +261,6 @@ export async function refreshScaffold(cwd) {
187
261
  };
188
262
  }
189
263
  // Numeric x.y.z comparison; null when either side is not a plain dotted triple.
190
- function compareDottedVersions(a, b) {
191
- const parse = (version) => {
192
- const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
193
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
194
- };
195
- const left = parse(a);
196
- const right = parse(b);
197
- if (!left || !right)
198
- return null;
199
- for (let i = 0; i < 3; i++) {
200
- if (left[i] !== right[i])
201
- return left[i] < right[i] ? -1 : 1;
202
- }
203
- return 0;
204
- }
205
264
  /**
206
265
  * Human-readable staleness notice for the workspace's .codecarto/ scaffold,
207
266
  * or null when the scaffold matches the running framework. A missing marker
@@ -263,7 +322,7 @@ export async function updateStatusAtomically(cwd, updater) {
263
322
  const normalizedEntry = result.threadLogEntry.trim();
264
323
  const isDuplicate = logEntries.some((line) => line.trim() === normalizedEntry);
265
324
  if (!isDuplicate) {
266
- await appendFile(threadLogPath, result.threadLogEntry, "utf8");
325
+ await appendFile(threadLogPath, `${newlineIfUnterminated(currentLog)}${normalizedEntry}\n`, "utf8");
267
326
  }
268
327
  }
269
328
  return nextState;
package/dist/core/yaml.js CHANGED
@@ -3,6 +3,18 @@
3
3
  // Round-trips structured carry_forward/open_questions entries.
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { isPlainObject } from "./utils.js";
6
+ /**
7
+ * True when the double quote at `index` is escaped, i.e. preceded by an odd
8
+ * run of backslashes. Checking only the single previous character read `\\"`
9
+ * (an escaped backslash, then the closing quote) as an escaped quote, so the
10
+ * scalar never closed and a trailing ` # comment` leaked into the value (#134).
11
+ */
12
+ function isEscapedQuote(text, index) {
13
+ let backslashes = 0;
14
+ for (let i = index - 1; i >= 0 && text[i] === "\\"; i--)
15
+ backslashes++;
16
+ return backslashes % 2 === 1;
17
+ }
6
18
  export function stripYamlComment(value) {
7
19
  let inSingle = false;
8
20
  let inDouble = false;
@@ -12,7 +24,7 @@ export function stripYamlComment(value) {
12
24
  inSingle = !inSingle;
13
25
  continue;
14
26
  }
15
- if (char === '"' && !inSingle && value[i - 1] !== "\\") {
27
+ if (char === '"' && !inSingle && !isEscapedQuote(value, i)) {
16
28
  inDouble = !inDouble;
17
29
  continue;
18
30
  }
@@ -49,7 +61,7 @@ function findKeySeparator(text) {
49
61
  inSingle = !inSingle;
50
62
  continue;
51
63
  }
52
- if (char === '"' && !inSingle && text[i - 1] !== "\\") {
64
+ if (char === '"' && !inSingle && !isEscapedQuote(text, i)) {
53
65
  inDouble = !inDouble;
54
66
  continue;
55
67
  }
@@ -77,7 +89,10 @@ export function parseYamlScalar(rawValue) {
77
89
  return Number.parseInt(trimmed, 10);
78
90
  if (/^-?\d+\.\d+$/.test(trimmed))
79
91
  return Number.parseFloat(trimmed);
80
- if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
92
+ // A quoted scalar needs at least its two quotes: a lone quote character
93
+ // satisfied startsWith and endsWith at once and was sliced to "" (#134).
94
+ const canBeQuoted = trimmed.length >= 2;
95
+ if (canBeQuoted && trimmed.startsWith('"') && trimmed.endsWith('"')) {
81
96
  try {
82
97
  return JSON.parse(trimmed);
83
98
  }
@@ -85,7 +100,7 @@ export function parseYamlScalar(rawValue) {
85
100
  return trimmed.slice(1, -1);
86
101
  }
87
102
  }
88
- if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
103
+ if (canBeQuoted && trimmed.startsWith("'") && trimmed.endsWith("'")) {
89
104
  return trimmed.slice(1, -1).replace(/''/g, "'");
90
105
  }
91
106
  return trimmed;
@@ -183,6 +183,12 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
183
183
  }
184
184
  try {
185
185
  await session.prompt(prompt);
186
+ // If no compaction fired during the run, the promise above would
187
+ // otherwise strand the continuation path for the full settle timeout
188
+ // (#129). Settle it with what actually happened — resolve() is
189
+ // idempotent, so a real compaction_end event earlier in the run
190
+ // keeps its `true`.
191
+ resolveCompaction?.(false);
186
192
  let primaryOutputPresent = true;
187
193
  if (options.primaryOutput) {
188
194
  primaryOutputPresent = await primaryOutputExists(cwd, options.primaryOutput);
@@ -41,6 +41,8 @@ export declare function isPhaseRunning(phaseId: string): boolean;
41
41
  export interface AutoCompleteResult {
42
42
  updatedState: WorkspaceState;
43
43
  closeoutNotice?: string;
44
+ /** Non-gating closure-integrity notes from completion (#122). */
45
+ warnings: string[];
44
46
  }
45
47
  export declare function autoCompletePhase(ctx: ExtensionContext, validation: ValidationResult): Promise<AutoCompleteResult>;
46
48
  export type AutoOutcome = "complete" | "stopped" | "aborted";
@@ -0,0 +1,26 @@
1
+ import { type BroadsideLensId } from "../../core/index.ts";
2
+ export type BroadsideAction = "submit" | "collect" | "status" | "models";
3
+ export interface BroadsideFlags {
4
+ action: BroadsideAction;
5
+ /** Empty means "the repository's default lens set". */
6
+ lenses: BroadsideLensId[];
7
+ /**
8
+ * Undefined means "use the repository's config default". --incremental sets
9
+ * true and --no-incremental sets false, so a config-set `incremental: true`
10
+ * can be overridden back to a full scan for one run (#163).
11
+ */
12
+ incremental?: boolean;
13
+ includeSynthesis?: boolean;
14
+ includeTriage?: boolean;
15
+ retryTruncated?: boolean;
16
+ /** Undefined means "use the repository's config default". */
17
+ maxCost?: number;
18
+ waitSeconds?: number;
19
+ benchmarks: boolean;
20
+ unknown: string[];
21
+ /** Set on an invalid combination. The caller surfaces it as an error. */
22
+ error?: string;
23
+ }
24
+ /** Every token the completer offers, in the order it offers them. */
25
+ export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--no-incremental", "--max-cost=", "--wait=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
26
+ export declare function parseBroadsideFlags(args: string): BroadsideFlags;
@@ -0,0 +1,129 @@
1
+ // Argument parser for /codecarto-broadside. The grammar is one optional
2
+ // action followed by lens names and flags, in any order:
3
+ //
4
+ // /codecarto-broadside → submit, default lenses
5
+ // /codecarto-broadside submit architecture security → submit, two lenses
6
+ // /codecarto-broadside collect --wait=900
7
+ // /codecarto-broadside status
8
+ // /codecarto-broadside models --benchmarks
9
+ //
10
+ // Flags mirror the codecarto_broadside tool parameters, with the negative
11
+ // forms spelled out because a slash command has no place to pass `false`:
12
+ // --incremental --no-synthesis
13
+ // --no-incremental --no-triage
14
+ // --max-cost=N --no-retry-truncated
15
+ // --wait=SECONDS --benchmarks (models only)
16
+ //
17
+ // --incremental has a spelled-out negative because the value is tri-state:
18
+ // absent defers to config.yaml, so a repository that set `incremental: true`
19
+ // can still ask for a one-off full scan (#163), exactly as an MCP caller can
20
+ // with `incremental: false`.
21
+ //
22
+ // The parser never throws. index.ts decides how to surface unknown tokens and
23
+ // invalid combinations, matching parseNextFlags.
24
+ import { BROADSIDE_LENS_IDS } from "../../core/index.js";
25
+ const ACTIONS = new Set(["submit", "collect", "status", "models"]);
26
+ /** Every token the completer offers, in the order it offers them. */
27
+ export const KNOWN_BROADSIDE_TOKENS = [
28
+ "submit",
29
+ "collect",
30
+ "status",
31
+ "models",
32
+ ...BROADSIDE_LENS_IDS,
33
+ "--incremental",
34
+ "--no-incremental",
35
+ "--max-cost=",
36
+ "--wait=",
37
+ "--no-synthesis",
38
+ "--no-triage",
39
+ "--no-retry-truncated",
40
+ "--benchmarks",
41
+ ];
42
+ // A numeric flag with a missing or unparseable value is an error, not a
43
+ // silent fallback to the config default: "--max-cost=" almost certainly means
44
+ // the user meant to cap the spend and mistyped it.
45
+ function parseNumeric(token, name, result) {
46
+ const raw = token.slice(name.length + 1);
47
+ const value = Number(raw);
48
+ if (!raw || !Number.isFinite(value) || value < 0) {
49
+ result.error = `${name} needs a non-negative number (got "${raw}").`;
50
+ return undefined;
51
+ }
52
+ return value;
53
+ }
54
+ export function parseBroadsideFlags(args) {
55
+ const tokens = args.trim().split(/\s+/).filter((token) => token.length > 0);
56
+ const result = {
57
+ action: "submit",
58
+ lenses: [],
59
+ benchmarks: false,
60
+ unknown: [],
61
+ };
62
+ let actionSeen = false;
63
+ for (const token of tokens) {
64
+ if (!actionSeen && ACTIONS.has(token)) {
65
+ result.action = token;
66
+ actionSeen = true;
67
+ continue;
68
+ }
69
+ if (BROADSIDE_LENS_IDS.includes(token)) {
70
+ // A lens named twice is one lens, not two batches of it.
71
+ if (!result.lenses.includes(token))
72
+ result.lenses.push(token);
73
+ continue;
74
+ }
75
+ if (token === "--incremental" || token === "--no-incremental") {
76
+ const value = token === "--incremental";
77
+ // Last-one-wins would be a silent tiebreak on a command that spends
78
+ // money; a contradiction is an error.
79
+ if (result.incremental !== undefined && result.incremental !== value) {
80
+ result.error ??= "--incremental and --no-incremental contradict each other; pass one or neither.";
81
+ }
82
+ result.incremental = value;
83
+ continue;
84
+ }
85
+ if (token === "--no-synthesis") {
86
+ result.includeSynthesis = false;
87
+ continue;
88
+ }
89
+ if (token === "--no-triage") {
90
+ result.includeTriage = false;
91
+ continue;
92
+ }
93
+ if (token === "--no-retry-truncated") {
94
+ result.retryTruncated = false;
95
+ continue;
96
+ }
97
+ if (token === "--benchmarks") {
98
+ result.benchmarks = true;
99
+ continue;
100
+ }
101
+ if (token.startsWith("--max-cost=")) {
102
+ result.maxCost = parseNumeric(token, "--max-cost", result);
103
+ continue;
104
+ }
105
+ if (token.startsWith("--wait=")) {
106
+ result.waitSeconds = parseNumeric(token, "--wait", result);
107
+ continue;
108
+ }
109
+ result.unknown.push(token);
110
+ }
111
+ // Flags that only mean something for one action are refused rather than
112
+ // ignored: silently dropping --incremental on a collect would read as
113
+ // "collected incrementally", which is not a thing (and --no-incremental
114
+ // would read as a full re-collect, which is not one either).
115
+ if (result.lenses.length > 0 && result.action !== "submit") {
116
+ result.error ??= `Lens names are only meaningful for submit (got action "${result.action}").`;
117
+ }
118
+ if (result.incremental !== undefined && result.action !== "submit") {
119
+ const flag = result.incremental ? "--incremental" : "--no-incremental";
120
+ result.error ??= `${flag} is only meaningful for submit (got action "${result.action}").`;
121
+ }
122
+ if (result.benchmarks && result.action !== "models") {
123
+ result.error ??= `--benchmarks is only meaningful for models (got action "${result.action}").`;
124
+ }
125
+ if (result.action === "status" && result.waitSeconds !== undefined) {
126
+ result.error ??= "--wait is only meaningful for submit and collect; status reads recorded state.";
127
+ }
128
+ return result;
129
+ }
@@ -66,7 +66,7 @@ async function readRecentCloseouts(workspaceDir) {
66
66
  return m ? { date: m[1], phaseOrModule: m[2], fileName: name } : null;
67
67
  })
68
68
  .filter((x) => x !== null)
69
- .sort((a, b) => (a.date < b.date ? 1 : -1))
69
+ .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
70
70
  .slice(0, MAX_CLOSEOUTS);
71
71
  const out = [];
72
72
  for (const entry of matched) {