codecartographer-pi 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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";
@@ -136,6 +136,11 @@ export declare function isValidSlug(slug: string): boolean;
136
136
  * Caller is responsible for collision handling — derived slugs may already
137
137
  * exist in the library and the calling UX (Pi or MCP) is the right place
138
138
  * to ask the user about it.
139
+ *
140
+ * A clone's remote URL and its checkout directory derive the same slug
141
+ * (`…/whisper.git`, `git@host:acme/whisper`, and `/path/whisper` all give
142
+ * `whisper`), which is what lets the Pi command switch from recording the
143
+ * directory to recording the remote without renaming anyone's entry.
139
144
  */
140
145
  export declare function deriveSlug(sourceRepo: string): string;
141
146
  /**
@@ -212,6 +217,41 @@ export declare class ConfidentialityMismatchError extends Error {
212
217
  readonly libraryVisibility: LibraryVisibility;
213
218
  constructor(message: string, entryConfidentiality: LibraryVisibility, libraryVisibility: LibraryVisibility);
214
219
  }
220
+ /**
221
+ * Thrown by `publishEntry` when the target entry's newest version records a
222
+ * `source_repo` that denotes a different repository than the incoming one.
223
+ * Nothing has been written when this is raised. It carries both values so a
224
+ * wrapper with a user to ask (Pi) can pose "did the repository move?" from
225
+ * the values rather than by matching the message.
226
+ */
227
+ export declare class SourceRepoMismatchError extends Error {
228
+ /** The `source_repo` the entry's newest version records. */
229
+ readonly recorded: string;
230
+ /** The `source_repo` this publish carries. */
231
+ readonly incoming: string;
232
+ constructor(message: string, recorded: string, incoming: string);
233
+ }
234
+ /** What `publishEntry` would do to an entry's version history, without doing it. */
235
+ export interface PublishVersionPreview {
236
+ /** Highest version directory present, or 0 for a new entry. */
237
+ latestVersion: number;
238
+ /** The version the publish would write or update. */
239
+ version: number;
240
+ /** True when a new version directory would be created; false for a metadata-only update. */
241
+ isNewVersion: boolean;
242
+ }
243
+ /**
244
+ * Read-only preview of the version a publish would land on. This is the same
245
+ * content-hash decision `publishEntry` makes (it calls this), so a wrapper
246
+ * that has to describe a publish before performing it — the MCP server's
247
+ * `publish_confirm` refusal — shows what would actually happen. Neither the
248
+ * collision nor the confidentiality guard is evaluated here; those still run
249
+ * on the real publish.
250
+ */
251
+ export declare function previewPublishVersion(libraryRoot: string, spec: string, ref: {
252
+ slug: string;
253
+ namespace?: string;
254
+ }, opts?: Pick<PublishOptions, "forceNewVersion">): Promise<PublishVersionPreview>;
215
255
  export declare function publishEntry(libraryRoot: string, spec: string, input: PublishInput, opts?: PublishOptions): Promise<PublishResult>;
