codecartographer-pi 0.14.1 → 0.16.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 (36) hide show
  1. package/.codecarto/BACKLOG.md +4 -12
  2. package/.codecarto/GUIDE.md +35 -36
  3. package/.codecarto/NEW_THREAD_BLURB.md +3 -3
  4. package/.codecarto/skills/spec-delta-application/SKILL.md +2 -2
  5. package/.codecarto/templates/amendment.yaml +17 -0
  6. package/.codecarto/templates/conventions-template.md +6 -5
  7. package/.codecarto/templates/decisions-template.md +13 -10
  8. package/.codecarto/templates/phase-handoff.yaml +7 -0
  9. package/.codecarto/templates/spike-report.md +51 -0
  10. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  11. package/agent-skill/codecartographer/SKILL.md +7 -1
  12. package/agent-skill/codecartographer/references/handoff-contract.md +12 -1
  13. package/agent-skill/codecartographer/references/library.md +32 -0
  14. package/agent-skill/codecartographer/references/orchestration.md +45 -0
  15. package/dist/core/amendment.d.ts +41 -0
  16. package/dist/core/amendment.js +143 -0
  17. package/dist/core/completion.d.ts +16 -0
  18. package/dist/core/completion.js +196 -5
  19. package/dist/core/index.d.ts +1 -0
  20. package/dist/core/index.js +1 -0
  21. package/dist/core/pipeline.js +18 -0
  22. package/dist/core/prompts.js +53 -0
  23. package/dist/core/status.d.ts +16 -1
  24. package/dist/core/status.js +49 -1
  25. package/dist/core/types.d.ts +24 -0
  26. package/dist/core/usage.d.ts +7 -0
  27. package/dist/core/usage.js +2 -1
  28. package/dist/core/workspace.d.ts +39 -0
  29. package/dist/core/workspace.js +92 -4
  30. package/dist/extensions/codecarto/auto-runner.js +1 -0
  31. package/dist/extensions/codecarto/dashboard-writer.d.ts +8 -1
  32. package/dist/extensions/codecarto/dashboard-writer.js +10 -1
  33. package/dist/extensions/codecarto/index.js +4 -1
  34. package/dist/mcp-server/server.d.ts +23 -0
  35. package/dist/mcp-server/server.js +139 -7
  36. package/package.json +1 -1
