codecartographer-pi 0.17.1 → 0.18.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.
@@ -92,7 +92,7 @@ If you are uncertain whether a file should be modified, treat it as read-only.
92
92
  ### Two things named "backlog", and neither is the other
93
93
 
94
94
  - **`BACKLOG.md` in this workspace** is *this project's* deferrals: work the project chose not to do yet. `DECISIONS.md` records what the project decided to **do**; `BACKLOG.md` records what it decided to **defer**. Deferrals get no `D` number. When a deferred item is later picked up, remove its entry here and record the decision in `DECISIONS.md`.
95
- - **`status.yaml`'s `post_pipeline` list** is framework-owned lifecycle state, not this file. Items there are retired by `codecarto_amend`, never by hand.
95
+ - **`status.yaml`'s `post_pipeline` list** is framework-owned lifecycle state, not this file. Items there are retired by an amendment (`codecarto_amend` on MCP, `/codecarto-amend` on Pi), never by hand.
96
96
  - **CodeCartographer's own backlog** — deferred improvements to the *framework* — lives in the CodeCartographer repository, not in your workspace. If a phase prompt misled you or a validation criterion did not fit, that is feedback to the framework; it does not belong in this file.
97
97
 
98
98
  ## Pipeline Selection
@@ -215,7 +215,7 @@ post_pipeline:
215
215
 
216
216
  `kind` is one of: `needs-runtime-test`, `needs-maintainer-decision`, `needs-spec-ruling`, `defer-to-phase`, `needs-fixture-capture`, or a post-pipeline work kind such as `spike` or `amendment`. Every new `carry_forward.target_phase` must be an ID in the active pipeline. Every `post_pipeline` entry requires a stable ID. Open questions should carry a stable `id` (e.g. `q-loadconfig-ambiguity`); if omitted, the framework auto-assigns one. When a later phase resolves an open question, list its id in `open_question_closures` to remove it from all phases. The downstream phase records resolved carry-forward IDs in `carry_forward_closures`; completion removes those entries atomically.
217
217
 
218
- After the pipeline completes, the handoff channel closes with it. Post-pipeline resolutions — an open question answered on evidence, a finished `post_pipeline` backlog item — are applied with an **amendment**: write `scratch/amendments/<slug>.yaml` (see `templates/amendment.yaml`) and run `codecarto_amend`. It updates `workflow/status.yaml` under the same lock completion uses and writes an amendment closeout plus THREAD_LOG entry. Never hand-edit `status.yaml` for this; amendments are refused while the pipeline is still running, so the two channels cannot race.
218
+ After the pipeline completes, the handoff channel closes with it. Post-pipeline resolutions — an open question answered on evidence, a finished `post_pipeline` backlog item — are applied with an **amendment**: write `scratch/amendments/<slug>.yaml` (see `templates/amendment.yaml`) and run `codecarto_amend` (MCP) or `/codecarto-amend <slug>` (Pi). It updates `workflow/status.yaml` under the same lock completion uses and writes an amendment closeout plus THREAD_LOG entry. Never hand-edit `status.yaml` for this; amendments are refused while the pipeline is still running, so the two channels cannot race.
219
219
 
220
220
  ## Phase Selection Logic
221
221
 
@@ -88,7 +88,7 @@ Per the standard closeout ritual:
88
88
 
89
89
  - Append a one-line entry to `THREAD_LOG.md` pointing at the closeout file.
90
90
  - Write `closeouts/<YYYY-MM-DD>-spec-deltas.md` using `templates/closeout-template.md`.
91
- - If a delta resolved an `open_questions` entry (or finished a `post_pipeline` backlog item), apply it: write `scratch/amendments/<slug>.yaml` (see `templates/amendment.yaml`) listing the closures, then run `codecarto_amend`. It updates `status.yaml` under the completion lock and writes the amendment closeout — never hand-edit `status.yaml`, which is framework-owned. Without the MCP server, record the intended amendment file in the closeout for the next MCP-capable session to apply.
91
+ - If a delta resolved an `open_questions` entry (or finished a `post_pipeline` backlog item), apply it: write `scratch/amendments/<slug>.yaml` (see `templates/amendment.yaml`) listing the closures, then run `codecarto_amend` (MCP) or `/codecarto-amend <slug>` (Pi). It updates `status.yaml` under the completion lock and writes the amendment closeout — never hand-edit `status.yaml`, which is framework-owned. Without Pi or the MCP server (drop-in mode), record the intended amendment file in the closeout for the next Pi or MCP session to apply.
92
92
  - Append numbered entries to `DECISIONS.md` for any decisions made during triage that weren't already in the deltas (e.g., "rejected Δ7 because the spec already covered the case at §X").
