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.
@@ -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
+ }
@@ -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;
@@ -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
- function safeRelativeHref(path) {
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("/");
@@ -5,6 +5,7 @@ export * from "./status.ts";
5
5
  export * from "./amendment.ts";
6
6
  export * from "./pipeline.ts";
7
7
  export * from "./findings.ts";
8
+ export * from "./coverage.ts";
8
9
  export * from "./prompts.ts";
9
10
  export * from "./workspace.ts";
10
11
  export * from "./completion.ts";
@@ -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";
@@ -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
- /** Cap on individually listed re-triage questions; the rest collapse to a count. */
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.");
@@ -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[];
@@ -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: ensureArray(raw.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 closureId of handoff.open_question_closures) {
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)) {
@@ -9,6 +9,16 @@ export type OpenQuestionEntry = {
9
9
  };
10
10
  export type CarryForwardEntry = OpenQuestionEntry & {
11
11
  target_phase?: string;
12
+ /**
13
+ * Optional id of the `open_questions` entry this routed item is one
14
+ * candidate answer to (#122, #186). The upstream phase usually registers
15
+ * the question and routes the candidate onward in the same handoff, which
16
+ * is the cheapest moment to record the link. Completion refuses a closure
17
+ * of this entry while that question is still open and is not closed by the
18
+ * same handoff: closing a routed item does not settle the question it came
19
+ * from. Omitted on every entry that predates the field.
20
+ */
21
+ derives_from?: string;
12
22
  };
13
23
  export type PostPipelineEntry = OpenQuestionEntry & {
14
24
  source_phase?: string;
@@ -114,6 +124,18 @@ export type ProposedConventionEntry = {
114
124
  /** Optional: where the pattern showed up (file, phase, incident). */
115
125
  evidence?: string;
116
126
  };
127
+ /**
128
+ * One claimed closure in a handoff's `open_question_closures` (#122, #186).
129
+ * A bare string — the only shape before this field grew — normalizes to
130
+ * `{ id }`; the object form adds the evidence that settles the question.
131
+ * Completion requires non-empty `evidence` when the closed question's `kind`
132
+ * is `needs-runtime-test`, because such a question closes on runtime evidence
133
+ * rather than on another source read.
134
+ */
135
+ export type ClosureEntry = {
136
+ id: string;
137
+ evidence?: string;
138
+ };
117
139
  export type PhaseHandoff = {
118
140
  phase_id: string;
119
141
  /**
@@ -126,7 +148,8 @@ export type PhaseHandoff = {
126
148
  open_questions: OpenQuestionEntry[];
127
149
  carry_forward: CarryForwardEntry[];
128
150
  carry_forward_closures: string[];
129
- open_question_closures: string[];
151
+ /** ids to resolve and remove from all phases; a bare string parses to `{ id }`. */
152
+ open_question_closures: ClosureEntry[];
130
153
  post_pipeline: PostPipelineEntry[];
131
154
  decisions: string[];
132
155
  /** Conventions proposed for promotion; completion stages them in CONVENTIONS.md. Omitted defaults to empty. */
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 in result) {
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
- result[key] = rawValue === "|" ? `${content}\n` : content;
185
+ assign(key, rawValue === "|" ? `${content}\n` : content);
172
186
  continue;
173
187
  }
174
188
  if (rawValue !== "") {
175
- result[key] = parseYamlScalar(rawValue);
189
+ assign(key, parseYamlScalar(rawValue));
176
190
  continue;
177
191
  }
178
192
  skipBlank();
179
193
  if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
180
- result[key] = parseBlock(countIndent(lines[index] ?? ""));
194
+ assign(key, parseBlock(countIndent(lines[index] ?? "")));
181
195
  }
182
196
  else {
183
- result[key] = null;
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 rawItem = trimmed === "-" ? "" : trimmed.slice(2).trim();
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(indent + 2);
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(ctx: ExtensionContext, validation: ValidationResult): Promise<AutoCompleteResult>;
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(ctx, validation) {
155
- const result = await completeValidatedPhase(ctx.cwd, validation, "/codecarto-complete");
156
- void writeDashboard(ctx.cwd, PACKAGE_VERSION);
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(ctx, validation);
272
+ const { updatedState } = await autoCompletePhase(autoCwd, validation);
266
273
  state = updatedState;
267
274
  phasesRun.push(phase.id);
268
275
  options.onPhaseAdvanced?.(state);