@@ -0,0 +1,143 @@
1
+ // Post-pipeline amendments (issue #99, formerly template BACKLOG item B2):
2
+ // apply evidence-based resolutions to workflow/status.yaml AFTER the pipeline
3
+ // is complete, under the same lock completion uses. During the pipeline the
4
+ // phase handoff is the only state channel (open_question_closures /
5
+ // carry_forward_closures); an amendment is the post-pipeline counterpart, so
6
+ // spec-delta sessions, spikes, and maintainer rulings no longer end with
7
+ // "record for a later explicit amendment" that nothing can perform.
8
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { getNextEligiblePhase } from "./pipeline.js";
11
+ import { buildTerminalNextActions, ensureArray, normalizeStatus } from "./status.js";
12
+ import { dateOnly, pathExists } from "./utils.js";
13
+ import { getWorkspaceState, updateStatusAtomically } from "./workspace.js";
14
+ import { loadYamlFile } from "./yaml.js";
15
+ /** Same charset rule as phase ids: the slug becomes file names, so path shapes are refused. */
16
+ export function assertSafeAmendmentSlug(slug) {
17
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) {
18
+ throw new Error(`Invalid amendment name: ${slug}`);
19
+ }
20
+ }
21
+ /**
22
+ * Load and validate one amendment file.
23
+ * @param name - the amendment slug, with or without a `.yaml` suffix.
24
+ */
25
+ export async function loadAmendmentFile(name, workspaceDir) {
26
+ const slug = name.trim().replace(/\.ya?ml$/i, "");
27
+ assertSafeAmendmentSlug(slug);
28
+ const amendmentPath = join(workspaceDir, "scratch", "amendments", `${slug}.yaml`);
29
+ if (!(await pathExists(amendmentPath))) {
30
+ throw new Error(`No amendment at .codecarto/scratch/amendments/${slug}.yaml. `
31
+ + `Write it first (see templates/amendment.yaml): schema_version: 1, arrays for open_question_closures, post_pipeline_closures, and notes, plus closeout_summary and optional closeout_content.`);
32
+ }
33
+ const raw = (await loadYamlFile(amendmentPath)) ?? {};
34
+ const schemaVersion = typeof raw.schema_version === "number" ? raw.schema_version : 1;
35
+ if (schemaVersion > 1) {
36
+ throw new Error(`Invalid amendment: unsupported schema_version ${schemaVersion}. Supported: 1.`);
37
+ }
38
+ for (const field of ["open_question_closures", "post_pipeline_closures", "notes"]) {
39
+ if (raw[field] !== undefined && !Array.isArray(raw[field])) {
40
+ throw new Error(`Invalid amendment: ${field} must be an array`);
41
+ }
42
+ }
43
+ const amendment = {
44
+ slug,
45
+ open_question_closures: ensureArray(raw.open_question_closures),
46
+ post_pipeline_closures: ensureArray(raw.post_pipeline_closures),
47
+ notes: ensureArray(raw.notes),
48
+ closeout_summary: typeof raw.closeout_summary === "string" ? raw.closeout_summary : "",
49
+ closeout_content: typeof raw.closeout_content === "string" ? raw.closeout_content : "",
50
+ schema_version: schemaVersion,
51
+ };
52
+ if (amendment.open_question_closures.length === 0 && amendment.post_pipeline_closures.length === 0 && amendment.notes.length === 0) {
53
+ throw new Error("Invalid amendment: nothing to apply (no closures and no notes)");
54
+ }
55
+ return amendment;
56
+ }
57
+ /** Render the generated closeout body when the amendment supplies none. */
58
+ function renderAmendmentCloseout(amendment, applied, timestamp) {
59
+ const lines = [`# Amendment — ${amendment.slug}`, "", `Applied ${dateOnly(timestamp)}.`, ""];
60
+ if (applied.openQuestionsClosed.length > 0) {
61
+ lines.push("## Open questions closed", "", ...applied.openQuestionsClosed.map((id) => `- ${id}`), "");
62
+ }
63
+ if (applied.postPipelineClosed.length > 0) {
64
+ lines.push("## Post-pipeline items closed", "", ...applied.postPipelineClosed.map((id) => `- ${id}`), "");
65
+ }
66
+ if (applied.unknownIds.length > 0) {
67
+ lines.push("## Ids that matched nothing (already closed or unknown)", "", ...applied.unknownIds.map((id) => `- ${id}`), "");
68
+ }
69
+ if (amendment.notes.length > 0) {
70
+ lines.push("## Notes", "", ...amendment.notes.map((note) => `- ${note}`), "");
71
+ }
72
+ return lines.join("\n");
73
+ }
74
+ /**
75
+ * Apply one amendment to canonical state under the completion lock. Refuses
76
+ * while the pipeline is incomplete — mid-pipeline resolutions belong in the
77
+ * phase handoff, and allowing both channels at once would race them.
78
+ * Idempotent: ids that no longer match anything are reported, not fatal.
79
+ */
80
+ export async function applyAmendment(cwd, name) {
81
+ const initialState = await getWorkspaceState(cwd);
82
+ if (!initialState)
83
+ throw new Error("CodeCartographer workspace not found. Run /codecarto-init first.");
84
+ const amendment = await loadAmendmentFile(name, initialState.workspaceDir);
85
+ const nextPhase = getNextEligiblePhase(initialState);
86
+ if (nextPhase) {
87
+ throw new Error(`Cannot amend: the pipeline is not complete (next phase: ${nextPhase.id}). `
88
+ + `Resolve open questions and routed items through that phase's handoff (open_question_closures / carry_forward_closures) instead.`);
89
+ }
90
+ const timestamp = new Date().toISOString();
91
+ const applied = { openQuestionsClosed: [], postPipelineClosed: [], unknownIds: [] };
92
+ let closeoutNotice = "";
93
+ const updatedState = await updateStatusAtomically(cwd, async (lockedState) => {
94
+ const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
95
+ for (const closureId of amendment.open_question_closures) {
96
+ if (!closureId)
97
+ continue;
98
+ let matched = false;
99
+ for (const phase of Object.values(nextStatus.phases)) {
100
+ const before = phase.open_questions.length;
101
+ phase.open_questions = phase.open_questions.filter((entry) => entry.id !== closureId);
102
+ if (phase.open_questions.length !== before)
103
+ matched = true;
104
+ }
105
+ (matched ? applied.openQuestionsClosed : applied.unknownIds).push(closureId);
106
+ }
107
+ for (const closureId of amendment.post_pipeline_closures) {
108
+ if (!closureId)
109
+ continue;
110
+ const before = nextStatus.post_pipeline.length;
111
+ nextStatus.post_pipeline = nextStatus.post_pipeline.filter((entry) => entry.id !== closureId);
112
+ (nextStatus.post_pipeline.length !== before ? applied.postPipelineClosed : applied.unknownIds).push(closureId);
113
+ }
114
+ // The amendment changed exactly the counts the terminal routing lines
115
+ // carry (issue #114); rebuild them so status never shows stale numbers.
116
+ nextStatus.next_actions = buildTerminalNextActions(nextStatus);
117
+ nextStatus.last_updated = timestamp;
118
+ // Amendment closeout + THREAD_LOG entry, same idempotence rule as
119
+ // completion: the closeout link appears in THREAD_LOG at most once.
120
+ const closeoutFile = `${dateOnly(timestamp)}-amendment-${amendment.slug}.md`;
121
+ const closeoutsDir = join(lockedState.workspaceDir, "closeouts");
122
+ await mkdir(closeoutsDir, { recursive: true });
123
+ const body = amendment.closeout_content.trim() || renderAmendmentCloseout(amendment, applied, timestamp);
124
+ await writeFile(join(closeoutsDir, closeoutFile), `${body}\n`, "utf8");
125
+ const summary = amendment.closeout_summary.trim()
126
+ || `Amendment applied: ${applied.openQuestionsClosed.length} open question(s) and ${applied.postPipelineClosed.length} post-pipeline item(s) closed.`;
127
+ const entry = `- ${dateOnly(timestamp)} — amendment:${amendment.slug} — ${summary} — [closeout](closeouts/${closeoutFile})`;
128
+ const threadLogPath = join(lockedState.workspaceDir, "THREAD_LOG.md");
129
+ let current = "";
130
+ try {
131
+ current = await readFile(threadLogPath, "utf8");
132
+ }
133
+ catch {
134
+ // Created below when absent.
135
+ }
136
+ if (!current.split(/\r?\n/).some((line) => line.includes(`[closeout](closeouts/${closeoutFile})`))) {
137
+ await appendFile(threadLogPath, `${entry}\n`, "utf8");
138
+ }
139
+ closeoutNotice = `Closeout: .codecarto/closeouts/${closeoutFile}`;
140
+ return { state: { ...lockedState, status: nextStatus } };
141
+ });
142
+ return { updatedState, closeoutNotice, applied };
143
+ }
@@ -2,5 +2,21 @@ import type { ValidationResult, WorkspaceState } from "./types.ts";
2
2
  export type CompletionResult = {
3
3
  updatedState: WorkspaceState;
4
4
  closeoutNotice?: string;
5
+ /**
6
+ * One-line phase-boundary reminder covering what completion just mechanized
7
+ * (decisions appended, proposals staged) and what still needs orchestrator
8
+ * judgment (pending proposals, open-question label re-triage). Undefined
9
+ * when there is nothing to surface.
10
+ */
11
+ orchestratorCheckpoint?: string;
5
12
  };