93
93
 
94
94
  ## What to avoid
@@ -1,7 +1,7 @@
1
1
  # Post-pipeline amendment schema v1. Copy to scratch/amendments/<slug>.yaml,
2
- # then run codecarto_amend (MCP) with the slug. Refused while the pipeline is
3
- # incomplete — mid-pipeline resolutions belong in the phase handoff
4
- # (open_question_closures / carry_forward_closures).
2
+ # then run codecarto_amend (MCP) or /codecarto-amend <slug> (Pi). Refused while
3
+ # the pipeline is incomplete — mid-pipeline resolutions belong in the phase
4
+ # handoff (open_question_closures / carry_forward_closures).
5
5
  schema_version: 1
6
6
  # Open-question ids resolved on evidence after the pipeline completed;
7
7
  # removed from every phase in workflow/status.yaml.
@@ -12,8 +12,8 @@
12
12
  phase handoff. When a spike's findings change the reimplementation spec, write
13
13
  the deltas as Recommended Deltas below and apply them with the
14
14
  spec-delta-application skill; when a spike resolves an open question after the
15
- pipeline completed, close it with an amendment (templates/amendment.yaml +
16
- codecarto_amend), citing this report.
15
+ pipeline completed, close it with an amendment (templates/amendment.yaml, then
16
+ codecarto_amend on MCP or /codecarto-amend on Pi), citing this report.
17
17
 
18
18
  Keep it honest: a spike that failed to answer its question is a valid result —
19
19
  record what was tried and what blocked it.
@@ -3,4 +3,4 @@
3
3
  # workspace's framework-owned files (GUIDE.md, templates/, workflow/ pipelines
4
4
  # and VALIDATE.md) predate the running release. Written at release time and
5
5
  # copied verbatim by init — never edit by hand.
6
- scaffold_version: 0.17.1
6
+ scaffold_version: 0.18.0
package/README.md CHANGED
@@ -92,7 +92,7 @@ Use this when your coding agent isn't Pi — Claude Code, Codex, opencode, Curso
92
92
 
93
93
  > **30-second setup for Claude Code, Cursor, Codex, and Claude Desktop: see the [MCP quickstart](docs/mcp-quickstart.md).**
94
94
 
95
- > **Teaching an agent to drive it:** call the `codecarto_guide` tool — the server returns the full drive loop, the phase-handoff contract, executor selection, and recovery patterns, with nothing to install. The same content ships as an installable skill at `agent-skill/codecartographer/` for agents that load skills from disk.
95
+ > **Teaching an agent to drive it:** call the `codecarto_guide` tool — the server returns the full drive loop, the phase-handoff contract, executor selection, and recovery patterns, with nothing to install. The same content ships as an installable skill at `agent-skill/codecartographer/` for agents that load skills from disk, and `/codecarto-guide [topic]` reads it into a Pi session.
96
96
 
97
97
  ```bash
98
98
  npm install --global codecartographer-pi
@@ -137,7 +137,7 @@ Analysis turns repositories into reusable specifications. Synthesis runs the oth
137
137
  library:
138
138
  path: /absolute/path/to/codecarto-library
139
139
  namespace: your-namespace # omit for a single-tenant library
140
- publish_confirm: true
140
+ publish_confirm: true # Pi asks before writing; MCP refuses a publish that lacks confirm: true
141
141
  ```
142
142
 
143
143
  2. Initialize a clean planning workspace and fill in its brief:
@@ -321,12 +321,16 @@ Beyond the slash commands, the Pi extension layers on:
321
321
  | `/codecarto-validate [phase]` | Validate a phase output against completion criteria |
322
322
  | `/codecarto-complete [phase]` | Validate and atomically apply the phase handoff, canonical status, closeout, and log entry |
323
323
  | `/codecarto-skill <name>` | Run a post-pipeline skill once all phases are complete (or `broadside` any time, for the scout reading guide) |
324
+ | `/codecarto-list-skills` | List the installed post-pipeline skills and the ungated `broadside` reading guide, and say when the gated ones unlock |
325
+ | `/codecarto-guide [topic]` | Read the packaged agent guide — drive loop, handoff contract, executors, recovery, Broad-Side — into the session; tab-completes topics; needs no workspace |
324
326
  | `/codecarto-broadside [action] [lenses…]` | Batch reconnaissance (Broad-Side). Actions: `submit`, `collect`, `status`, `models`. Prices the run and asks before spending; works with or without a workspace |
