codecartographer-pi 0.17.1 → 0.19.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.
@@ -36,8 +36,42 @@ function coerceEntry(value, allowTargetPhase) {
36
36
  entry.deferred_reason = raw.deferred_reason.trim();
37
37
  if (allowTargetPhase && typeof raw.target_phase === "string" && raw.target_phase.trim())
38
38
  entry.target_phase = raw.target_phase.trim();
39
+ // derives_from rides the same flag as target_phase: it is a carry-forward
40
+ // concept only — the id of the open question this routed item answers one
41
+ // candidate of (#122, #186). An open_questions entry has nothing to derive
42
+ // from, so the field is dropped there rather than silently carried.
43
+ if (allowTargetPhase && typeof raw.derives_from === "string" && raw.derives_from.trim())
44
+ entry.derives_from = raw.derives_from.trim();
39
45
  return Object.keys(entry).length > 0 ? entry : null;
40
46
  }
47
+ /**
48
+ * Normalize a handoff's `open_question_closures` (#122, #186). Accepts both
49
+ * the original bare-string shape and `{ id, evidence }`; a string becomes
50
+ * `{ id }`, and an entry with no usable id is dropped rather than resolving
51
+ * nothing under the lock. Values are trimmed.
52
+ */
53
+ export function ensureClosureArray(value) {
54
+ if (!Array.isArray(value))
55
+ return [];
56
+ const result = [];
57
+ for (const item of value) {
58
+ if (typeof item === "string") {
59
+ const id = item.trim();
60
+ if (id)
61
+ result.push({ id });
62
+ continue;
63
+ }
64
+ if (!item || typeof item !== "object" || Array.isArray(item))
65
+ continue;
66
+ const raw = item;
67
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
68
+ if (!id)
69
+ continue;
70
+ const evidence = typeof raw.evidence === "string" && raw.evidence.trim() ? raw.evidence.trim() : undefined;
71
+ result.push({ id, ...(evidence !== undefined && { evidence }) });
72
+ }
73
+ return result;
74
+ }
41
75
  export function ensureEntryArray(value, allowTargetPhase = false) {
42
76
  if (!Array.isArray(value))
43
77
  return [];
@@ -126,26 +160,40 @@ export function createEmptyStatus(projectName, pipelinePath, pipeline) {
126
160
  post_pipeline: [],
127
161
  };
128
162
  }
163
+ /**
164
+ * Spell a tool for both executable surfaces — the shape the scaffold
165
+ * staleness notice adopted (#177). next_actions is canonical state rendered
166
+ * by codecarto_status on MCP and as the Pi widget's "Next:" line alike, and a
167
+ * Pi user handed only the MCP tool name has nothing to run. Several tools
168
+ * join with "then" so a sequence reads as one per surface. No backticks:
169
+ * these lines render in a plain TUI line and in the HTML dashboard.
170
+ */
171
+ function onBothSurfaces(...tools) {
172
+ const mcp = tools.map((tool) => `codecarto_${tool}`).join(" then ");
173
+ const pi = tools.map((tool) => `/codecarto-${tool.replace(/_/g, "-")}`).join(" then ");
174
+ return `${mcp} on MCP, ${pi} on Pi`;
175
+ }
129
176
  /**
130
177
  * Route the terminal boundary to the post-pipeline surfaces (issue #114). The
131
178
  * moment every phase completes is exactly when skills, amendments, publishing,
132
179
  * and the dashboard apply; the prior static sentence left them undiscovered —
133
180
  * 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.
181
+ * Amendment recomputes this list so closure counts never go stale. Every tool
182
+ * named here is spelled for both surfaces (see onBothSurfaces).
135
183
  */