13
+ /** Section heading completion appends mechanized decision rows under. */
14
+ export declare const DECISIONS_COMPLETION_LOG_HEADING = "## Completion log";
15
+ /** Section heading completion stages proposed conventions under. */
16
+ export declare const CONVENTIONS_PENDING_HEADING = "## Pending proposals";
17
+ /**
18
+ * Count staged proposals in CONVENTIONS.md's pending section.
19
+ * @param content - the file content, or null to read from disk (null when the file is absent).
20
+ */
21
+ export declare function countPendingProposals(workspaceDir: string, content?: string): Promise<number>;
6
22
  export declare function completeValidatedPhase(cwd: string, validation: ValidationResult, sourceLabel: string): Promise<CompletionResult>;
@@ -1,9 +1,190 @@
1
1
  import { appendFile, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { getNextEligiblePhase, resolvePhase } from "./pipeline.js";
4
- import { applyHandoff, autoAssignIds, loadHandoffFile, normalizeStatus } from "./status.js";
4
+ import { applyHandoff, autoAssignIds, buildTerminalNextActions, loadHandoffFile, normalizeStatus } from "./status.js";
5
5
  import { dateOnly, pathExists, uniqueStrings } from "./utils.js";
6
6
  import { getWorkspaceState, updateStatusAtomically } from "./workspace.js";
7
+ /**
8
+ * The Markdown a reader sees: content inside `<!-- -->` blocks removed by a
9
+ * line scanner. This is read-only scan input for numbering and dedupe — never
10
+ * written back or rendered — so it is deliberately not an HTML sanitizer; an
11
+ * unterminated comment drops the remainder of the file from the scan.
12
+ */
13
+ function visibleMarkdown(content) {
14
+ const out = [];
15
+ let inComment = false;
16
+ for (const line of content.split(/\r?\n/)) {
17
+ let rest = line;
18
+ let visible = "";
19
+ while (rest.length > 0) {
20
+ if (inComment) {
21
+ const end = rest.indexOf("-->");
22
+ if (end === -1) {
23
+ rest = "";
24
+ break;
25
+ }
26
+ rest = rest.slice(end + 3);
27
+ inComment = false;
28
+ }
29
+ else {
30
+ const start = rest.indexOf("<!--");
31
+ if (start === -1) {
32
+ visible += rest;
33
+ rest = "";
34
+ break;
35
+ }
36
+ visible += rest.slice(0, start);
37
+ rest = rest.slice(start + 4);
38
+ inComment = true;
39
+ }
40
+ }
41
+ out.push(visible);
42
+ }
43
+ return out.join("\n");
44
+ }
45
+ /** Section heading completion appends mechanized decision rows under. */
46
+ export const DECISIONS_COMPLETION_LOG_HEADING = "## Completion log";
47
+ /** Section heading completion stages proposed conventions under. */
48
+ export const CONVENTIONS_PENDING_HEADING = "## Pending proposals";
49
+ /**
50
+ * True when `heading` exists as its own visible line. Both orchestrator-file
51
+ * templates mention their headings in running prose (issue #111: a raw
52
+ * substring check saw the decisions-template's "…rows under `## Completion
53
+ * log`…" sentence and never inserted the real heading), so presence checks
54
+ * must match a whole trimmed line of comment-stripped content.
55
+ */
56
+ function hasVisibleHeadingLine(content, heading) {
57
+ return visibleMarkdown(content)
58
+ .split(/\r?\n/)
59
+ .some((line) => line.trim() === heading);
60
+ }
61
+ /**
62
+ * Ensure an orchestrator file exists: prefer the workspace's template, fall
63
+ * back to a minimal header for scaffolds that predate the template.
64
+ * @returns the file's current content.
65
+ */
66
+ async function ensureOrchestratorFile(workspaceDir, fileName, templateName, fallbackHeader) {
67
+ const filePath = join(workspaceDir, fileName);
68
+ if (!(await pathExists(filePath))) {
69
+ const templatePath = join(workspaceDir, "templates", templateName);
70
+ if (await pathExists(templatePath)) {
71
+ await copyFile(templatePath, filePath);
72
+ }
73
+ else {
74
+ await writeFile(filePath, fallbackHeader, "utf8");
75
+ }
76
+ }
77
+ return readFile(filePath, "utf8");
78
+ }
79
+ /**
80
+ * Append handoff `decisions` to DECISIONS.md as `D<NNN> | ...` rows under
81
+ * {@link DECISIONS_COMPLETION_LOG_HEADING}. Numbering continues from the
82
+ * highest `D<NNN>` anywhere in the file, so orchestrator-curated category
83
+ * entries and the completion log share one namespace. A decision whose text
84
+ * already appears in the file is skipped, so re-running completion cannot
85
+ * duplicate rows.
86
+ * @returns how many rows were appended.
87
+ */
88
+ async function appendDecisionLog(workspaceDir, phaseId, closeoutFile, decisions) {
89
+ if (decisions.length === 0)
90
+ return 0;
91
+ let content = await ensureOrchestratorFile(workspaceDir, "DECISIONS.md", "decisions-template.md", "# Decisions\n\nAppend-only log of cross-cutting decisions. This scaffold predates templates/decisions-template.md; refresh the framework-owned files for the full format.\n");
92
+ // The template ships worked examples inside HTML comments (a commented
93
+ // `D001 | ...` row); numbering and dedupe must read only visible content or
94
+ // a fresh file starts at D002 and a decision matching example text is lost.
95
+ const visible = visibleMarkdown(content);
96
+ const fresh = decisions.filter((decision) => decision.trim() && !visible.includes(decision.trim()));
97
+ if (fresh.length === 0)
98
+ return 0;
99
+ let nextNumber = 1;
100
+ for (const match of visible.matchAll(/^D(\d+)\s*\|/gm)) {
101
+ const parsed = Number.parseInt(match[1], 10);
102
+ if (Number.isFinite(parsed) && parsed >= nextNumber)
103
+ nextNumber = parsed + 1;
104
+ }
105
+ if (!hasVisibleHeadingLine(content, DECISIONS_COMPLETION_LOG_HEADING)) {
106
+ content += `${content.endsWith("\n") ? "" : "\n"}\n${DECISIONS_COMPLETION_LOG_HEADING}\n\nAppended by completion from each phase handoff's \`decisions\` array. The orchestrator may re-file entries into the category sections above; numbering is shared with them.\n`;
107
+ }
108
+ const source = closeoutFile.replace(/\.md$/, "");
109
+ const rows = fresh.map((decision, index) => {
110
+ const number = String(nextNumber + index).padStart(3, "0");
111
+ return `D${number} | ${decision.trim()} | ${source} | closeouts/${closeoutFile} §Decisions Beyond Prompt (${phaseId})`;
112
+ });
113
+ content += `${content.endsWith("\n") ? "" : "\n"}${rows.join("\n")}\n`;
114
+ await writeFile(join(workspaceDir, "DECISIONS.md"), content, "utf8");
115
+ return fresh.length;
116
+ }
117
+ /**
118
+ * Count staged proposals in CONVENTIONS.md's pending section.
119
+ * @param content - the file content, or null to read from disk (null when the file is absent).
120
+ */
121
+ export async function countPendingProposals(workspaceDir, content) {
122
+ let text = content;
123
+ if (text === undefined) {
124
+ const filePath = join(workspaceDir, "CONVENTIONS.md");
125
+ if (!(await pathExists(filePath)))
126
+ return 0;
127
+ text = await readFile(filePath, "utf8");
128
+ }
129
+ // Same line-anchored rule as the heading-insertion checks: a prose mention
130
+ // of the heading must not open the section early and miscount.
131
+ const lines = visibleMarkdown(text).split(/\r?\n/);
132
+ const headingIndex = lines.findIndex((line) => line.trim() === CONVENTIONS_PENDING_HEADING);
133
+ if (headingIndex === -1)
134
+ return 0;
135
+ let count = 0;
136
+ for (const line of lines.slice(headingIndex + 1)) {
137
+ if (line.startsWith("## "))
138
+ break;
139
+ if (line.startsWith("- **"))
140
+ count += 1;
141
+ }
142
+ return count;
143
+ }
144
+ /**
145
+ * Stage handoff `proposed_conventions` in CONVENTIONS.md under
146
+ * {@link CONVENTIONS_PENDING_HEADING}. Staging is mechanical; promotion into
147
+ * the numbered convention sections stays an orchestrator judgment at the
148
+ * phase boundary. A proposal whose name and rule both already appear in the
149
+ * file is skipped, so re-running completion cannot duplicate entries.
150
+ * @returns staged count and the section's total pending count afterward.
151
+ */
152
+ async function stageProposedConventions(workspaceDir, phaseId, timestamp, proposals) {
153
+ if (proposals.length === 0) {
154
+ return { staged: 0, totalPending: await countPendingProposals(workspaceDir) };
155
+ }
156
+ let content = await ensureOrchestratorFile(workspaceDir, "CONVENTIONS.md", "conventions-template.md", "# Conventions\n\nCross-cutting patterns promoted to project-wide invariants. This scaffold predates templates/conventions-template.md; refresh the framework-owned files for the full format.\n");
157
+ if (!hasVisibleHeadingLine(content, CONVENTIONS_PENDING_HEADING)) {
158
+ content += `${content.endsWith("\n") ? "" : "\n"}\n${CONVENTIONS_PENDING_HEADING}\n\nStaged by completion from each phase handoff's \`proposed_conventions\`. The orchestrator promotes an entry into a numbered convention above (or removes it with a note) at the phase boundary — see GUIDE.md §Roles.\n`;
159
+ }
160
+ // Same visible-content rule as the decision log: template comments must not
161
+ // swallow a genuine proposal through the dedupe check.
162
+ const visible = visibleMarkdown(content);
163
+ const fresh = proposals.filter((proposal) => !(visible.includes(`**${proposal.name}**`) && visible.includes(proposal.rule)));
164
+ if (fresh.length > 0) {
165
+ const bullets = fresh.map((proposal) => {
166
+ const evidence = proposal.evidence ? `\n - Evidence: ${proposal.evidence}` : "";
167
+ return `- **${proposal.name}** (${phaseId}, ${dateOnly(timestamp)}) — ${proposal.rule}${evidence}`;
168
+ });
169
+ content += `${content.endsWith("\n") ? "" : "\n"}${bullets.join("\n")}\n`;
170
+ await writeFile(join(workspaceDir, "CONVENTIONS.md"), content, "utf8");
171
+ }
172
+ return { staged: fresh.length, totalPending: await countPendingProposals(workspaceDir, content) };
173
+ }
174
+ /** Build the phase-boundary checkpoint line, or undefined when nothing needs surfacing. */
175
+ function buildOrchestratorCheckpoint(decisionsAppended, totalPendingProposals, status) {
176
+ const openQuestions = Object.values(status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
177
+ const parts = [];
178
+ if (decisionsAppended > 0)
179
+ parts.push(`${decisionsAppended} decision(s) appended to DECISIONS.md`);
180
+ if (totalPendingProposals > 0)
181
+ parts.push(`${totalPendingProposals} proposal(s) pending in CONVENTIONS.md — promote or remove them before the next phase`);
182
+ if (openQuestions > 0)
183
+ parts.push(`${openQuestions} open question(s) outstanding — re-triage their kind labels before the next phase (GUIDE.md §Roles)`);
184
+ if (parts.length === 0)
185
+ return undefined;
186
+ return `Orchestrator checkpoint: ${parts.join("; ")}.`;
187
+ }
7
188
  function escapeRegExp(value) {
8
189
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9
190
  }
@@ -44,7 +225,13 @@ async function writeCompletionArtifacts(workspaceDir, phaseId, validation, times
44
225
  if (!current.split(/\r?\n/).some((line) => line.includes(link))) {
45
226
  await appendFile(threadLogPath, `${entry}\n`, "utf8");
46
227
  }
47
- return `.codecarto/closeouts/${closeoutFile}`;
228
+ // Mechanize the orchestrator loop's bookkeeping half (issue #98): decisions
229
+ // reach DECISIONS.md and proposals reach CONVENTIONS.md at completion, so a
230
+ // run without a promotion ritual cannot strand them in closeout prose.
231
+ // Promotion of pending proposals into numbered conventions stays judged.
232
+ const decisionsAppended = await appendDecisionLog(workspaceDir, phaseId, closeoutFile, handoff?.decisions ?? []);
233
+ const { totalPending: totalPendingProposals } = await stageProposedConventions(workspaceDir, phaseId, timestamp, handoff?.proposed_conventions ?? []);
234
+ return { closeoutPath: `.codecarto/closeouts/${closeoutFile}`, decisionsAppended, totalPendingProposals };
48
235
  }
49
236
  export async function completeValidatedPhase(cwd, validation, sourceLabel) {
50
237
  const initialState = await getWorkspaceState(cwd);
@@ -63,7 +250,7 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
63
250
  if (declaringPhase?.handoff_requirements?.length) {
64
251
  throw new Error(`Phase ${validation.phaseId} declares handoff_requirements, but no phase handoff exists at .codecarto/scratch/handoffs/${validation.phaseId}.yaml. `
65
252
  + `Write the handoff first (see GUIDE.md and templates/phase-handoff.yaml): schema_version: 1, the exact phase_id, `
66
- + `arrays for owner_notes, open_questions, carry_forward, carry_forward_closures, open_question_closures, post_pipeline, and decisions (omitted arrays default to empty), `
253
+ + `arrays for owner_notes, open_questions, carry_forward, carry_forward_closures, open_question_closures, post_pipeline, decisions, and proposed_conventions (omitted arrays default to empty), `
67
254
  + `plus closeout_summary and optional closeout_content. Then re-run completion.`);
68
255
  }
69
256
  }
@@ -83,6 +270,7 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
83
270
  }
84
271
  const completionTimestamp = new Date().toISOString();
85
272
  let closeoutPath;
273
+ let orchestratorCheckpoint;
86
274
  const updatedState = await updateStatusAtomically(cwd, async (lockedState) => {
87
275
  const phase = resolvePhase(lockedState, validation.phaseId);
88
276
  if (!phase?.primary_output)
@@ -129,12 +317,15 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
129
317
  nextStatus.current_phase = nextEligible?.id ?? "complete";
130
318
  nextStatus.next_actions = nextEligible
131
319
  ? [`Begin ${nextEligible.id} phase by producing ${nextEligible.primary_output ?? `findings/${nextEligible.id}/`}`]
132
- : ["All phases complete. Review findings, open questions, and downstream implementation notes."];
133
- closeoutPath = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, validation, completionTimestamp, handoff);
320
+ : buildTerminalNextActions(nextStatus);
321
+ const artifacts = await writeCompletionArtifacts(lockedState.workspaceDir, validation.phaseId, validation, completionTimestamp, handoff);
322
+ closeoutPath = artifacts.closeoutPath;
323
+ orchestratorCheckpoint = buildOrchestratorCheckpoint(artifacts.decisionsAppended, artifacts.totalPendingProposals, nextStatus);
134
324
  return { state: { ...nextWorkspace, status: nextStatus } };
135
325
  });
