codecartographer-pi 0.17.0 → 0.18.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 (66) hide show
  1. package/.codecarto/GUIDE.md +3 -3
  2. package/.codecarto/findings/architecture/SKILL.md +1 -0
  3. package/.codecarto/findings/contracts/SKILL.md +1 -0
  4. package/.codecarto/findings/defect-scan/SKILL.md +15 -1
  5. package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +1 -1
  6. package/.codecarto/findings/defect-scan/passes/02-error-handling.md +1 -1
  7. package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +1 -1
  8. package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +1 -1
  9. package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +2 -1
  10. package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +1 -1
  11. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +3 -2
  12. package/.codecarto/findings/defect-scan-semantic/SKILL.md +2 -0
  13. package/.codecarto/findings/porting/SKILL.md +2 -1
  14. package/.codecarto/findings/protocols/SKILL.md +1 -0
  15. package/.codecarto/findings/reimplementation-spec/SKILL.md +1 -1
  16. package/.codecarto/skills/spec-delta-application/SKILL.md +1 -1
  17. package/.codecarto/templates/amendment.yaml +3 -3
  18. package/.codecarto/templates/architecture-map.md +1 -1
  19. package/.codecarto/templates/defect-report.md +23 -0
  20. package/.codecarto/templates/mechanical-defects.md +22 -0
  21. package/.codecarto/templates/reverse-engineering-bundle.md +2 -2
  22. package/.codecarto/templates/semantic-defects.md +26 -0
  23. package/.codecarto/templates/spike-report.md +2 -2
  24. package/.codecarto/workflow/VALIDATE.md +1 -1
  25. package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
  26. package/.codecarto/workflow/pipeline-full-with-audit.yaml +3 -1
  27. package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +5 -1
  28. package/.codecarto/workflow/pipeline-scout-first.yaml +5 -1
  29. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  30. package/README.md +14 -10
  31. package/agent-skill/codecartographer/SKILL.md +1 -1
  32. package/agent-skill/codecartographer/references/deep-audit-synthesis.md +4 -1
  33. package/agent-skill/codecartographer/references/library.md +3 -3
  34. package/agent-skill/codecartographer/references/orchestration.md +1 -1
  35. package/agent-skill/codecartographer/references/phase-recovery.md +1 -1
  36. package/dist/core/amendment.d.ts +5 -0
  37. package/dist/core/amendment.js +23 -4
  38. package/dist/core/completion.d.ts +5 -0
  39. package/dist/core/completion.js +18 -2
  40. package/dist/core/dashboard.js +5 -3
  41. package/dist/core/findings.d.ts +59 -0
  42. package/dist/core/findings.js +145 -0
  43. package/dist/core/index.d.ts +1 -0
  44. package/dist/core/index.js +1 -0
  45. package/dist/core/library.d.ts +139 -1
  46. package/dist/core/library.js +291 -40
  47. package/dist/core/orchestrator-config.d.ts +6 -0
  48. package/dist/core/orchestrator-config.js +2 -0
  49. package/dist/core/pipeline.js +15 -0
  50. package/dist/core/prompts.js +1 -1
  51. package/dist/core/status.d.ts +2 -1
  52. package/dist/core/status.js +33 -11
  53. package/dist/core/types.d.ts +6 -0
  54. package/dist/core/utils.d.ts +14 -0
  55. package/dist/core/utils.js +30 -0
  56. package/dist/core/workspace.d.ts +16 -0
  57. package/dist/core/workspace.js +42 -22
  58. package/dist/core/yaml.js +19 -4
  59. package/dist/extensions/codecarto/auto-runner.d.ts +2 -0
  60. package/dist/extensions/codecarto/broadside-flags.d.ts +7 -2
  61. package/dist/extensions/codecarto/broadside-flags.js +22 -9
  62. package/dist/extensions/codecarto/dashboard-narrator.js +1 -1
  63. package/dist/extensions/codecarto/index.js +379 -20
  64. package/dist/extensions/codecarto/phase-compaction.js +4 -0
  65. package/dist/mcp-server/server.js +127 -10
  66. package/package.json +2 -2
