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.
- package/.codecarto/GUIDE.md +9 -2
- package/.codecarto/skills/spec-delta-application/SKILL.md +1 -1
- package/.codecarto/templates/amendment.yaml +3 -3
- package/.codecarto/templates/phase-handoff.yaml +20 -1
- package/.codecarto/templates/spike-report.md +2 -2
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +10 -6
- package/agent-skill/codecartographer/SKILL.md +1 -1
- package/agent-skill/codecartographer/references/handoff-contract.md +30 -2
- package/agent-skill/codecartographer/references/library.md +2 -2
- package/agent-skill/codecartographer/references/phase-recovery.md +1 -1
- package/dist/core/amendment.d.ts +5 -0
- package/dist/core/amendment.js +21 -2
- 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/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/library.d.ts +73 -0
- package/dist/core/library.js +135 -37
- package/dist/core/orchestrator-config.d.ts +6 -0
- package/dist/core/orchestrator-config.js +2 -0
- package/dist/core/prompts.js +19 -1
- package/dist/core/status.d.ts +10 -2
- package/dist/core/status.js +56 -7
- package/dist/core/types.d.ts +24 -1
- package/dist/core/workspace.d.ts +16 -0
- package/dist/core/workspace.js +31 -4
- package/dist/extensions/codecarto/index.js +355 -20
- package/dist/mcp-server/server.js +63 -4
- package/package.json +1 -1
|
@@ -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/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/library.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/core/library.js
CHANGED
|
@@ -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
|
|
29
|
-
// Failures are non-fatal — the caller decides how to
|
|
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
|
-
|
|
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
|
|
199
|
-
//
|
|
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
|
|
328
|
-
const latestVersion =
|
|
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
|
|
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
|
|
375
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 =
|
|
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;
|
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[];
|
|
@@ -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;
|