325
327
  | `/codecarto-publish` | Publish the reimplementation spec to the configured library after reviewing an explicit confirmation preview |
326
328
  | `/codecarto-library-init <path> [--namespace <name>]` | Create a library directory with marker and write the config — fixes the first-publish dead end |
327
329
  | `/codecarto-config` | Show the effective merged configuration (global + workspace) and library marker status |
328
330
  | `/codecarto-usage` | Cumulative + per-phase token usage |
329
331
  | `/codecarto-dashboard [--narrate]` | Regenerate `.codecarto/dashboard.html`; `--narrate` for the LLM executive summary |
332
+ | `/codecarto-refresh-scaffold` | Refresh the framework-owned `.codecarto/` files (GUIDE.md, templates/, workflow/ pipelines and VALIDATE.md) from the packaged template after a confirmation that lists the exact file set; project state, config, findings outputs, scratch, closeouts, and `broadside/` are never touched |
333
+ | `/codecarto-amend <name>` | Apply a post-pipeline amendment from `scratch/amendments/<name>.yaml` after a confirmation that previews which open questions and post-pipeline items it closes; refused while the pipeline is incomplete |
330
334
 
331
335
  ### End-to-end auto mode (0.8.0+)
332
336
 
@@ -360,7 +364,7 @@ Implements MCP spec revision [`2025-11-25`](https://modelcontextprotocol.io/spec
360
364
  | `codecarto_validate` | `/codecarto-validate` |
361
365
  | `codecarto_complete` | `/codecarto-complete` |
362
366
  | `codecarto_skill` | `/codecarto-skill` |
363
- | `codecarto_list_skills` | MCP-only ([#161](https://github.com/HuginnIndustries/CodeCartographer/issues/161)); Pi lists skills when `/codecarto-skill` runs with no argument |
367
+ | `codecarto_list_skills` | `/codecarto-list-skills` |
364
368
  | `codecarto_publish` | `/codecarto-publish` |
365
369
  | `codecarto_library_init` | `/codecarto-library-init` |
366
370
  | `codecarto_library_list` | MCP-only library listing |
@@ -368,9 +372,9 @@ Implements MCP spec revision [`2025-11-25`](https://modelcontextprotocol.io/spec
368
372
  | `codecarto_config` | `/codecarto-config` |
369
373
  | `codecarto_usage` | `/codecarto-usage` |
370
374
  | `codecarto_dashboard` | `/codecarto-dashboard` |
371
- | `codecarto_guide` | MCP-only ([#160](https://github.com/HuginnIndustries/CodeCartographer/issues/160)) |
372
- | `codecarto_amend` | MCP-only ([#157](https://github.com/HuginnIndustries/CodeCartographer/issues/157)) |
373
- | `codecarto_refresh_scaffold` | MCP-only ([#159](https://github.com/HuginnIndustries/CodeCartographer/issues/159)) |
375
+ | `codecarto_guide` | `/codecarto-guide` |
376
+ | `codecarto_amend` | `/codecarto-amend` (Pi previews the closures and asks first) |
377
+ | `codecarto_refresh_scaffold` | `/codecarto-refresh-scaffold` (Pi lists the file set and asks first) |
374
378
  | `codecarto_broadside` | `/codecarto-broadside` |
375
379
 
376
380
  Each workflow tool accepts an absolute `cwd` for the target repository. `codecarto_init` requires `force: true` to overwrite an existing `.codecarto/` (instead of Pi's interactive confirmation). The library tools accept an explicit absolute `library_path` or resolve `library.path` from `.codecarto/workflow/config.yaml` / `~/.codecarto/config.yaml`. `codecarto_library_reindex` and `codecarto_library_list` also report entries whose versions disagree about `source_repo` — the shape a slug collision left behind before v0.17.0's publish guard — and leave the repair manual, since splitting an entry changes paths the library format treats as ABI. The library schema is experimental and may break before v2.
@@ -117,7 +117,7 @@ A PARTIAL row's evidence must name what is missing and which `open_questions` or
117
117
  - `codecarto_next` returns a prompt. Something still has to *do* the phase.
118
118
  - Never hand-edit `workflow/status.yaml`, append `THREAD_LOG.md`, or write a second closeout. Propose through the handoff.
119
119
  - Do not force phases out of DAG order unless the user asked.
120
- - If `codecarto_status` reports a scaffold-staleness warning, refresh the workspace's framework-owned files before trusting anything written inside `.codecarto/`; a stale scaffold's `GUIDE.md` can contradict this contract.
120
+ - If `codecarto_status` reports a scaffold-staleness warning, refresh the workspace's framework-owned files (`codecarto_refresh_scaffold`; `/codecarto-refresh-scaffold` on the Pi extension) before trusting anything written inside `.codecarto/`; a stale scaffold's `GUIDE.md` can contradict this contract. The refresh never touches project state, findings outputs, or session directories.
121
121
  - A delegated run that times out may still have written its artifact. Check for the file and validate before retrying.
122
122
  - The drop-in `.codecarto/` template works without MCP, but the server is preferred: it owns atomic state updates, validation parsing, and the completion gate.
123
123
 
@@ -13,7 +13,7 @@ A CodeCartographer **library** is a directory of published reimplementation-spec
13
13
  | Tool | Does | Notes |
14
14
  |---|---|---|
15
15
  | `codecarto_library_init` | Create the directory, write the marker, record `library.path` in user-global config | Idempotent; pass `namespace` to create a namespaced library |
16
- | `codecarto_publish` | Publish a spec as a library entry | Required: `source_repo`, `headline`, and `spec` (inline) or `spec_path` (absolute). Content-hash idempotent: identical bytes update metadata in place, no version bump. `slug` derives from `source_repo` if omitted; namespaced libraries require `namespace` (or inherit via `cwd`). Provenance (`source_commit`, `source_branch`, `source_dirty`, `analyzed_at`, `pipeline`, `model_metadata`) is recorded; omitted generation fields default to `unknown` |
16
+ | `codecarto_publish` | Publish a spec as a library entry | Required: `source_repo`, `headline`, and `spec` (inline) or `spec_path` (absolute). Content-hash idempotent: identical bytes update metadata in place, no version bump. `slug` derives from `source_repo` if omitted; namespaced libraries require `namespace` (or inherit via `cwd`). Provenance (`source_commit`, `source_branch`, `source_dirty`, `analyzed_at`, `pipeline`, `model_metadata`) is recorded; omitted generation fields default to `unknown`. `confirm: true` acknowledges the `publish_confirm` gate (below) |
17
17
  | `codecarto_library_list` | List entries | Filter by `namespace`, `tag`, `slug`, or `source_repo` |
18
18
  | `codecarto_library_reindex` | Regenerate `index.yaml` + `INDEX.md` from filesystem state | For manual edits and index merge conflicts. Also reports entries whose versions disagree about `source_repo` (merged by a slug collision before publish refused cross-project appends; `codecarto_library_list` flags them too) — repair is manual, split the entry by hand |
19
19
 
@@ -25,7 +25,7 @@ The moment `reimplementation-spec` completes and validates is the publish moment
25
25
  codecarto_publish cwd:<workspace repo> source_repo:<repo URL or path> headline:"<one line>" spec_path:<abs path to reimplementation-spec.md>
26
26
  ```
27
27
 
28
- Set `publish_confirm` in config if you want an explicit confirmation gate before writes. **Pi-only today:** the Pi extension asks for interactive confirmation before `/codecarto-publish` writes; the MCP `codecarto_publish` tool does not act on the key (an MCP host has no one to ask — [#162](https://github.com/HuginnIndustries/CodeCartographer/issues/162) tracks whether it should refuse-unless-forced instead), so on MCP treat it as advisory.
28
+ Set `publish_confirm` in config if you want an explicit confirmation gate before writes. It means something on both executable surfaces, in the only way each can ask. The Pi extension shows a preview and asks interactively before `/codecarto-publish` writes. The MCP `codecarto_publish` tool has no one to ask, so when the key is set — in `~/.codecarto/config.yaml`, or in the workspace's `.codecarto/workflow/config.yaml` when `cwd` is passed — it refuses a call that lacks `confirm: true` and returns the preview instead: library, entry, whether this would be a new version or a metadata-only update, `source_repo`, headline, confidentiality. Nothing is written by the refusal. Show the preview, then re-invoke with the same arguments plus `confirm: true` to publish. The gate applies only when the key is actually set in a config file (`codecarto_library_init` writes `publish_confirm: true`, so a library initialized through the tool has it on); a host that never configured the key is not gated, and `publish_confirm: false` drops the gate.
29
29
 
30
30
  ## What this is not
31
31
 
@@ -39,7 +39,7 @@ If two reduced-scope attempts fail, the problem is usually scope, not the execut
39
39
 
40
40
  - Split the reading. Use scoped pre-passes over individual subsystems, save the notes under `.codecarto/scratch/`, and give the retry those notes as evidence.
41
41
  - Consider whether the pipeline variant is right. A repository too large for one `architecture` pass may want `architecture-only` first, reviewed, then a switch.
42
- - Check for a scaffold-staleness warning in `codecarto_status`. A workspace whose framework-owned files predate the running version can carry instructions that contradict the current contract, which produces artifacts that fail validation for reasons the executor cannot see.
42
+ - Check for a scaffold-staleness warning in `codecarto_status`. A workspace whose framework-owned files predate the running version can carry instructions that contradict the current contract, which produces artifacts that fail validation for reasons the executor cannot see. `codecarto_refresh_scaffold` (`/codecarto-refresh-scaffold` on the Pi extension) refreshes those files without touching project state.
43
43
 
44
44
  ## What not to do
45
45
 
@@ -27,6 +27,11 @@ export type AmendmentResult = {
27
27
  };
28
28
  /** Same charset rule as phase ids: the slug becomes file names, so path shapes are refused. */
29
29
  export declare function assertSafeAmendmentSlug(slug: string): void;
30
+ /**
31
+ * Slugs of the amendment files staged under scratch/amendments/, sorted.
32
+ * Discovery only — nothing here is validated; {@link loadAmendmentFile} does that.
33
+ */
34
+ export declare function listAmendmentNames(workspaceDir: string): Promise<string[]>;
30
35
  /**
31
36
  * Load and validate one amendment file.
32
37
  * @param name - the amendment slug, with or without a `.yaml` suffix.
@@ -5,8 +5,8 @@
5
5
  // carry_forward_closures); an amendment is the post-pipeline counterpart, so
6
6
  // spec-delta sessions, spikes, and maintainer rulings no longer end with
7
7
  // "record for a later explicit amendment" that nothing can perform.
8
- import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
9
- import { join } from "node:path";
8
+ import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
9
+ import { basename, join } from "node:path";
10
10
  import { getNextEligiblePhase } from "./pipeline.js";
11
11
  import { buildTerminalNextActions, ensureArray, normalizeStatus } from "./status.js";
12
12
  import { dateOnly, newlineIfUnterminated, pathExists } from "./utils.js";
@@ -18,6 +18,25 @@ export function assertSafeAmendmentSlug(slug) {
18
18
  throw new Error(`Invalid amendment name: ${slug}`);
19
19
  }
20
20
  }
21
+ /**
22
+ * Slugs of the amendment files staged under scratch/amendments/, sorted.
23
+ * Discovery only — nothing here is validated; {@link loadAmendmentFile} does that.
24
+ */
25
+ export async function listAmendmentNames(workspaceDir) {
26
+ const amendmentsDir = join(workspaceDir, "scratch", "amendments");
27
+ if (!(await pathExists(amendmentsDir)))
28
+ return [];
29
+ try {
30
+ const entries = await readdir(amendmentsDir, { withFileTypes: true });
31
+ return entries
32
+ .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name))
33
+ .map((entry) => basename(entry.name).replace(/\.ya?ml$/i, ""))
34
+ .sort();
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ }
21
40
  /**
22
41
  * Load and validate one amendment file.
23
42
  * @param name - the amendment slug, with or without a `.yaml` suffix.
@@ -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;
@@ -14,7 +14,8 @@ export declare function createEmptyStatus(projectName: string, pipelinePath: str
14
14
  * moment every phase completes is exactly when skills, amendments, publishing,
15
15
  * and the dashboard apply; the prior static sentence left them undiscovered —
16
16
  * the 0.15.0 field test finished two full runs with every one of them unused.
17
- * Amendment recomputes this list so closure counts never go stale.
17
+ * Amendment recomputes this list so closure counts never go stale. Every tool
18
+ * named here is spelled for both surfaces (see onBothSurfaces).
18
19
  */
19
20
  export declare function buildTerminalNextActions(status: NormalizedStatus): string[];
20
21
  export declare function normalizeStatus(status: StatusFile, pipeline: PipelineFile, pipelinePath: string, cwd: string): NormalizedStatus;