136
326
  return {
137
327
  updatedState,
138
328
  closeoutNotice: closeoutPath ? `Closeout: ${closeoutPath}` : undefined,
329
+ orchestratorCheckpoint,
139
330
  };
140
331
  }
@@ -2,6 +2,7 @@ export * from "./types.ts";
2
2
  export * from "./utils.ts";
3
3
  export * from "./yaml.ts";
4
4
  export * from "./status.ts";
5
+ export * from "./amendment.ts";
5
6
  export * from "./pipeline.ts";
6
7
  export * from "./prompts.ts";
7
8
  export * from "./workspace.ts";
@@ -5,6 +5,7 @@ export * from "./types.js";
5
5
  export * from "./utils.js";
6
6
  export * from "./yaml.js";
7
7
  export * from "./status.js";
8
+ export * from "./amendment.js";
8
9
  export * from "./pipeline.js";
9
10
  export * from "./prompts.js";
10
11
  export * from "./workspace.js";
@@ -72,6 +72,17 @@ export async function validatePhaseOutput(state, phaseId) {
72
72
  if (!phase.primary_output) {
73
73
  throw new Error(`Phase ${phase.id} has no primary_output in the active pipeline.`);
74
74
  }
75
+ // Declared secondary outputs with existence (issue #101): non-gating
76
+ // visibility, because secondary outputs are created only when needed — but
77
+ // a declared output that ends the phase absent AND unaccounted-for is how
78
+ // a real run silently dropped one. The summary surfaces it; the session
79
+ // either writes it or routes the gap.
80
+ const secondaryOutputs = [];
81
+ for (const output of phase.secondary_outputs ?? []) {
82
+ if (!output.path)
83
+ continue;
84
+ secondaryOutputs.push({ path: output.path, exists: await pathExists(join(state.workspaceDir, output.path)) });
85
+ }
75
86
  const outputPath = join(state.workspaceDir, phase.primary_output);
76
87
  if (!(await pathExists(outputPath))) {
77
88
  return {
@@ -84,6 +95,7 @@ export async function validatePhaseOutput(state, phaseId) {
84
95
  rows: [],
85
96
  gaps: [],
86
97
  errors: [`Missing primary output: .codecarto/${phase.primary_output}`],
98
+ secondaryOutputs,
87
99
  };
88
100
  }
89
101
  const content = await readFile(outputPath, "utf8");
@@ -99,6 +111,7 @@ export async function validatePhaseOutput(state, phaseId) {
99
111
  rows: [],
100
112
  gaps: [],
101
113
  errors: ["Primary output exists but is missing a ## Validation block."],
114
+ secondaryOutputs,
102
115
  };
103
116
  }
104
117
  const validationContent = content.slice(validationHeadingIndex);
@@ -154,6 +167,7 @@ export async function validatePhaseOutput(state, phaseId) {
154
167
  rows,
155
168
  gaps,
156
169
  errors,
170
+ secondaryOutputs,
157
171
  };
158
172
  }
