codecartographer-pi 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codecarto/GUIDE.md +7 -0
- package/.codecarto/broadside/SKILL.md +7 -0
- package/.codecarto/broadside/config.yaml +2 -2
- package/.codecarto/templates/phase-handoff.yaml +20 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/agent-skill/codecartographer/references/handoff-contract.md +30 -2
- package/dist/core/broadside.d.ts +99 -4
- package/dist/core/broadside.js +284 -30
- package/dist/core/completion.d.ts +15 -0
- package/dist/core/completion.js +80 -3
- package/dist/core/coverage.d.ts +45 -0
- package/dist/core/coverage.js +131 -0
- package/dist/core/dashboard.d.ts +13 -0
- package/dist/core/dashboard.js +13 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/prompts.js +19 -1
- package/dist/core/status.d.ts +8 -1
- package/dist/core/status.js +37 -2
- package/dist/core/types.d.ts +24 -1
- package/dist/core/yaml.js +27 -7
- package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
- package/dist/extensions/codecarto/auto-runner.js +11 -4
- package/dist/extensions/codecarto/index.js +70 -23
- package/dist/mcp-server/server.js +42 -8
- package/package.json +1 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { WorkspaceState } from "./types.ts";
|
|
2
|
+
/** The section heading whose bullets this module reads. */
|
|
3
|
+
export declare const COVERAGE_SECTION_HEADING = "Coverage and limits";
|
|
4
|
+
/** The ledger's five fixed bullets, verbatim values with the label stripped. */
|
|
5
|
+
export type CoverageLedger = {
|
|
6
|
+
inspected_scope: string;
|
|
7
|
+
skipped_scope: string;
|
|
8
|
+
evidence_basis: string;
|
|
9
|
+
known_blind_spots: string;
|
|
10
|
+
coverage_disposition: string;
|
|
11
|
+
};
|
|
12
|
+
/** One declared gap from one completed phase's ledger. */
|
|
13
|
+
export type CoverageGap = {
|
|
14
|
+
/** The phase that declared it. */
|
|
15
|
+
phaseId: string;
|
|
16
|
+
/** Which bullet it came from: "skipped scope" or "known blind spots". */
|
|
17
|
+
label: string;
|
|
18
|
+
/** The bullet's text, sub-bullets folded onto one line. */
|
|
19
|
+
detail: string;
|
|
20
|
+
/** `.codecarto/`-relative path of the output the ledger was read from. */
|
|
21
|
+
output: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Read a phase output's `## Coverage and limits` ledger.
|
|
25
|
+
*
|
|
26
|
+
* Returns null when the document has no such section. A bullet the author left
|
|
27
|
+
* blank comes back as an empty string, as does a label the section omits, so a
|
|
28
|
+
* caller never has to distinguish "absent" from "empty" — both mean nothing to
|
|
29
|
+
* carry forward.
|
|
30
|
+
*
|
|
31
|
+
* Sub-bullets and wrapped continuation lines under a label belong to that
|
|
32
|
+
* label: a real report writes its blind spots as a nested list, and dropping
|
|
33
|
+
* them would silence exactly the case this exists for. They fold onto one line
|
|
34
|
+
* (sub-bullets joined with "; ") because the consumer is a prompt bullet.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseCoverageAndLimits(content: string): CoverageLedger | null;
|
|
37
|
+
/**
|
|
38
|
+
* Every declared gap from every completed phase whose primary output exists.
|
|
39
|
+
*
|
|
40
|
+
* Walks `phase_order` so the list is deterministic and reads upstream-first.
|
|
41
|
+
* Only `Skipped scope` and `Known blind spots` are collected: those are the
|
|
42
|
+
* two bullets that bind a later phase's claims. Unreadable or unparsable
|
|
43
|
+
* outputs contribute nothing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function collectCoverageGaps(state: WorkspaceState): Promise<CoverageGap[]>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The coverage-gap ledger every primary output carries (#122, #186).
|
|
2
|
+
//
|
|
3
|
+
// Each phase output ends with a `## Coverage and limits` section whose bullet
|
|
4
|
+
// labels are fixed across every template — `- Inspected scope:`, `- Skipped
|
|
5
|
+
// scope:`, `- Evidence basis:`, `- Known blind spots:`, `- Coverage
|
|
6
|
+
// disposition:` — which is what makes reading it a parse and not a guess.
|
|
7
|
+
//
|
|
8
|
+
// The ledger was carried nowhere: not into the next phase's prompt, not into
|
|
9
|
+
// status.yaml, not into validation. So an upstream phase could declare "the
|
|
10
|
+
// encoded search-proxy command was not fully decoded" and the next phase could
|
|
11
|
+
// assert an `observed fact` about that exact component, with nothing in the
|
|
12
|
+
// framework comparing the two. The contradiction sweep could not have caught
|
|
13
|
+
// it either: that sweep compares against `owner_notes`, and a declared blind
|
|
14
|
+
// spot is not an owner note.
|
|
15
|
+
//
|
|
16
|
+
// Everything here is non-gating and read-only. A missing file, a missing
|
|
17
|
+
// section, or an empty bullet yields nothing; nothing throws.
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { pathExists } from "./utils.js";
|
|
21
|
+
/** The section heading whose bullets this module reads. */
|
|
22
|
+
export const COVERAGE_SECTION_HEADING = "Coverage and limits";
|
|
23
|
+
/** Bullet label (normalized) to ledger field. */
|
|
24
|
+
const LEDGER_LABELS = new Map([
|
|
25
|
+
["inspected scope", "inspected_scope"],
|
|
26
|
+
["skipped scope", "skipped_scope"],
|
|
27
|
+
["evidence basis", "evidence_basis"],
|
|
28
|
+
["known blind spots", "known_blind_spots"],
|
|
29
|
+
["coverage disposition", "coverage_disposition"],
|
|
30
|
+
]);
|
|
31
|
+
/** The two ledger bullets a downstream phase is bound by, in render order. */
|
|
32
|
+
const GAP_FIELDS = [
|
|
33
|
+
{ field: "skipped_scope", label: "skipped scope" },
|
|
34
|
+
{ field: "known_blind_spots", label: "known blind spots" },
|
|
35
|
+
];
|
|
36
|
+
function emptyLedger() {
|
|
37
|
+
return {
|
|
38
|
+
inspected_scope: "",
|
|
39
|
+
skipped_scope: "",
|
|
40
|
+
evidence_basis: "",
|
|
41
|
+
known_blind_spots: "",
|
|
42
|
+
coverage_disposition: "",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function normalizeLabel(label) {
|
|
46
|
+
return label.replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read a phase output's `## Coverage and limits` ledger.
|
|
50
|
+
*
|
|
51
|
+
* Returns null when the document has no such section. A bullet the author left
|
|
52
|
+
* blank comes back as an empty string, as does a label the section omits, so a
|
|
53
|
+
* caller never has to distinguish "absent" from "empty" — both mean nothing to
|
|
54
|
+
* carry forward.
|
|
55
|
+
*
|
|
56
|
+
* Sub-bullets and wrapped continuation lines under a label belong to that
|
|
57
|
+
* label: a real report writes its blind spots as a nested list, and dropping
|
|
58
|
+
* them would silence exactly the case this exists for. They fold onto one line
|
|
59
|
+
* (sub-bullets joined with "; ") because the consumer is a prompt bullet.
|
|
60
|
+
*/
|
|
61
|
+
export function parseCoverageAndLimits(content) {
|
|
62
|
+
const lines = content.split(/\r?\n/);
|
|
63
|
+
const start = lines.findIndex((line) => /^##\s+Coverage and limits\s*$/i.test(line.replace(/[`*_]/g, "")));
|
|
64
|
+
if (start < 0)
|
|
65
|
+
return null;
|
|
66
|
+
const ledger = emptyLedger();
|
|
67
|
+
let current = null;
|
|
68
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
69
|
+
const line = lines[i];
|
|
70
|
+
if (/^##\s/.test(line))
|
|
71
|
+
break;
|
|
72
|
+
const bullet = /^[-*]\s+(.*)$/.exec(line);
|
|
73
|
+
if (bullet) {
|
|
74
|
+
const separator = bullet[1].indexOf(":");
|
|
75
|
+
const field = separator >= 0 ? LEDGER_LABELS.get(normalizeLabel(bullet[1].slice(0, separator))) : undefined;
|
|
76
|
+
// An unrecognized top-level bullet ends the previous label's block
|
|
77
|
+
// rather than absorbing text that belongs to neither.
|
|
78
|
+
current = field ?? null;
|
|
79
|
+
if (field)
|
|
80
|
+
ledger[field] = bullet[1].slice(separator + 1).trim();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!line.trim())
|
|
84
|
+
continue; // a blank line does not end a label's block
|
|
85
|
+
if (!current || !/^\s/.test(line)) {
|
|
86
|
+
current = null; // unindented prose is not part of any bullet
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const continuation = line.trim();
|
|
90
|
+
const nested = /^[-*]\s+(.*)$/.exec(continuation);
|
|
91
|
+
const text = (nested ? nested[1] : continuation).trim();
|
|
92
|
+
if (!text)
|
|
93
|
+
continue;
|
|
94
|
+
ledger[current] = ledger[current] ? `${ledger[current]}${nested ? "; " : " "}${text}` : text;
|
|
95
|
+
}
|
|
96
|
+
return ledger;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Every declared gap from every completed phase whose primary output exists.
|
|
100
|
+
*
|
|
101
|
+
* Walks `phase_order` so the list is deterministic and reads upstream-first.
|
|
102
|
+
* Only `Skipped scope` and `Known blind spots` are collected: those are the
|
|
103
|
+
* two bullets that bind a later phase's claims. Unreadable or unparsable
|
|
104
|
+
* outputs contribute nothing.
|
|
105
|
+
*/
|
|
106
|
+
export async function collectCoverageGaps(state) {
|
|
107
|
+
const configs = new Map(state.pipeline.phases.map((phase) => [phase.id, phase]));
|
|
108
|
+
const gaps = [];
|
|
109
|
+
for (const phaseId of state.pipeline.phase_order ?? []) {
|
|
110
|
+
if (state.status.phases[phaseId]?.status !== "complete")
|
|
111
|
+
continue;
|
|
112
|
+
const output = configs.get(phaseId)?.primary_output;
|
|
113
|
+
if (!output)
|
|
114
|
+
continue;
|
|
115
|
+
const outputPath = join(state.workspaceDir, output);
|
|
116
|
+
if (!(await pathExists(outputPath)))
|
|
117
|
+
continue;
|
|
118
|
+
const content = await readFile(outputPath, "utf8").catch(() => null);
|
|
119
|
+
if (content === null)
|
|
120
|
+
continue;
|
|
121
|
+
const ledger = parseCoverageAndLimits(content);
|
|
122
|
+
if (!ledger)
|
|
123
|
+
continue;
|
|
124
|
+
for (const { field, label } of GAP_FIELDS) {
|
|
125
|
+
const detail = ledger[field];
|
|
126
|
+
if (detail)
|
|
127
|
+
gaps.push({ phaseId, label, detail, output });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return gaps;
|
|
131
|
+
}
|
package/dist/core/dashboard.d.ts
CHANGED
|
@@ -39,4 +39,17 @@ export interface DashboardInputs {
|
|
|
39
39
|
narration?: DashboardNarration;
|
|
40
40
|
}
|
|
41
41
|
export declare function renderDashboard(inputs: DashboardInputs): string;
|
|
42
|
+
/**
|
|
43
|
+
* A dashboard link target, or undefined when the path is not a safe relative
|
|
44
|
+
* reference. Absolute paths, scheme-bearing URLs and `.`/`..` segments are
|
|
45
|
+
* refused outright.
|
|
46
|
+
*
|
|
47
|
+
* Percent-encoding each segment is load-bearing, not cosmetic: a Windows-style
|
|
48
|
+
* `dir\..\secret` survives the segment scan, because that scan splits on `/`
|
|
49
|
+
* — but `encodeURIComponent` turns the separators into `%5C`, which no URL
|
|
50
|
+
* parser treats as a path separator, so the traversal cannot resolve. Removing
|
|
51
|
+
* the encoding, or "simplifying" it to a raw join, reopens it. Exported so
|
|
52
|
+
* that property has a test.
|
|
53
|
+
*/
|
|
54
|
+
export declare function safeRelativeHref(path: string): string | undefined;
|
|
42
55
|
export declare function escapeHtml(input: string): string;
|
package/dist/core/dashboard.js
CHANGED
|
@@ -660,7 +660,19 @@ function renderSafeLink(path, label, className) {
|
|
|
660
660
|
const classAttr = className ? ` class="${escapeAttr(className)}"` : "";
|
|
661
661
|
return `<a${classAttr} href="${escapeAttr(href)}">${escapeHtml(label)}</a>`;
|
|
662
662
|
}
|
|
663
|
-
|
|
663
|
+
/**
|
|
664
|
+
* A dashboard link target, or undefined when the path is not a safe relative
|
|
665
|
+
* reference. Absolute paths, scheme-bearing URLs and `.`/`..` segments are
|
|
666
|
+
* refused outright.
|
|
667
|
+
*
|
|
668
|
+
* Percent-encoding each segment is load-bearing, not cosmetic: a Windows-style
|
|
669
|
+
* `dir\..\secret` survives the segment scan, because that scan splits on `/`
|
|
670
|
+
* — but `encodeURIComponent` turns the separators into `%5C`, which no URL
|
|
671
|
+
* parser treats as a path separator, so the traversal cannot resolve. Removing
|
|
672
|
+
* the encoding, or "simplifying" it to a raw join, reopens it. Exported so
|
|
673
|
+
* that property has a test.
|
|
674
|
+
*/
|
|
675
|
+
export function safeRelativeHref(path) {
|
|
664
676
|
if (!path || path.startsWith("/") || path.startsWith("\\") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path))
|
|
665
677
|
return undefined;
|
|
666
678
|
const parts = path.split("/");
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./status.js";
|
|
|
8
8
|
export * from "./amendment.js";
|
|
9
9
|
export * from "./pipeline.js";
|
|
10
10
|
export * from "./findings.js";
|
|
11
|
+
export * from "./coverage.js";
|
|
11
12
|
export * from "./prompts.js";
|
|
12
13
|
export * from "./workspace.js";
|
|
13
14
|
export * from "./completion.js";
|
package/dist/core/prompts.js
CHANGED
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
import { readdir } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { pathExists } from "./utils.js";
|
|
7
|
+
import { collectCoverageGaps } from "./coverage.js";
|
|
7
8
|
import { countPendingProposals } from "./completion.js";
|
|
8
9
|
import { describeScaffoldStaleness } from "./workspace.js";
|
|
9
10
|
import { runPhasePreflight } from "./synthesis.js";
|
|
10
11
|
/** Open-question kinds whose label the orchestrator re-tests at each phase boundary. */
|
|
11
12
|
const RETRIAGE_KINDS = new Set(["needs-maintainer-decision", "needs-runtime-test"]);
|
|
12
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Cap on the individually listed entries of a mechanically surfaced duty list
|
|
15
|
+
* — re-triage questions and upstream coverage gaps alike; the rest collapse to
|
|
16
|
+
* a count naming where the full list lives.
|
|
17
|
+
*/
|
|
13
18
|
const RETRIAGE_LIST_LIMIT = 10;
|
|
14
19
|
/**
|
|
15
20
|
* Build the "Orchestrator duties" prompt block (issue #98): the cross-phase
|
|
@@ -47,6 +52,19 @@ async function buildOrchestratorDuties(state, phase, auto) {
|
|
|
47
52
|
lines.push(` - .codecarto/${output.path} (${exists ? "exists" : "missing"})`);
|
|
48
53
|
}
|
|
49
54
|
}
|
|
55
|
+
// The coverage-gap ledger (#122, #186). The contradiction sweep below
|
|
56
|
+
// compares against `owner_notes` only, and a declared blind spot is not an
|
|
57
|
+
// owner note — so a phase could assert an observed fact about a component
|
|
58
|
+
// the upstream phase had recorded as not decoded, and nothing compared the
|
|
59
|
+
// two. Non-gating: this is a duty in the prompt, not a validation rule.
|
|
60
|
+
const coverageGaps = await collectCoverageGaps(state);
|
|
61
|
+
if (coverageGaps.length > 0) {
|
|
62
|
+
lines.push("- Upstream phases declared these coverage gaps in their `## Coverage and limits` sections. A finding of yours that lands inside one must either close the gap with cited new evidence of its own or inherit its uncertainty — an upstream `not inspected` or `not decoded` does not license an `observed fact` about that scope:");
|
|
63
|
+
for (const gap of coverageGaps.slice(0, RETRIAGE_LIST_LIMIT))
|
|
64
|
+
lines.push(` - ${gap.phaseId} (${gap.label}): ${gap.detail}`);
|
|
65
|
+
if (coverageGaps.length > RETRIAGE_LIST_LIMIT)
|
|
66
|
+
lines.push(` - (+${coverageGaps.length - RETRIAGE_LIST_LIMIT} more in completed phases' Coverage and limits sections)`);
|
|
67
|
+
}
|
|
50
68
|
const anyCompleted = Object.values(state.status.phases).some((phaseState) => phaseState.status === "complete");
|
|
51
69
|
if (anyCompleted) {
|
|
52
70
|
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.");
|
package/dist/core/status.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import type { NormalizedStatus, OpenQuestionEntry, PostPipelineEntry, PhaseHandoff, PipelineFile, ProposedConventionEntry, StatusFile, StatusPhase } from "./types.ts";
|
|
1
|
+
import type { ClosureEntry, 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;
|
|
5
5
|
export declare function assertSafePhaseId(phaseId: string): void;
|
|
6
6
|
export declare function ensureArray(value: unknown): string[];
|
|
7
|
+
/**
|
|
8
|
+
* Normalize a handoff's `open_question_closures` (#122, #186). Accepts both
|
|
9
|
+
* the original bare-string shape and `{ id, evidence }`; a string becomes
|
|
10
|
+
* `{ id }`, and an entry with no usable id is dropped rather than resolving
|
|
11
|
+
* nothing under the lock. Values are trimmed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureClosureArray(value: unknown): ClosureEntry[];
|
|
7
14
|
export declare function ensureEntryArray<T extends OpenQuestionEntry>(value: unknown, allowTargetPhase?: boolean): T[];
|
|
8
15
|
export declare function autoAssignIds(entries: OpenQuestionEntry[], prefix: string, phaseId: string): void;
|
|
9
16
|
export declare function ensurePostPipelineArray(value: unknown): PostPipelineEntry[];
|
package/dist/core/status.js
CHANGED
|
@@ -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 [];
|
|
@@ -218,7 +252,7 @@ export function parseHandoff(value) {
|
|
|
218
252
|
open_questions: openQuestions,
|
|
219
253
|
carry_forward: carryForward,
|
|
220
254
|
carry_forward_closures: ensureArray(raw.carry_forward_closures),
|
|
221
|
-
open_question_closures:
|
|
255
|
+
open_question_closures: ensureClosureArray(raw.open_question_closures),
|
|
222
256
|
post_pipeline: ensurePostPipelineArray(raw.post_pipeline),
|
|
223
257
|
decisions: ensureArray(raw.decisions),
|
|
224
258
|
proposed_conventions: ensureProposedConventionArray(raw.proposed_conventions),
|
|
@@ -335,7 +369,8 @@ export function applyHandoff(status, handoff) {
|
|
|
335
369
|
}
|
|
336
370
|
}
|
|
337
371
|
// Apply open_question_closures: remove resolved questions from ALL phases by id
|
|
338
|
-
for (const
|
|
372
|
+
for (const closure of handoff.open_question_closures) {
|
|
373
|
+
const closureId = closure?.id;
|
|
339
374
|
if (!closureId)
|
|
340
375
|
continue;
|
|
341
376
|
for (const ph of Object.values(status.phases)) {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/core/yaml.js
CHANGED
|
@@ -126,6 +126,19 @@ export function parseSimpleYaml(raw) {
|
|
|
126
126
|
};
|
|
127
127
|
const parseMapping = (indent) => {
|
|
128
128
|
const result = {};
|
|
129
|
+
// Duplicate detection tracks keys explicitly rather than testing
|
|
130
|
+
// `key in result`. A bare object inherits from Object.prototype, so
|
|
131
|
+
// `"constructor" in result` is already true before anything is parsed —
|
|
132
|
+
// a document whose key is `constructor`, `toString`, `valueOf` or any
|
|
133
|
+
// other prototype member was rejected as a duplicate on first sight.
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
// Assignment goes through defineProperty for the same reason: plain
|
|
136
|
+
// `result[key] = value` with the key `__proto__` invokes the prototype
|
|
137
|
+
// setter instead of creating an entry, so a hand-edited YAML file could
|
|
138
|
+
// change the shape of every object in the process rather than parse.
|
|
139
|
+
const assign = (key, value) => {
|
|
140
|
+
Object.defineProperty(result, key, { value, writable: true, enumerable: true, configurable: true });
|
|
141
|
+
};
|
|
129
142
|
while (index < lines.length) {
|
|
130
143
|
skipBlank();
|
|
131
144
|
if (index >= lines.length)
|
|
@@ -147,9 +160,10 @@ export function parseSimpleYaml(raw) {
|
|
|
147
160
|
const key = trimmed.slice(0, separator).trim();
|
|
148
161
|
const rawValue = trimmed.slice(separator + 1).trim();
|
|
149
162
|
index++;
|
|
150
|
-
if (key
|
|
163
|
+
if (seen.has(key)) {
|
|
151
164
|
throw new Error(`Duplicate YAML key: ${key} near line: ${line.trim()}`);
|
|
152
165
|
}
|
|
166
|
+
seen.add(key);
|
|
153
167
|
if (rawValue === "|" || rawValue === "|-") {
|
|
154
168
|
const blockLines = [];
|
|
155
169
|
let contentIndent = null;
|
|
@@ -168,19 +182,19 @@ export function parseSimpleYaml(raw) {
|
|
|
168
182
|
index++;
|
|
169
183
|
}
|
|
170
184
|
const content = blockLines.join("\n").replace(/\n+$/, "");
|
|
171
|
-
|
|
185
|
+
assign(key, rawValue === "|" ? `${content}\n` : content);
|
|
172
186
|
continue;
|
|
173
187
|
}
|
|
174
188
|
if (rawValue !== "") {
|
|
175
|
-
|
|
189
|
+
assign(key, parseYamlScalar(rawValue));
|
|
176
190
|
continue;
|
|
177
191
|
}
|
|
178
192
|
skipBlank();
|
|
179
193
|
if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
|
|
180
|
-
|
|
194
|
+
assign(key, parseBlock(countIndent(lines[index] ?? "")));
|
|
181
195
|
}
|
|
182
196
|
else {
|
|
183
|
-
|
|
197
|
+
assign(key, null);
|
|
184
198
|
}
|
|
185
199
|
}
|
|
186
200
|
return result;
|
|
@@ -198,7 +212,13 @@ export function parseSimpleYaml(raw) {
|
|
|
198
212
|
const trimmed = line.slice(lineIndent);
|
|
199
213
|
if (lineIndent !== indent || (!trimmed.startsWith("- ") && trimmed !== "-"))
|
|
200
214
|
break;
|
|
201
|
-
const
|
|
215
|
+
const afterDash = trimmed === "-" ? "" : trimmed.slice(2);
|
|
216
|
+
const rawItem = afterDash.trim();
|
|
217
|
+
// A sequence item's mapping continues at the column where its own
|
|
218
|
+
// content starts, which is not always two past the dash: `- id: x`
|
|
219
|
+
// aligns its siblings under the `i`, four columns in. Hardcoding two
|
|
220
|
+
// rejected that valid layout as bad indentation.
|
|
221
|
+
const itemIndent = indent + 2 + (afterDash.length - afterDash.trimStart().length);
|
|
202
222
|
index++;
|
|
203
223
|
if (rawItem === "") {
|
|
204
224
|
skipBlank();
|
|
@@ -221,7 +241,7 @@ export function parseSimpleYaml(raw) {
|
|
|
221
241
|
item[key] = parseBlock(countIndent(lines[index] ?? ""));
|
|
222
242
|
}
|
|
223
243
|
if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
|
|
224
|
-
const nested = parseMapping(
|
|
244
|
+
const nested = parseMapping(itemIndent);
|
|
225
245
|
for (const [nestedKey, nestedValue] of Object.entries(nested))
|
|
226
246
|
item[nestedKey] = nestedValue;
|
|
227
247
|
}
|
|
@@ -44,7 +44,7 @@ export interface AutoCompleteResult {
|
|
|
44
44
|
/** Non-gating closure-integrity notes from completion (#122). */
|
|
45
45
|
warnings: string[];
|
|
46
46
|
}
|
|
47
|
-
export declare function autoCompletePhase(
|
|
47
|
+
export declare function autoCompletePhase(cwd: string, validation: ValidationResult): Promise<AutoCompleteResult>;
|
|
48
48
|
export type AutoOutcome = "complete" | "stopped" | "aborted";
|
|
49
49
|
export interface AutoRunOptions {
|
|
50
50
|
strict: boolean;
|
|
@@ -151,9 +151,13 @@ export function isPhaseRunning(phaseId) {
|
|
|
151
151
|
const existing = getPhaseActivity(phaseId);
|
|
152
152
|
return existing?.status === "running";
|
|
153
153
|
}
|
|
154
|
-
export async function autoCompletePhase(
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
export async function autoCompletePhase(
|
|
155
|
+
// A directory, not a ctx: this only ever needed `ctx.cwd`, and its callers
|
|
156
|
+
// run after a sub-agent has invalidated the ctx they captured, where every
|
|
157
|
+
// property access throws.
|
|
158
|
+
cwd, validation) {
|
|
159
|
+
const result = await completeValidatedPhase(cwd, validation, "/codecarto-complete");
|
|
160
|
+
void writeDashboard(cwd, PACKAGE_VERSION);
|
|
157
161
|
return result;
|
|
158
162
|
}
|
|
159
163
|
export function decideAfterPhase(phaseStatus, phaseError, validation, strict) {
|
|
@@ -185,6 +189,9 @@ export function decideAfterPhase(phaseStatus, phaseError, validation, strict) {
|
|
|
185
189
|
}
|
|
186
190
|
export async function runAuto(ctx, pi, initialState, options) {
|
|
187
191
|
const startedAt = Date.now();
|
|
192
|
+
// Captured once: the loop below spawns a sub-agent per phase, and each one
|
|
193
|
+
// invalidates this ctx, after which reading `ctx.cwd` throws.
|
|
194
|
+
const autoCwd = ctx.cwd;
|
|
188
195
|
const phasesRun = [];
|
|
189
196
|
const totalTokens = { input: 0, output: 0, cacheWrite: 0 };
|
|
190
197
|
const totalPhases = initialState.pipeline.phase_order.length;
|
|
@@ -262,7 +269,7 @@ export async function runAuto(ctx, pi, initialState, options) {
|
|
|
262
269
|
// decision.action === "continue" → auto-complete and loop.
|
|
263
270
|
// validation is guaranteed non-null on the continue branch.
|
|
264
271
|
try {
|
|
265
|
-
const { updatedState } = await autoCompletePhase(
|
|
272
|
+
const { updatedState } = await autoCompletePhase(autoCwd, validation);
|
|
266
273
|
state = updatedState;
|
|
267
274
|
phasesRun.push(phase.id);
|
|
268
275
|
options.onPhaseAdvanced?.(state);
|