@@ -2,6 +2,7 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { basename, join } from "node:path";
4
4
  import { pathExists } from "./utils.js";
5
+ import { crossCheckFindings, findingsPairingGateActive } from "./findings.js";
5
6
  export const PIPELINE_ALIASES = {
6
7
  "full-with-audit": "workflow/pipeline-full-with-audit.yaml",
7
8
  "full-with-deep-audit": "workflow/pipeline-full-with-deep-audit.yaml",
@@ -155,6 +156,16 @@ export async function validatePhaseOutput(state, phaseId) {
155
156
  errors.push("One or more validation criteria are marked FAIL.");
156
157
  overall = "FAIL";
157
158
  }
159
+ // Findings cross-checks (#122): the validation table says whether criteria
160
+ // were met; these read what the findings' own evidence and action cells
161
+ // say. Deterministic on two cells the model wrote, so the pairing rule can
162
+ // gate — on a scaffold that offers `verify at runtime`. Older scaffolds warn.
163
+ const crossCheck = crossCheckFindings(content, { gate: findingsPairingGateActive(state.scaffoldVersion) });
164
+ if (crossCheck.errors.length > 0) {
165
+ errors.push(...crossCheck.errors);
166
+ overall = "FAIL";
167
+ }
168
+ const warnings = crossCheck.warnings;
158
169
  if (overall === "FAIL" && errors.length === 0) {
159
170
  errors.push("Validation overall result is FAIL.");
160
171
  }
@@ -169,6 +180,7 @@ export async function validatePhaseOutput(state, phaseId) {
169
180
  gaps,
170
181
  errors,
171
182
  secondaryOutputs,
183
+ ...(warnings.length > 0 && { warnings }),
172
184
  };
173
185
  }