159
173
  export function buildValidationSummary(validation) {
@@ -169,5 +183,9 @@ export function buildValidationSummary(validation) {
169
183
  if (validation.errors.length > 0) {
170
184
  lines.push(...validation.errors.slice(0, 3));
171
185
  }
186
+ const missingSecondary = (validation.secondaryOutputs ?? []).filter((output) => !output.exists);
187
+ if (missingSecondary.length > 0) {
188
+ 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.`);
189
+ }
172
190
  return lines;
173
191
  }
@@ -4,8 +4,60 @@
4
4
  import { readdir } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { pathExists } from "./utils.js";
7
+ import { countPendingProposals } from "./completion.js";
7
8
  import { describeScaffoldStaleness } from "./workspace.js";
8
9
  import { runPhasePreflight } from "./synthesis.js";
10
+ /** Open-question kinds whose label the orchestrator re-tests at each phase boundary. */
11
+ const RETRIAGE_KINDS = new Set(["needs-maintainer-decision", "needs-runtime-test"]);
12
+ /** Cap on individually listed re-triage questions; the rest collapse to a count. */
13
+ const RETRIAGE_LIST_LIMIT = 10;
14
+ /**
15
+ * Build the "Orchestrator duties" prompt block (issue #98): the cross-phase
16
+ * intelligence surfaced mechanically, so an inline run cannot skip it
17
+ * silently. Returns an empty array when there is nothing to surface (fresh
18
+ * workspace, no proposals, no questions, no declared secondary outputs).
19
+ */
20
+ async function buildOrchestratorDuties(state, phase, auto) {
21
+ const lines = [];
22
+ const pendingProposals = await countPendingProposals(state.workspaceDir);
23
+ if (pendingProposals > 0) {
24
+ lines.push(`- CONVENTIONS.md has ${pendingProposals} pending proposal(s) under "## Pending proposals" — promote each into a numbered convention or remove it with a note.`);
25
+ }
26
+ const retriage = [];
27
+ for (const [phaseId, phaseState] of Object.entries(state.status.phases)) {
28
+ for (const entry of phaseState.open_questions ?? []) {
29
+ if (!entry.kind || !RETRIAGE_KINDS.has(entry.kind))
30
+ continue;
31
+ const label = [entry.id, `(${entry.kind}, from ${phaseId})`, entry.description ?? ""].filter(Boolean).join(" ").trim();
32
+ retriage.push(label);
33
+ }
34
+ }
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:");
37
+ for (const label of retriage.slice(0, RETRIAGE_LIST_LIMIT))
38
+ lines.push(` - ${label}`);
39
+ if (retriage.length > RETRIAGE_LIST_LIMIT)
40
+ lines.push(` - (+${retriage.length - RETRIAGE_LIST_LIMIT} more in workflow/status.yaml)`);
41
+ }
42
+ const secondaryOutputs = phase.secondary_outputs ?? [];
43
+ if (secondaryOutputs.length > 0) {
44
+ lines.push("- This phase declares secondary outputs. Each should end the phase either written or explicitly accounted for (in Coverage and limits, or a routed handoff entry) — never dropped silently:");
45
+ for (const output of secondaryOutputs) {
46
+ const exists = await pathExists(join(state.workspaceDir, output.path));
47
+ lines.push(` - .codecarto/${output.path} (${exists ? "exists" : "missing"})`);
48
+ }
49
+ }
50
+ const anyCompleted = Object.values(state.status.phases).some((phaseState) => phaseState.status === "complete");
51
+ if (anyCompleted) {
52
+ lines.push("- Contradiction sweep: compare this phase's required reads against completed phases' owner_notes; a measured fact that contradicts a summarized claim is a gap to route through the handoff, not a nuance to smooth over.");
53
+ }
54
+ if (lines.length === 0)
55
+ return [];
56
+ const header = auto
57
+ ? "Orchestrator duties (auto run — perform them without asking the user; defer judgment calls into the handoff's owner_notes or open_questions):"
58
+ : "Orchestrator duties (perform BEFORE executing this phase; see GUIDE.md §Roles):";
59
+ return ["", header, ...lines];
60
+ }
9
61
  export function describeEntry(entry) {
10
62
  const parts = [];
11
63
  if (entry.id)
@@ -96,6 +148,7 @@ export async function buildPhasePrompt(state, phase, forced, options = {}) {
96
148
  }
97
149
  lines.push("Close each item by editing your phase output to address it, then record the closure in your phase handoff so the framework can remove the carry_forward entry atomically.");
98
150
  }
151
+ lines.push(...await buildOrchestratorDuties(state, phase, options.auto === true));
99
152
  if (preflight.libraryPath) {
100
153
  lines.push("", "Synthesis library context:");
101
154
  lines.push(`- Library: ${preflight.libraryName ?? "CodeCartographer library"} (${preflight.libraryPath})`);
@@ -1,4 +1,4 @@
1
- import type { NormalizedStatus, OpenQuestionEntry, PostPipelineEntry, PhaseHandoff, PipelineFile, StatusFile, StatusPhase } from "./types.ts";
1
+ import type { NormalizedStatus, OpenQuestionEntry, PostPipelineEntry, PhaseHandoff, PipelineFile, ProposedConventionEntry, StatusFile, StatusPhase } from "./types.ts";
2
2
  export declare const LOCK_RETRY_MS = 125;
3
3
  export declare const LOCK_TIMEOUT_MS = 5000;
4
4
  export declare const STALE_LOCK_MS = 60000;
@@ -9,8 +9,23 @@ export declare function autoAssignIds(entries: OpenQuestionEntry[], prefix: stri
9
9
  export declare function ensurePostPipelineArray(value: unknown): PostPipelineEntry[];
10
10
  export declare function ensurePhaseRecord(value: unknown): Record<string, StatusPhase>;
11
11
  export declare function createEmptyStatus(projectName: string, pipelinePath: string, pipeline: PipelineFile): NormalizedStatus;
12
+ /**
13
+ * Route the terminal boundary to the post-pipeline surfaces (issue #114). The
14
+ * moment every phase completes is exactly when skills, amendments, publishing,
15
+ * and the dashboard apply; the prior static sentence left them undiscovered —
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.
18
+ */
19
+ export declare function buildTerminalNextActions(status: NormalizedStatus): string[];
12
20
  export declare function normalizeStatus(status: StatusFile, pipeline: PipelineFile, pipelinePath: string, cwd: string): NormalizedStatus;
13
21
  export declare function parseHandoff(value: unknown): PhaseHandoff;
22
+ /**
23
+ * Parse the handoff's `proposed_conventions` collection. A present entry must
24
+ * carry non-empty `name` and `rule` strings — a proposal the framework cannot
25
+ * stage legibly fails completion loudly instead of being staged as a stub
26
+ * (same posture as post_pipeline's required id). Omitted defaults to empty.
27
+ */
28
+ export declare function ensureProposedConventionArray(value: unknown): ProposedConventionEntry[];
14
29
  export declare function loadHandoffFile(phaseId: string, workspaceDir: string): Promise<PhaseHandoff | null>;
15
30
  export declare function applyHandoff(status: NormalizedStatus, handoff: PhaseHandoff): NormalizedStatus;
16
31
  export declare function acquireLock(lockPath: string): Promise<{
@@ -126,6 +126,28 @@ export function createEmptyStatus(projectName, pipelinePath, pipeline) {
126
126
  post_pipeline: [],
127
127
  };
128
128
  }
129
+ /**
130
+ * Route the terminal boundary to the post-pipeline surfaces (issue #114). The
131
+ * moment every phase completes is exactly when skills, amendments, publishing,
132
+ * and the dashboard apply; the prior static sentence left them undiscovered —
133
+ * 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.
135
+ */
136
+ export function buildTerminalNextActions(status) {
137
+ const openQuestions = Object.values(status.phases).reduce((sum, phase) => sum + (phase.open_questions?.length ?? 0), 0);
138
+ const postPipeline = status.post_pipeline.length;
139
+ const actions = [
140
+ "All phases complete. Review findings; post-pipeline skills: codecarto_list_skills / codecarto_skill.",
141
+ ];
142
+ 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).`);
144
+ }
145
+ 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).");
147
+ }
148
+ actions.push("Dashboard: .codecarto/dashboard.html (refreshed on completion and amendment; codecarto_dashboard re-renders on demand). Usage totals: codecarto_usage.");
149
+ return actions;
150
+ }
129
151
  export function normalizeStatus(status, pipeline, pipelinePath, cwd) {
130
152
  if (typeof status.schema_version === "number" && status.schema_version > 1) {
131
153
  throw new Error(`Unsupported status schema_version ${status.schema_version}. Supported: 1.`);
@@ -166,7 +188,7 @@ export function parseHandoff(value) {
166
188
  if (schemaVersion > 1) {
167
189
  throw new Error(`Invalid handoff: unsupported schema_version ${schemaVersion}. Supported: 1.`);
168
190
  }
169
- for (const field of ["owner_notes", "open_questions", "carry_forward", "carry_forward_closures", "open_question_closures", "post_pipeline", "decisions"]) {
191
+ for (const field of ["owner_notes", "open_questions", "carry_forward", "carry_forward_closures", "open_question_closures", "post_pipeline", "decisions", "proposed_conventions"]) {
170
192
  if (raw[field] !== undefined && !Array.isArray(raw[field])) {
171
193
  throw new Error(`Invalid handoff: ${field} must be an array`);
172
194
  }
@@ -185,11 +207,37 @@ export function parseHandoff(value) {
185
207
  open_question_closures: ensureArray(raw.open_question_closures),
186
208
  post_pipeline: ensurePostPipelineArray(raw.post_pipeline),
187
209
  decisions: ensureArray(raw.decisions),
210
+ proposed_conventions: ensureProposedConventionArray(raw.proposed_conventions),
188
211
  closeout_content: typeof raw.closeout_content === "string" ? raw.closeout_content : "",
189
212
  closeout_summary: typeof raw.closeout_summary === "string" ? raw.closeout_summary : "",
190
213
  schema_version: schemaVersion,
191
214
  };
192
215
  }
216
+ /**
217
+ * Parse the handoff's `proposed_conventions` collection. A present entry must
218
+ * carry non-empty `name` and `rule` strings — a proposal the framework cannot
219
+ * stage legibly fails completion loudly instead of being staged as a stub
220
+ * (same posture as post_pipeline's required id). Omitted defaults to empty.
221
+ */
222
+ export function ensureProposedConventionArray(value) {
223
+ if (!Array.isArray(value))
224
+ return [];
225
+ const result = [];
226
+ for (const item of value) {
227
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
228
+ throw new Error("Invalid handoff: proposed_conventions entries must be objects with name and rule");
229
+ }
230
+ const raw = item;
231
+ const name = typeof raw.name === "string" ? raw.name.trim() : "";
232
+ const rule = typeof raw.rule === "string" ? raw.rule.trim() : "";
233
+ if (!name || !rule) {
234
+ throw new Error("Invalid handoff: proposed_conventions entries require non-empty name and rule");
235
+ }
236
+ const evidence = typeof raw.evidence === "string" && raw.evidence.trim() ? raw.evidence.trim() : undefined;
237
+ result.push({ name, rule, ...(evidence !== undefined && { evidence }) });
238
+ }
239
+ return result;
240
+ }
193
241
  export async function loadHandoffFile(phaseId, workspaceDir) {
194
242
  assertSafePhaseId(phaseId);
195
243
  const handoffPath = join(workspaceDir, "scratch", "handoffs", `${phaseId}.yaml`);