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.
- package/.codecarto/BACKLOG.md +4 -12
- package/.codecarto/GUIDE.md +35 -36
- package/.codecarto/NEW_THREAD_BLURB.md +3 -3
- package/.codecarto/skills/spec-delta-application/SKILL.md +2 -2
- package/.codecarto/templates/amendment.yaml +17 -0
- package/.codecarto/templates/conventions-template.md +6 -5
- package/.codecarto/templates/decisions-template.md +13 -10
- package/.codecarto/templates/phase-handoff.yaml +7 -0
- package/.codecarto/templates/spike-report.md +51 -0
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/agent-skill/codecartographer/SKILL.md +7 -1
- package/agent-skill/codecartographer/references/handoff-contract.md +12 -1
- package/agent-skill/codecartographer/references/library.md +32 -0
- package/agent-skill/codecartographer/references/orchestration.md +45 -0
- package/dist/core/amendment.d.ts +41 -0
- package/dist/core/amendment.js +143 -0
- package/dist/core/completion.d.ts +16 -0
- package/dist/core/completion.js +196 -5
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/pipeline.js +18 -0
- package/dist/core/prompts.js +53 -0
- package/dist/core/status.d.ts +16 -1
- package/dist/core/status.js +49 -1
- package/dist/core/types.d.ts +24 -0
- package/dist/core/usage.d.ts +7 -0
- package/dist/core/usage.js +2 -1
- package/dist/core/workspace.d.ts +39 -0
- package/dist/core/workspace.js +92 -4
- package/dist/extensions/codecarto/auto-runner.js +1 -0
- package/dist/extensions/codecarto/dashboard-writer.d.ts +8 -1
- package/dist/extensions/codecarto/dashboard-writer.js +10 -1
- package/dist/extensions/codecarto/index.js +4 -1
- package/dist/mcp-server/server.d.ts +23 -0
- package/dist/mcp-server/server.js +139 -7
- 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>;
|
package/dist/core/completion.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
:
|
|
133
|
-
|
|
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
|
}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
package/dist/core/pipeline.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/prompts.js
CHANGED
|
@@ -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})`);
|
package/dist/core/status.d.ts
CHANGED
|
@@ -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<{
|
package/dist/core/status.js
CHANGED
|
@@ -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`);
|