216
256
  export interface EntryRef {
217
257
  slug: string;
@@ -248,6 +288,39 @@ export declare function reindex(libraryRoot: string): Promise<ReindexResult>;
248
288
  * reported the same way; the history alone cannot tell the two apart.
249
289
  */
250
290
  export declare function detectProvenanceConflicts(libraryRoot: string, entries: ReadonlyArray<Pick<LibraryIndexEntry, "slug" | "namespace">>): Promise<ProvenanceConflict[]>;
291
+ /** What a Pi publish records as `source_repo`, and where the value came from. */
292
+ export interface ResolvedSourceRepo {
293
+ /** The value to record: a remote's fetch URL verbatim, or the directory itself. */
294
+ source_repo: string;
295
+ /**
296
+ * The git remote the URL was read from (`origin`, or the current branch's
297
+ * upstream remote), or null when the directory was recorded instead — it
298
+ * is not a git work tree, is a subdirectory of one, or has no usable
299
+ * remote.
300
+ */
301
+ remote: string | null;
302
+ }
303
+ /**
304
+ * Resolve the repository reference a publish from `cwd` should record. The
305
+ * remote is preferred over the path because a path means nothing outside the
306
+ * machine that published it, and because a slug derived from the remote is
307
+ * stable across clones of one repository, which is what lets the collision
308
+ * guard compare something meaningful (#147).
309
+ *
310
+ * Resolution order: `origin`'s fetch URL, else the fetch URL of the remote
311
+ * the current branch tracks, else `cwd`. The remote is consulted only when
312
+ * `cwd` is the root of its work tree. A subdirectory keeps recording its
313
+ * path: every subdirectory of one repository would otherwise resolve to the
314
+ * same URL and the same slug, and the second one published would land as a
315
+ * new version of the first with no guard able to tell — exactly the
316
+ * cross-project append the guard exists to refuse.
317
+ *
318
+ * The URL is stored as git reports it. Spellings of one repository are
319
+ * reconciled at comparison time by `sameSourceRepo`, not here, so the
320
+ * recorded value stays human-readable. Never throws: a missing `git` binary
321
+ * or any git failure falls back to the path.
322
+ */
323
+ export declare function resolvePublishSourceRepo(cwd: string): Promise<ResolvedSourceRepo>;
251
324
  export interface CommitOptions {
252
325
  addAll?: boolean;
253
326
  }
@@ -25,13 +25,14 @@
25
25
  // files, entries whose versions disagree about source_repo — the shape
26
26
  // a slug collision left behind before publish refused cross-project
27
27
  // appends. Repair is manual; see the "Provenance conflicts" section.
28
- // - Git operations (`commitPublish`) shell out to the `git` binary.
29
- // Failures are non-fatal — the caller decides how to surface them.
28
+ // - Git operations (`commitPublish`, `resolvePublishSourceRepo`) shell out
29
+ // to the `git` binary. Failures are non-fatal — the caller decides how to
30
+ // surface them, and the resolver falls back to the directory itself.
30
31
  import { createHash } from "node:crypto";
31
32
  import { spawn } from "node:child_process";
32
33
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
33
34
  import { basename, join, resolve } from "node:path";
34
- import { isPlainObject, pathExists } from "./utils.js";
35
+ import { canonicalPath, isPlainObject, normalizeForComparison, pathExists } from "./utils.js";
35
36
  import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
36
37
  // ─── Constants ──────────────────────────────────────────────────────────────
37
38
  export const LIBRARY_MARKER_FILE = ".codecarto-library";
@@ -137,9 +138,19 @@ export function isValidSlug(slug) {
137
138
  * Caller is responsible for collision handling — derived slugs may already
138
139
  * exist in the library and the calling UX (Pi or MCP) is the right place
139
140
  * to ask the user about it.
141
+ *
142
+ * A clone's remote URL and its checkout directory derive the same slug
143
+ * (`…/whisper.git`, `git@host:acme/whisper`, and `/path/whisper` all give
144
+ * `whisper`), which is what lets the Pi command switch from recording the
145
+ * directory to recording the remote without renaming anyone's entry.
140
146
  */
141
147
  export function deriveSlug(sourceRepo) {
142
- const cleaned = sourceRepo.replace(/\.git$/i, "").replace(/\\/g, "/");
148
+ let cleaned = sourceRepo.replace(/\.git$/i, "").replace(/\\/g, "/");
149
+ // SCP shorthand for a repository at the root of a host (`git@host:whisper`)
150
+ // has no slash at all, so the colon is the only separator to split on. Any
151
+ // form with a slash already yields the right trailing segment below.
152
+ if (!cleaned.includes("/"))
153
+ cleaned = cleaned.replace(/^[^:]*:/, "");
143
154
  const parts = cleaned.split("/").filter((p) => p.length > 0);
144
155
  const last = parts[parts.length - 1] ?? "entry";
145
156
  const slug = last
@@ -195,8 +206,10 @@ export function normalizeSourceRepo(sourceRepo) {
195
206
  // Windows drive paths. A POSIX absolute path is not: /srv/Repos/tool and
196
207
  // /srv/repos/tool are two directories on Linux, and folding them together
197
208
  // would hide exactly the cross-project collision this comparison exists to
198
- // catch. Pi records the analyzed directory as source_repo, so local paths
199
- // are a common case here rather than a curiosity.
209
+ // catch. Pi records the analyzed directory as source_repo whenever it has
210
+ // no git remote to record instead, and every entry Pi published before it
211
+ // resolved remotes holds one, so local paths are a common case here rather
212
+ // than a curiosity.
200
213
  return isCaseSensitivePath(s) ? s : s.toLowerCase();
201
214
  }
202
215
  /** An absolute POSIX path (or a `~` home reference), where case is significant. */
@@ -305,6 +318,48 @@ export class ConfidentialityMismatchError extends Error {
305
318
  this.libraryVisibility = libraryVisibility;
306
319
  }
307
320
  }
321
+ /**
322
+ * Thrown by `publishEntry` when the target entry's newest version records a
323
+ * `source_repo` that denotes a different repository than the incoming one.
324
+ * Nothing has been written when this is raised. It carries both values so a
325
+ * wrapper with a user to ask (Pi) can pose "did the repository move?" from
326
+ * the values rather than by matching the message.
327
+ */
328
+ export class SourceRepoMismatchError extends Error {
329
+ /** The `source_repo` the entry's newest version records. */
330
+ recorded;
331
+ /** The `source_repo` this publish carries. */
332
+ incoming;
333
+ constructor(message, recorded, incoming) {
334
+ super(message);
335
+ this.name = "SourceRepoMismatchError";
336
+ this.recorded = recorded;
337
+ this.incoming = incoming;
338
+ }
339
+ }
340
+ /**
341
+ * Read-only preview of the version a publish would land on. This is the same
342
+ * content-hash decision `publishEntry` makes (it calls this), so a wrapper
343
+ * that has to describe a publish before performing it — the MCP server's
344
+ * `publish_confirm` refusal — shows what would actually happen. Neither the
345
+ * collision nor the confidentiality guard is evaluated here; those still run
346
+ * on the real publish.
347
+ */
348
+ export async function previewPublishVersion(libraryRoot, spec, ref, opts = {}) {
349
+ const entryDir = entryRoot(libraryRoot, ref.namespace, ref.slug);
350
+ const existingVersions = await listVersionDirs(entryDir);
351
+ const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1];
352
+ if (latestVersion > 0 && !opts.forceNewVersion) {
353
+ const latestSpecPath = join(versionDir(libraryRoot, ref.namespace, ref.slug, latestVersion), SPEC_FILE);
354
+ if (await pathExists(latestSpecPath)) {
355
+ const existingSpec = await readFile(latestSpecPath, "utf8");
356
+ if (sha256(existingSpec) === sha256(spec)) {
357
+ return { latestVersion, version: latestVersion, isNewVersion: false };
358
+ }
359
+ }
360
+ }
361
+ return { latestVersion, version: latestVersion + 1, isNewVersion: true };
362
+ }
308
363
  export async function publishEntry(libraryRoot, spec, input, opts = {}) {
309
364
  const marker = await readMarker(libraryRoot);
310
365
  if (!marker) {
@@ -324,9 +379,8 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
324
379
  }
325
380
  const namespace = input.namespace;
326
381
  const entryDir = entryRoot(libraryRoot, namespace, input.slug);
327
- const existingVersions = await listVersionDirs(entryDir);
328
- const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1];
329
- const newSpecHash = sha256(spec);
382
+ const preview = await previewPublishVersion(libraryRoot, spec, { slug: input.slug, namespace }, opts);
383
+ const latestVersion = preview.latestVersion;
330
384
  // Collision guard. Slugs derive from the trailing path segment of the source
331
385
  // repo, so two unrelated projects (acme/whisper and openai/whisper) collapse
332
386
  // onto one slug. Without this check the second publish would append its spec
@@ -338,13 +392,13 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
338
392
  const recorded = await readRecordedSourceRepo(libraryRoot, namespace, input.slug, latestVersion);
339
393
  if (recorded !== null && !sameSourceRepo(recorded, input.source_repo)) {
340
394
  const label = namespace ? `${namespace}/${input.slug}` : input.slug;
341
- throw new Error(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
395
+ throw new SourceRepoMismatchError(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
342
396
  `"${recorded}", but this publish carries "${input.source_repo}". Publishing would ` +
343
397
  `append this spec to a different project's version history. Publish this project ` +
344
398
  `under a distinct slug to shelve it separately, or — if the repository itself ` +
345
399
  `moved (rename, org transfer, host change) — re-publish with the source-repo ` +
346
400
  `change allowed: allow_source_repo_change on codecarto_publish, ` +
347
- `allowSourceRepoChange in PublishOptions.`);
401
+ `allowSourceRepoChange in PublishOptions.`, recorded, input.source_repo);
348
402
  }
349
403
  }