174
186
  export function buildValidationSummary(validation) {
@@ -184,6 +196,9 @@ export function buildValidationSummary(validation) {
184
196
  if (validation.errors.length > 0) {
185
197
  lines.push(...validation.errors.slice(0, 3));
186
198
  }
199
+ for (const warning of validation.warnings ?? []) {
200
+ lines.push(`NOTE: ${warning} Non-gating.`);
201
+ }
187
202
  const missingSecondary = (validation.secondaryOutputs ?? []).filter((output) => !output.exists);
188
203
  if (missingSecondary.length > 0) {
189
204
  lines.push(`NOTE: ${missingSecondary.length} declared secondary output(s) not written: ${missingSecondary.map((output) => `.codecarto/${output.path}`).join(", ")} — write each, or account for it in Coverage and limits / a routed handoff entry. Non-gating.`);
@@ -33,7 +33,7 @@ async function buildOrchestratorDuties(state, phase, auto) {
33
33
  }
34
34
  }
35
35
  if (retriage.length > 0) {
36
- lines.push("- Re-triage these open questions' kind labels — a label is a claim needing its own evidence; re-test whether each is now answerable by reading before accepting it:");
36
+ lines.push("- Re-triage these open questions' kind labels — a label is a claim needing its own evidence; re-test whether each is now answerable by reading before accepting it. If one still needs a runtime test, no finding in this phase may assert one of its candidate answers with a settled action (fix before porting / fix now): the finding inherits the question's uncertainty as `verify at runtime` until runtime evidence closes the question:");
37
37
  for (const label of retriage.slice(0, RETRIAGE_LIST_LIMIT))
38
38
  lines.push(` - ${label}`);
39
39
  if (retriage.length > RETRIAGE_LIST_LIMIT)
@@ -14,7 +14,8 @@ export declare function createEmptyStatus(projectName: string, pipelinePath: str
14
14
  * moment every phase completes is exactly when skills, amendments, publishing,
15
15
  * and the dashboard apply; the prior static sentence left them undiscovered —
16
16
  * the 0.15.0 field test finished two full runs with every one of them unused.
17
- * Amendment recomputes this list so closure counts never go stale.
17
+ * Amendment recomputes this list so closure counts never go stale. Every tool
18
+ * named here is spelled for both surfaces (see onBothSurfaces).
18
19
  */
19
20
  export declare function buildTerminalNextActions(status: NormalizedStatus): string[];
20
21
  export declare function normalizeStatus(status: StatusFile, pipeline: PipelineFile, pipelinePath: string, cwd: string): NormalizedStatus;
@@ -126,26 +126,40 @@ export function createEmptyStatus(projectName, pipelinePath, pipeline) {
126
126
  post_pipeline: [],
127
127
  };
128
128
  }
129
+ /**
130
+ * Spell a tool for both executable surfaces — the shape the scaffold
131
+ * staleness notice adopted (#177). next_actions is canonical state rendered
132
+ * by codecarto_status on MCP and as the Pi widget's "Next:" line alike, and a
133
+ * Pi user handed only the MCP tool name has nothing to run. Several tools
134
+ * join with "then" so a sequence reads as one per surface. No backticks:
135
+ * these lines render in a plain TUI line and in the HTML dashboard.
136
+ */
137
+ function onBothSurfaces(...tools) {
138
+ const mcp = tools.map((tool) => `codecarto_${tool}`).join(" then ");
139
+ const pi = tools.map((tool) => `/codecarto-${tool.replace(/_/g, "-")}`).join(" then ");
140
+ return `${mcp} on MCP, ${pi} on Pi`;
141
+ }
129
142
  /**
130
143
  * Route the terminal boundary to the post-pipeline surfaces (issue #114). The
131
144
  * moment every phase completes is exactly when skills, amendments, publishing,
132
145
  * and the dashboard apply; the prior static sentence left them undiscovered —
133
146
  * the 0.15.0 field test finished two full runs with every one of them unused.
134
- * Amendment recomputes this list so closure counts never go stale.
147
+ * Amendment recomputes this list so closure counts never go stale. Every tool
148
+ * named here is spelled for both surfaces (see onBothSurfaces).
135
149
  */
136
150
  export function buildTerminalNextActions(status) {
137
151
  const openQuestions = Object.values(status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
138
152
  const postPipeline = status.post_pipeline.length;
139
153
  const actions = [
140
- "All phases complete. Review findings; post-pipeline skills: codecarto_list_skills / codecarto_skill.",
154
+ `All phases complete. Review findings; post-pipeline skills: ${onBothSurfaces("list_skills", "skill")}.`,
141
155
  ];
142
156
  if (openQuestions > 0 || postPipeline > 0) {
143
- actions.push(`${openQuestions} open question(s) and ${postPipeline} post-pipeline item(s) remain — apply resolutions with codecarto_amend (write scratch/amendments/<slug>.yaml from templates/amendment.yaml).`);
157
+ actions.push(`${openQuestions} open question(s) and ${postPipeline} post-pipeline item(s) remain — apply resolutions with ${onBothSurfaces("amend")} (write scratch/amendments/<slug>.yaml from templates/amendment.yaml).`);
144
158
  }
145
159
  if ("reimplementation-spec" in status.phases) {
146
- actions.push("Publish the finished spec to a library: codecarto_publish (create one with codecarto_library_init; see the library guide topic).");
160
+ actions.push(`Publish the finished spec to a library: ${onBothSurfaces("publish")} (create one with ${onBothSurfaces("library_init")}; see the library guide topic).`);
147
161
  }
148
- actions.push("Dashboard: .codecarto/dashboard.html (refreshed on completion and amendment; codecarto_dashboard re-renders on demand). Usage totals: codecarto_usage.");
162
+ actions.push(`Dashboard: .codecarto/dashboard.html (refreshed on completion and amendment; re-render on demand with ${onBothSurfaces("dashboard")}). Usage totals: ${onBothSurfaces("usage")}.`);
149
163
  return actions;
150
164
  }
151
165
  export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
@@ -274,36 +288,44 @@ export function applyHandoff(status, handoff) {
274
288
  }
275
289
  }
276
290
  }
277
- // Now merge into the current phase: overwrite by id or append new
291
+ // Now merge into the current phase: overwrite by id or append new. An entry
292
+ // with neither id nor description has no key to merge on; it is kept as-is
293
+ // rather than lost when the array is rebuilt from the map (#134).
278
294
  const localOqMap = new Map();
295
+ const unkeyedOpenQuestions = [];
279
296
  for (const entry of phase.open_questions) {
280
297
  const key = entry.id || entry.description || "";
281
298
  if (key)
282
299
  localOqMap.set(key, entry);
300
+ else
301
+ unkeyedOpenQuestions.push(entry);
283
302
  }
284
303
  for (const entry of handoff.open_questions) {
285
304
  const key = entry.id || entry.description || "";
286
305
  if (key)
287
306
  localOqMap.set(key, entry);
288
307
  else
289
- phase.open_questions.push(entry);
308
+ unkeyedOpenQuestions.push(entry);
290
309
  }
291
- phase.open_questions = [...localOqMap.values()];
292
- // Merge carry_forward: overwrite by id or append new
310
+ phase.open_questions = [...localOqMap.values(), ...unkeyedOpenQuestions];
311
+ // Merge carry_forward: overwrite by id or append new, same unkeyed rule
293
312
  const cfMap = new Map();
313
+ const unkeyedCarryForward = [];
294
314
  for (const entry of phase.carry_forward) {
295
315
  const key = entry.id || entry.description || "";
296
316
  if (key)
297
317
  cfMap.set(key, entry);
318
+ else
319
+ unkeyedCarryForward.push(entry);
298
320
  }
299
321
  for (const entry of handoff.carry_forward) {
300
322
  const key = entry.id || entry.description || "";
301
323
  if (key)
302
324
  cfMap.set(key, entry);
303
325
  else
304
- phase.carry_forward.push(entry);
326
+ unkeyedCarryForward.push(entry);
305
327
  }
306
- phase.carry_forward = [...cfMap.values()];
328
+ phase.carry_forward = [...cfMap.values(), ...unkeyedCarryForward];
307
329
  // Apply closures: remove carry_forward entries from ALL phases by id
308
330
  for (const closureId of handoff.carry_forward_closures) {
309
331
  if (!closureId)
@@ -94,6 +94,12 @@ export type ValidationResult = {
94
94
  path: string;
95
95
  exists: boolean;
96
96
  }>;
97
+ /**
98
+ * Non-gating observations from the findings cross-checks (issue #122):
99
+ * contradictions worth the reader's attention that must not stop an
100
+ * --auto run. Rendered as NOTE lines by buildValidationSummary.
101
+ */
102
+ warnings?: string[];
97
103
  };
98
104
  /**
99
105
  * One convention a phase proposes for promotion. Completion stages these in
@@ -15,6 +15,14 @@ export declare function isWithinPathResolved(path: string, root: string): Promis
15
15
  export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
16
16
  export declare function uniqueStrings(items: string[]): string[];
17
17
  export declare function dateOnly(timestamp: string): string;
18
+ /**
19
+ * The separator to put before a line appended to file content `current` so
20
+ * the line starts at column 0. A file whose last line lacks a trailing newline
21
+ * (a hand-edited THREAD_LOG.md, an editor that strips final newlines) would
22
+ * otherwise have the appended entry glued onto that line (#134). Empty or
23
+ * absent content needs no separator.
24
+ */
25
+ export declare function newlineIfUnterminated(current: string): string;
18
26
  /**
19
27
  * Expand a leading `~` or `~/` to the user's home directory. Node's `path`
20
28
  * module deliberately doesn't do this (it's a shell convention, not a path
@@ -36,3 +44,9 @@ export declare function formatTokenCount(count: number): string;
36
44
  * dashboard renderer can reuse without crossing the core/extensions boundary.
37
45
  */
38
46
  export declare function formatMillis(ms: number): string;
47
+ /**
48
+ * Compare two dotted `major.minor.patch` versions. Returns -1, 0, or 1, or
49
+ * null when either side is not a plain three-part version (pre-release tags,
50
+ * hand-edited markers) so callers can fall back to string equality.
51
+ */
52
+ export declare function compareDottedVersions(a: string, b: string): number | null;
@@ -72,6 +72,16 @@ export function uniqueStrings(items) {
72
72
  export function dateOnly(timestamp) {
73
73
  return timestamp.slice(0, 10);
74
74
  }
75
+ /**
76
+ * The separator to put before a line appended to file content `current` so
77
+ * the line starts at column 0. A file whose last line lacks a trailing newline
78
+ * (a hand-edited THREAD_LOG.md, an editor that strips final newlines) would
79
+ * otherwise have the appended entry glued onto that line (#134). Empty or
80
+ * absent content needs no separator.
81
+ */
82
+ export function newlineIfUnterminated(current) {
83
+ return current === "" || current.endsWith("\n") ? "" : "\n";
84
+ }
75
85
  /**
76
86
  * Expand a leading `~` or `~/` to the user's home directory. Node's `path`
77
87
  * module deliberately doesn't do this (it's a shell convention, not a path
@@ -114,3 +124,23 @@ export function formatMillis(ms) {
114
124
  const seconds = Math.floor((ms % 60_000) / 1000);
115
125
  return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
116
126
  }
127
+ /**
128
+ * Compare two dotted `major.minor.patch` versions. Returns -1, 0, or 1, or
129
+ * null when either side is not a plain three-part version (pre-release tags,
130
+ * hand-edited markers) so callers can fall back to string equality.
131
+ */
132
+ export function compareDottedVersions(a, b) {
133
+ const parse = (version) => {
134
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
135
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
136
+ };
137
+ const left = parse(a);
138
+ const right = parse(b);
139
+ if (!left || !right)
140
+ return null;
141
+ for (let i = 0; i < 3; i++) {
142
+ if (left[i] !== right[i])
143
+ return left[i] < right[i] ? -1 : 1;
144
+ }
145
+ return 0;
146
+ }
@@ -38,6 +38,16 @@ export declare function copyPackagedWorkspace(targetWorkspaceDir: string, source
38
38
  * @returns the file names created, for the caller's report.
39
39
  */
40
40
  export declare function seedOrchestratorFiles(workspaceDir: string): Promise<string[]>;
41
+ /**
42
+ * What a scaffold refresh never touches, for a wrapper that asks before
43
+ * refreshing to show. The same sets drive {@link refreshScaffold}, so the
44
+ * preview and the write cannot disagree.
45
+ */
46
+ export declare const SCAFFOLD_REFRESH_PROTECTED: Readonly<{
47
+ topLevel: readonly string[];
48
+ dirs: readonly string[];
49
+ workflowFiles: readonly string[];
50
+ }>;
41
51
  /** One scaffold refresh's outcome. */
42
52
  export type RefreshScaffoldResult = {
43
53
  /** Workspace-relative paths written, sorted. */
@@ -47,6 +57,12 @@ export type RefreshScaffoldResult = {
47
57
  /** The running framework version the scaffold now matches. */
48
58
  scaffoldVersionAfter: string;
49
59
  };
60
+ /**
61
+ * The workspace-relative paths a scaffold refresh would write, sorted — the
62
+ * exact set {@link refreshScaffold} copies, computed without writing anything.
63
+ * A wrapper that asks before refreshing shows this.
64
+ */
65
+ export declare function listScaffoldRefreshFiles(): Promise<string[]>;
50
66
  /**
51
67
  * Refresh a workspace's framework-owned files from the packaged template
52
68
  * (issue #102): both staleness notices instruct exactly this, and the only
@@ -7,7 +7,7 @@ import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename, writeFile }
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)
@@ -199,6 +199,16 @@ const REFRESH_EXCLUDED_TOP_LEVEL = new Set(["BACKLOG.md", "THREAD_LOG.md", "CONV
199
199
  // generated results) — refresh must never overwrite it.
200
200
  const REFRESH_EXCLUDED_DIRS = new Set(["scratch", "inputs", "closeouts", "broadside"]);
201
201
  const REFRESH_EXCLUDED_WORKFLOW_FILES = new Set(["status.yaml", "config.yaml", ".usage.local.yaml"]);
202
+ /**
203
+ * What a scaffold refresh never touches, for a wrapper that asks before
204
+ * refreshing to show. The same sets drive {@link refreshScaffold}, so the
205
+ * preview and the write cannot disagree.
206
+ */
207
+ export const SCAFFOLD_REFRESH_PROTECTED = Object.freeze({
208
+ topLevel: Object.freeze([...REFRESH_EXCLUDED_TOP_LEVEL]),
209
+ dirs: Object.freeze([...REFRESH_EXCLUDED_DIRS]),
210
+ workflowFiles: Object.freeze([...REFRESH_EXCLUDED_WORKFLOW_FILES]),
211
+ });
202
212
  async function listTemplateFiles(dir, relativeDir = "") {
203
213
  const entries = await readdir(dir, { withFileTypes: true });
204
214
  const files = [];
@@ -218,6 +228,17 @@ async function listTemplateFiles(dir, relativeDir = "") {
218
228
  }
219
229
  return files;
220
230
  }
231
+ /**
232
+ * The workspace-relative paths a scaffold refresh would write, sorted — the
233
+ * exact set {@link refreshScaffold} copies, computed without writing anything.
234
+ * A wrapper that asks before refreshing shows this.
235
+ */
236
+ export async function listScaffoldRefreshFiles() {
237
+ if (!existsSync(packagedWorkspaceDir)) {
238
+ throw new Error("Packaged .codecarto template is missing. Reinstall codecartographer-pi.");
239
+ }
240
+ return (await listTemplateFiles(packagedWorkspaceDir)).sort();
241
+ }
221
242
  /**
222
243
  * Refresh a workspace's framework-owned files from the packaged template
223
244
  * (issue #102): both staleness notices instruct exactly this, and the only
@@ -238,14 +259,22 @@ export async function refreshScaffold(cwd) {
238
259
  throw new Error("Packaged .codecarto template is missing. Reinstall codecartographer-pi.");
239
260
  }
240
261
  const scaffoldVersionBefore = state.scaffoldVersion;
241
- const files = (await listTemplateFiles(packagedWorkspaceDir)).sort();
262
+ const files = await listScaffoldRefreshFiles();
242
263
  for (const relativePath of files) {
243
264
  const target = join(state.workspaceDir, relativePath);
244
265
  await mkdir(dirname(target), { recursive: true });
245
266
  await copyFile(join(packagedWorkspaceDir, relativePath), target);
246
267
  }
247
268
  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.`;
248
- await appendFile(join(state.workspaceDir, "THREAD_LOG.md"), `${entry}\n`, "utf8");
269
+ const threadLogPath = join(state.workspaceDir, "THREAD_LOG.md");
270
+ let currentLog = "";
271
+ try {
272
+ currentLog = await readFile(threadLogPath, "utf8");
273
+ }
274
+ catch {
275
+ // Created by the append when absent (pre-template scaffolds).
276
+ }
277
+ await appendFile(threadLogPath, `${newlineIfUnterminated(currentLog)}${entry}\n`, "utf8");
249
278
  return {
250
279
  written: files,
251
280
  ...(scaffoldVersionBefore !== undefined && { scaffoldVersionBefore }),
@@ -253,21 +282,12 @@ export async function refreshScaffold(cwd) {
253
282
  };
254
283
  }
255
284
  // Numeric x.y.z comparison; null when either side is not a plain dotted triple.
256
- function compareDottedVersions(a, b) {
257
- const parse = (version) => {
258
- const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
259
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
260
- };
261
- const left = parse(a);
262
- const right = parse(b);
263
- if (!left || !right)
264
- return null;
265
- for (let i = 0; i < 3; i++) {
266
- if (left[i] !== right[i])
267
- return left[i] < right[i] ? -1 : 1;
268
- }
269
- return 0;
270
- }
285
+ /**
286
+ * The remedy every staleness notice points at, spelled for both executable
287
+ * surfaces: this text renders inside Pi's widget and inside MCP's status
288
+ * result alike, and a Pi user handed only the MCP tool name has nothing to run.
289
+ */
290
+ const SCAFFOLD_REFRESH_REMEDY = "`codecarto_refresh_scaffold` on MCP, `/codecarto-refresh-scaffold` on Pi";
271
291
  /**
272
292
  * Human-readable staleness notice for the workspace's .codecarto/ scaffold,
273
293
  * or null when the scaffold matches the running framework. A missing marker
@@ -279,7 +299,7 @@ function compareDottedVersions(a, b) {
279
299
  export function describeScaffoldStaleness(state) {
280
300
  const scaffold = state.scaffoldVersion;
281
301
  if (!scaffold) {
282
- return "This workspace's .codecarto/ scaffold has no workflow/scaffold-version.yaml marker (introduced after v0.12.11), so its framework-owned files (GUIDE.md, templates/, workflow/ pipelines and VALIDATE.md) may predate the v0.12.0 handoff contract. Refresh them from the packaged CodeCartographer template (codecarto_refresh_scaffold does exactly this without touching project state).";
302
+ return `This workspace's .codecarto/ scaffold has no workflow/scaffold-version.yaml marker (introduced after v0.12.11), so its framework-owned files (GUIDE.md, templates/, workflow/ pipelines and VALIDATE.md) may predate the v0.12.0 handoff contract. Refresh them from the packaged CodeCartographer template (${SCAFFOLD_REFRESH_REMEDY}) — that refresh does not touch project state.`;
283
303
  }
284
304
  const comparison = compareDottedVersions(scaffold, PACKAGE_VERSION);
285
305
  if (comparison === 0)
@@ -287,10 +307,10 @@ export function describeScaffoldStaleness(state) {
287
307
  if (comparison === null) {
288
308
  return scaffold === PACKAGE_VERSION
289
309
  ? null
290
- : `This workspace's scaffold version (${scaffold}) does not match the running framework (${PACKAGE_VERSION}). Refresh the framework-owned files (GUIDE.md, templates/, workflow/) from the packaged template — codecarto_refresh_scaffold does exactly this without touching project state.`;
310
+ : `This workspace's scaffold version (${scaffold}) does not match the running framework (${PACKAGE_VERSION}). Refresh the framework-owned files (GUIDE.md, templates/, workflow/) from the packaged template (${SCAFFOLD_REFRESH_REMEDY}) — that refresh does not touch project state.`;
291
311
  }
292
312
  if (comparison < 0) {
293
- return `This workspace's scaffold (v${scaffold}) is older than the running framework (v${PACKAGE_VERSION}). Refresh the framework-owned files (GUIDE.md, templates/, workflow/) from the packaged template to pick up pipeline and template fixes — codecarto_refresh_scaffold does exactly this without touching project state.`;
313
+ return `This workspace's scaffold (v${scaffold}) is older than the running framework (v${PACKAGE_VERSION}). Refresh the framework-owned files (GUIDE.md, templates/, workflow/) from the packaged template to pick up pipeline and template fixes (${SCAFFOLD_REFRESH_REMEDY}) — that refresh does not touch project state.`;
294
314
  }
295
315
  return `This workspace's scaffold (v${scaffold}) is newer than the running framework (v${PACKAGE_VERSION}). Upgrade CodeCartographer to at least v${scaffold}.`;
296
316
  }
@@ -329,7 +349,7 @@ export async function updateStatusAtomically(cwd, updater) {
329
349
  const normalizedEntry = result.threadLogEntry.trim();
330
350
  const isDuplicate = logEntries.some((line) => line.trim() === normalizedEntry);
331
351
  if (!isDuplicate) {
332
- await appendFile(threadLogPath, result.threadLogEntry, "utf8");
352
+ await appendFile(threadLogPath, `${newlineIfUnterminated(currentLog)}${normalizedEntry}\n`, "utf8");
333
353
  }
334
354
  }
335
355
  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;
@@ -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";
@@ -4,7 +4,12 @@ export interface BroadsideFlags {
4
4
  action: BroadsideAction;
5
5
  /** Empty means "the repository's default lens set". */
6
6
  lenses: BroadsideLensId[];
7
- incremental: boolean;
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;
8
13
  includeSynthesis?: boolean;
9
14
  includeTriage?: boolean;
10
15
  retryTruncated?: boolean;
@@ -17,5 +22,5 @@ export interface BroadsideFlags {
17
22
  error?: string;
18
23
  }
19
24
  /** Every token the completer offers, in the order it offers them. */
20
- export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--max-cost=", "--wait=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
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"];
21
26
  export declare function parseBroadsideFlags(args: string): BroadsideFlags;
@@ -10,9 +10,14 @@
10
10
  // Flags mirror the codecarto_broadside tool parameters, with the negative
11
11
  // forms spelled out because a slash command has no place to pass `false`:
12
12
  // --incremental --no-synthesis
13
- // --max-cost=N --no-triage
14
- // --wait=SECONDS --no-retry-truncated
15
- // --benchmarks (models only)
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`.
16
21
  //
17
22
  // The parser never throws. index.ts decides how to surface unknown tokens and
18
23
  // invalid combinations, matching parseNextFlags.
@@ -26,6 +31,7 @@ export const KNOWN_BROADSIDE_TOKENS = [
26
31
  "models",
27
32
  ...BROADSIDE_LENS_IDS,
28
33
  "--incremental",
34
+ "--no-incremental",
29
35
  "--max-cost=",
30
36
  "--wait=",
31
37
  "--no-synthesis",
@@ -50,7 +56,6 @@ export function parseBroadsideFlags(args) {
50
56
  const result = {
51
57
  action: "submit",
52
58
  lenses: [],
53
- incremental: false,
54
59
  benchmarks: false,
55
60
  unknown: [],
56
61
  };
@@ -67,8 +72,14 @@ export function parseBroadsideFlags(args) {
67
72
  result.lenses.push(token);
68
73
  continue;
69
74
  }
70
- if (token === "--incremental") {
71
- result.incremental = true;
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;
72
83
  continue;
73
84
  }
74
85
  if (token === "--no-synthesis") {
@@ -99,12 +110,14 @@ export function parseBroadsideFlags(args) {
99
110
  }
100
111
  // Flags that only mean something for one action are refused rather than
101
112
  // ignored: silently dropping --incremental on a collect would read as
102
- // "collected incrementally", which is not a thing.
113
+ // "collected incrementally", which is not a thing (and --no-incremental
114
+ // would read as a full re-collect, which is not one either).
103
115
  if (result.lenses.length > 0 && result.action !== "submit") {
104
116
  result.error ??= `Lens names are only meaningful for submit (got action "${result.action}").`;
105
117
  }
106
- if (result.incremental && result.action !== "submit") {
107
- result.error ??= `--incremental is only meaningful for submit (got action "${result.action}").`;
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}").`;
108
121
  }
109
122
  if (result.benchmarks && result.action !== "models") {
110
123
  result.error ??= `--benchmarks is only meaningful for models (got action "${result.action}").`;
@@ -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) {