136
184
  export function buildTerminalNextActions(status) {
137
185
  const openQuestions = Object.values(status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
138
186
  const postPipeline = status.post_pipeline.length;
139
187
  const actions = [
140
- "All phases complete. Review findings; post-pipeline skills: codecarto_list_skills / codecarto_skill.",
188
+ `All phases complete. Review findings; post-pipeline skills: ${onBothSurfaces("list_skills", "skill")}.`,
141
189
  ];
142
190
  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).`);
191
+ 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
192
  }
145
193
  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).");
194
+ actions.push(`Publish the finished spec to a library: ${onBothSurfaces("publish")} (create one with ${onBothSurfaces("library_init")}; see the library guide topic).`);
147
195
  }
148
- actions.push("Dashboard: .codecarto/dashboard.html (refreshed on completion and amendment; codecarto_dashboard re-renders on demand). Usage totals: codecarto_usage.");
196
+ actions.push(`Dashboard: .codecarto/dashboard.html (refreshed on completion and amendment; re-render on demand with ${onBothSurfaces("dashboard")}). Usage totals: ${onBothSurfaces("usage")}.`);
149
197
  return actions;
150
198
  }
151
199
  export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
@@ -204,7 +252,7 @@ export function parseHandoff(value) {
204
252
  open_questions: openQuestions,
205
253
  carry_forward: carryForward,
206
254
  carry_forward_closures: ensureArray(raw.carry_forward_closures),
207
- open_question_closures: ensureArray(raw.open_question_closures),
255
+ open_question_closures: ensureClosureArray(raw.open_question_closures),
208
256
  post_pipeline: ensurePostPipelineArray(raw.post_pipeline),
209
257
  decisions: ensureArray(raw.decisions),
210
258
  proposed_conventions: ensureProposedConventionArray(raw.proposed_conventions),
@@ -321,7 +369,8 @@ export function applyHandoff(status, handoff) {
321
369
  }
322
370
  }
323
371
  // Apply open_question_closures: remove resolved questions from ALL phases by id
324
- for (const closureId of handoff.open_question_closures) {
372
+ for (const closure of handoff.open_question_closures) {
373
+ const closureId = closure?.id;
325
374
  if (!closureId)
326
375
  continue;
327
376
  for (const ph of Object.values(status.phases)) {
@@ -9,6 +9,16 @@ export type OpenQuestionEntry = {
9
9
  };
10
10
  export type CarryForwardEntry = OpenQuestionEntry & {
11
11
  target_phase?: string;
12
+ /**
13
+ * Optional id of the `open_questions` entry this routed item is one
14
+ * candidate answer to (#122, #186). The upstream phase usually registers
15
+ * the question and routes the candidate onward in the same handoff, which
16
+ * is the cheapest moment to record the link. Completion refuses a closure
17
+ * of this entry while that question is still open and is not closed by the
18
+ * same handoff: closing a routed item does not settle the question it came
19
+ * from. Omitted on every entry that predates the field.
20
+ */
21
+ derives_from?: string;
12
22
  };
13
23
  export type PostPipelineEntry = OpenQuestionEntry & {
14
24
  source_phase?: string;
@@ -114,6 +124,18 @@ export type ProposedConventionEntry = {
114
124
  /** Optional: where the pattern showed up (file, phase, incident). */
115
125
  evidence?: string;
116
126
  };
127
+ /**
128
+ * One claimed closure in a handoff's `open_question_closures` (#122, #186).
129
+ * A bare string — the only shape before this field grew — normalizes to
130
+ * `{ id }`; the object form adds the evidence that settles the question.
131
+ * Completion requires non-empty `evidence` when the closed question's `kind`
132
+ * is `needs-runtime-test`, because such a question closes on runtime evidence
133
+ * rather than on another source read.
134
+ */
135
+ export type ClosureEntry = {
136
+ id: string;
137
+ evidence?: string;
138
+ };
117
139
  export type PhaseHandoff = {
118
140
  phase_id: string;
119
141
  /**
@@ -126,7 +148,8 @@ export type PhaseHandoff = {
126
148
  open_questions: OpenQuestionEntry[];
127
149
  carry_forward: CarryForwardEntry[];
128
150
  carry_forward_closures: string[];
129
- open_question_closures: string[];
151
+ /** ids to resolve and remove from all phases; a bare string parses to `{ id }`. */
152
+ open_question_closures: ClosureEntry[];
130
153
  post_pipeline: PostPipelineEntry[];
131
154
  decisions: string[];
132
155
  /** Conventions proposed for promotion; completion stages them in CONVENTIONS.md. Omitted defaults to empty. */
@@ -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
@@ -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,7 +259,7 @@ 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 });
@@ -261,6 +282,12 @@ export async function refreshScaffold(cwd) {
261
282
  };
262
283
  }
263
284
  // Numeric x.y.z comparison; null when either side is not a plain dotted triple.
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";
264
291
  /**
265
292
  * Human-readable staleness notice for the workspace's .codecarto/ scaffold,
266
293
  * or null when the scaffold matches the running framework. A missing marker
@@ -272,7 +299,7 @@ export async function refreshScaffold(cwd) {
272
299
  export function describeScaffoldStaleness(state) {
273
300
  const scaffold = state.scaffoldVersion;
274
301
  if (!scaffold) {
275
- 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.`;
276
303
  }
277
304
  const comparison = compareDottedVersions(scaffold, PACKAGE_VERSION);
278
305
  if (comparison === 0)
@@ -280,10 +307,10 @@ export function describeScaffoldStaleness(state) {
280
307
  if (comparison === null) {
281
308
  return scaffold === PACKAGE_VERSION
282
309
  ? null
283
- : `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.`;
284
311
  }
285
312
  if (comparison < 0) {
286
- 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.`;
287
314
  }
288
315
  return `This workspace's scaffold (v${scaffold}) is newer than the running framework (v${PACKAGE_VERSION}). Upgrade CodeCartographer to at least v${scaffold}.`;
289
316
  }