350
404
  // Confidentiality guard. Levels are ordered internal < shared < public. An
@@ -370,34 +424,29 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
370
424
  `this exposure is intended — re-publish with the mismatch allowed: ` +
371
425
  `allow_confidentiality_mismatch on codecarto_publish, allowConfidentialityMismatch in PublishOptions.`, entryConfidentiality, libraryVisibility);
372
426
  }
373
- // Content-hash idempotence: if the latest version's spec matches bytes-for-bytes,
374
- // update metadata in place and return without bumping the version.
375
- if (latestVersion > 0 && !opts.forceNewVersion) {
427
+ // Content-hash idempotence: if the latest version's spec matches bytes-for-bytes
428
+ // (decided by previewPublishVersion above), update metadata in place and
429
+ // return without bumping the version.
430
+ if (!preview.isNewVersion) {
376
431
  const latestVersionDir = versionDir(libraryRoot, namespace, input.slug, latestVersion);
377
- const latestSpecPath = join(latestVersionDir, SPEC_FILE);
378
- if (await pathExists(latestSpecPath)) {
379
- const existingSpec = await readFile(latestSpecPath, "utf8");
380
- if (sha256(existingSpec) === newSpecHash) {
381
- // buildMetadata writes provenance only when the input carries it, and
382
- // neither surface sends it on publish — so without this the rewrite
383
- // would drop the block the version's original publish recorded.
384
- const provenance = input.provenance ?? (await readRecordedProvenance(libraryRoot, namespace, input.slug, latestVersion));
385
- const metadata = buildMetadata({ ...input, provenance }, latestVersion);
386
- await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
387
- if (!opts.skipReindex)
388
- await reindex(libraryRoot);
389
- return {
390
- slug: input.slug,
391
- namespace,
392
- version: latestVersion,
393
- isNewVersion: false,
394
- entryDir,
395
- versionDir: latestVersionDir,
396
- };
397
- }
398
- }
432
+ // buildMetadata writes provenance only when the input carries it, and
433
+ // neither surface sends it on publish — so without this the rewrite
434
+ // would drop the block the version's original publish recorded.
435
+ const provenance = input.provenance ?? (await readRecordedProvenance(libraryRoot, namespace, input.slug, latestVersion));
436
+ const metadata = buildMetadata({ ...input, provenance }, latestVersion);
437
+ await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
438
+ if (!opts.skipReindex)
439
+ await reindex(libraryRoot);
440
+ return {
441
+ slug: input.slug,
442
+ namespace,
443
+ version: latestVersion,
444
+ isNewVersion: false,
445
+ entryDir,
446
+ versionDir: latestVersionDir,
447
+ };
399
448
  }
400
- const nextVersion = latestVersion + 1;
449
+ const nextVersion = preview.version;
401
450
  const finalVersionDir = versionDir(libraryRoot, namespace, input.slug, nextVersion);
402
451
  const stagingDir = `${entryDir}.publish.${process.pid}.${Date.now()}`;
403
452
  // Stage all files under a sibling directory, then atomically rename it
@@ -901,6 +950,55 @@ async function atomicWriteYaml(path, value) {
901
950
  function sha256(content) {
902
951
  return createHash("sha256").update(content, "utf8").digest("hex");
903
952
  }
953
+ /**
954
+ * Resolve the repository reference a publish from `cwd` should record. The
955
+ * remote is preferred over the path because a path means nothing outside the
956
+ * machine that published it, and because a slug derived from the remote is
957
+ * stable across clones of one repository, which is what lets the collision
958
+ * guard compare something meaningful (#147).
959
+ *
960
+ * Resolution order: `origin`'s fetch URL, else the fetch URL of the remote
961
+ * the current branch tracks, else `cwd`. The remote is consulted only when
962
+ * `cwd` is the root of its work tree. A subdirectory keeps recording its
963
+ * path: every subdirectory of one repository would otherwise resolve to the
964
+ * same URL and the same slug, and the second one published would land as a
965
+ * new version of the first with no guard able to tell — exactly the
966
+ * cross-project append the guard exists to refuse.
967
+ *
968
+ * The URL is stored as git reports it. Spellings of one repository are
969
+ * reconciled at comparison time by `sameSourceRepo`, not here, so the
970
+ * recorded value stays human-readable. Never throws: a missing `git` binary
971
+ * or any git failure falls back to the path.
972
+ */
973
+ export async function resolvePublishSourceRepo(cwd) {
974
+ const asPath = { source_repo: cwd, remote: null };
975
+ try {
976
+ const toplevel = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
977
+ if (!toplevel.ok || toplevel.stdout.trim() === "")
978
+ return asPath;
979
+ const [canonicalCwd, canonicalTop] = await Promise.all([canonicalPath(cwd), canonicalPath(toplevel.stdout.trim())]);
980
+ if (normalizeForComparison(canonicalCwd) !== normalizeForComparison(canonicalTop))
981
+ return asPath;
982
+ const origin = await runGit(cwd, ["remote", "get-url", "origin"]);
983
+ if (origin.ok && origin.stdout.trim() !== "")
984
+ return { source_repo: origin.stdout.trim(), remote: "origin" };
985
+ const branch = await runGit(cwd, ["symbolic-ref", "--short", "HEAD"]);
986
+ if (!branch.ok || branch.stdout.trim() === "")
987
+ return asPath;
988
+ const upstream = await runGit(cwd, ["config", "--get", `branch.${branch.stdout.trim()}.remote`]);
989
+ const remote = upstream.stdout.trim();
990
+ // `.` marks a branch tracking another local branch; there is no URL behind it.
991
+ if (!upstream.ok || remote === "" || remote === ".")
992
+ return asPath;
993
+ const url = await runGit(cwd, ["remote", "get-url", remote]);
994
+ if (url.ok && url.stdout.trim() !== "")
995
+ return { source_repo: url.stdout.trim(), remote };
996
+ return asPath;
997
+ }
998
+ catch {
999
+ return asPath;
1000
+ }
1001
+ }
904
1002
  /**
905
1003
  * Optional convenience: stage and commit publish output. Never pushes.
906
1004
  * On any failure, returns `{ ok: false, skipped: <reason> }` rather than
@@ -17,6 +17,12 @@ export interface LibraryConfig {
17
17
  /** Whether `codecarto publish` should display a confirmation prompt
18
18
  * with slug + source + library path before writing. Default true. */
19
19
  publish_confirm: boolean;
20
+ /** True when some config layer set `publish_confirm`; false when the
21
+ * default above supplied it. Pi confirms either way (a dialog costs
22
+ * nothing), but the MCP server's refuse-unless-confirmed gate on
23
+ * `codecarto_publish` costs every host a round trip, so it applies only
24
+ * to hosts that actually configured the key. */
25
+ publish_confirm_configured: boolean;
20
26
  }
21
27
  export interface CodecartoConfig {
22
28
  orchestrator: OrchestratorConfig;
@@ -38,6 +38,7 @@ const DEFAULT_CONFIG = {
38
38
  path: null,
39
39
  namespace: null,
40
40
  publish_confirm: true,
41
+ publish_confirm_configured: false,
41
42
  },
42
43
  };
43
44
  export async function loadCodecartoConfig(workspaceDir) {
@@ -101,6 +102,7 @@ function applyRaw(base, raw) {
101
102
  }
102
103
  if (typeof l.publish_confirm === "boolean") {
103
104
  out.library.publish_confirm = l.publish_confirm;
105
+ out.library.publish_confirm_configured = true;
104
106
  }
105
107
  }
106
108
  return out;
@@ -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[];
@@ -14,7 +21,8 @@ export declare function createEmptyStatus(projectName: string, pipelinePath: str
14
21
  * moment every phase completes is exactly when skills, amendments, publishing,
15
22
  * and the dashboard apply; the prior static sentence left them undiscovered —
16
23
  * 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.
24
+ * Amendment recomputes this list so closure counts never go stale. Every tool
25
+ * named here is spelled for both surfaces (see onBothSurfaces).
18
26
  */
19
27
  export declare function buildTerminalNextActions(status: NormalizedStatus): string[];
20
28
  export declare function normalizeStatus(status: StatusFile, pipeline: PipelineFile, pipelinePath: string, cwd: string): NormalizedStatus;