@izagood/avcs 0.29.1 → 0.31.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/dist/api/repo.js CHANGED
@@ -20,8 +20,17 @@ import { defaultPolicy, MATERIALIZER_VERSION } from "../reducer/policy.js";
20
20
  import { Keyring, generateKeypair, signMessage, } from "../core/identity.js";
21
21
  import { checkLease, isActive, scopesOverlap } from "../concurrency/lease.js";
22
22
  import { isBinary } from "../core/bytes.js";
23
+ import { lcsLineLength } from "../merge/merge3.js";
23
24
  import { Metrics } from "../observe/metrics.js";
24
25
  import { silentLogger } from "../observe/logger.js";
26
+ /** The trunk branch assumed when `.avcs/config.json` records none (docs/20 §3.1). */
27
+ export const DEFAULT_TRUNK = "main";
28
+ /**
29
+ * The branch names that count as trunk when none is configured. The pre-trunk bridge
30
+ * special-cased exactly this pair, so keeping both is what makes an unconfigured repository
31
+ * — including a `master`-default one — behave as it always did (docs/20 W7).
32
+ */
33
+ export const LEGACY_TRUNK_BRANCHES = ["main", "master"];
25
34
  // Sidecar: ignore EVERYTHING under .avcs/ (the `*` also ignores this file itself), so the
26
35
  // directory contributes nothing to git — the team's git history is untouched by AVCS.
27
36
  const GITIGNORE_SIDECAR = `# AVCS — sidecar mode (default).
@@ -946,6 +955,12 @@ export class Repo {
946
955
  }
947
956
  // required checks — unless an active break-glass Override waives them (Phase 12)
948
957
  const cp = await this.store.get(args.newCheckpoint);
958
+ // A workspace-scoped checkpoint (docs/20 §3.3) froze a tree containing ops that have
959
+ // not landed on the base line. Advancing a protected head to it would publish
960
+ // unlanded work under a verified head, so it is refused outright.
961
+ if (cp.workspace) {
962
+ return { finalized: false, reason: `checkpoint ${args.newCheckpoint.slice(0, 16)} is scoped to workspace ${cp.workspace} — land it first (\`avcs workspace land ${cp.workspace}\`)` };
963
+ }
949
964
  const waived = await this.#activeWaivers(args.view);
950
965
  for (const k of prot?.requiredChecks ?? []) {
951
966
  if (waived.has(k))
@@ -1754,6 +1769,19 @@ export class Repo {
1754
1769
  async landedWorkspaces() {
1755
1770
  return [...(await this.#landedWorkspaces())].sort();
1756
1771
  }
1772
+ /**
1773
+ * Every workspace that actually carries operations. A workspace is not a stored object —
1774
+ * it exists exactly as a tag on ops — so this is the only way to ask whether a NAME names
1775
+ * anything. The `post-merge` land seam uses it as a guard: landing is append-only and
1776
+ * irreversible, so a name it cannot corroborate is not landed (docs/20 §3.4, R1).
1777
+ */
1778
+ async workspaceNames() {
1779
+ const names = new Set();
1780
+ for (const op of await this.#allOpsTailed())
1781
+ if (op.workspace)
1782
+ names.add(op.workspace);
1783
+ return [...names].sort();
1784
+ }
1757
1785
  /**
1758
1786
  * Land a workspace onto its base line (docs/16): its ops join the base view and merge
1759
1787
  * there. There is no "rebase" — reduce always 3-way-merges the full op set, and any
@@ -1790,17 +1818,22 @@ export class Repo {
1790
1818
  const allOps = await this.#allOpsTailed();
1791
1819
  const inherited = await this.#inheritedOps(lineName, allOps);
1792
1820
  const wsName = opts?.workspace;
1793
- // A base view also includes ops from workspaces that have LANDED (promoted onto base).
1794
- const landed = wsName ? null : await this.#landedWorkspaces();
1821
+ // Landing makes a workspace's ops BASE-ACCEPTED (docs/16 §4.3), so the landed set is
1822
+ // read for EVERY view, not only base ones. A workspace view that ignored it would keep
1823
+ // a sibling's already-landed work invisible and rediscover the conflict at land time
1824
+ // (docs/20 §1.3).
1825
+ const landed = await this.#landedWorkspaces();
1795
1826
  const ops = [];
1796
1827
  for (const op of allOps) {
1797
1828
  const onLine = (op.line ?? "main") === lineName || inherited.has(op.oid);
1798
1829
  if (!onLine)
1799
1830
  continue;
1800
- // Workspace isolation (docs/16): a base view excludes workspace-tagged ops UNLESS
1801
- // that workspace has landed; a workspace view (wsName=W) sees base ops + W's own.
1831
+ // Workspace isolation (docs/16): a view excludes a workspace-tagged op unless that
1832
+ // workspace has landed, or it is this view's OWN workspace. With `wsName` undefined
1833
+ // the middle clause is vacuously true, so a BASE view's op set is exactly what it was
1834
+ // — exclude tagged-and-unlanded, keep everything else.
1802
1835
  const opWs = op.workspace;
1803
- if (wsName ? (!!opWs && opWs !== wsName) : (!!opWs && !landed.has(opWs)))
1836
+ if (opWs && opWs !== wsName && !landed.has(opWs))
1804
1837
  continue;
1805
1838
  if (exclude.has(op.oid))
1806
1839
  continue;
@@ -1976,6 +2009,15 @@ export class Repo {
1976
2009
  // repeats). A clone is returned so callers can mutate without corrupting the cache.
1977
2010
  #reduceCache = new Map();
1978
2011
  static REDUCE_CACHE_MAX = 64;
2012
+ /**
2013
+ * Minimum line similarity for the capture path to call a removed × added pair a MOVE
2014
+ * rather than an unrelated delete + create (docs/19 §3.1, and §6 R3 asks for exactly one
2015
+ * place to tune it). 0.5 is git's `-M` default, so a tree avcs captures and the same tree
2016
+ * `git diff -M` describes agree about what moved. Raising it makes capture more
2017
+ * conservative (more moves recorded as delete + create, which is the pre-Stage-0
2018
+ * behaviour); lowering it risks attaching a wrong merge base, which is worse than none.
2019
+ */
2020
+ static RENAME_SIMILARITY = 0.5;
1979
2021
  #cloneResult(r) {
1980
2022
  return {
1981
2023
  tree: new Map(r.tree),
@@ -2208,6 +2250,102 @@ export class Repo {
2208
2250
  return false;
2209
2251
  return Buffer.from(buf.toString("utf8"), "utf8").equals(buf);
2210
2252
  }
2253
+ /**
2254
+ * Pair `removed` paths against `added` ones to recover MOVES from a path-set diff
2255
+ * (docs/19 §3.1, Stage 0).
2256
+ *
2257
+ * `commitWorkingTree` compares path sets, so a move arrives as a removal plus an
2258
+ * unrelated-looking addition — which the reducer then sees as a delete racing an edit,
2259
+ * plus a create with no merge base. Recovering the move here is what makes Stage 1's
2260
+ * commutativity reachable from actual usage.
2261
+ *
2262
+ * Two tiers, both deterministic and language-blind:
2263
+ *
2264
+ * 1. EXACT content match. Content is hashed, so this is an O(n) bucket join and a pure
2265
+ * relocation — the common case, and the whole of the binary case — never pays for a
2266
+ * line diff at all.
2267
+ * 2. LINE SIMILARITY `2·LCS / (linesP + linesQ)` over the leftovers, using merge3's own
2268
+ * LCS. Binary content (a NUL byte) is excluded: a line diff over bytes is noise, so
2269
+ * binary is exact-match-only.
2270
+ *
2271
+ * A pair is only accepted when it is UNAMBIGUOUS in both directions — one candidate for
2272
+ * the source and one for the destination. Anything else stays a delete + create rather
2273
+ * than a guess: naming the wrong file as "the same file" invents history, and a wrong
2274
+ * merge base is worse than no merge base. That is also why there is no tie-breaking to
2275
+ * get wrong; ambiguity is not resolved, it is declined.
2276
+ */
2277
+ #detectRenames(removed, added, before, after) {
2278
+ if (!removed.length || !added.length)
2279
+ return [];
2280
+ const sources = [...removed].sort();
2281
+ const destinations = [...added].sort();
2282
+ /** Accept only pairs that are the single candidate on BOTH sides. */
2283
+ const unambiguous = (links) => {
2284
+ const inverse = new Map();
2285
+ for (const [from, tos] of links)
2286
+ for (const to of tos)
2287
+ (inverse.get(to) ?? inverse.set(to, new Set()).get(to)).add(from);
2288
+ const out = [];
2289
+ for (const from of [...links.keys()].sort()) {
2290
+ const tos = links.get(from);
2291
+ if (tos.size !== 1)
2292
+ continue;
2293
+ const to = [...tos][0];
2294
+ if (inverse.get(to).size !== 1)
2295
+ continue;
2296
+ out.push({ from, to });
2297
+ }
2298
+ return out;
2299
+ };
2300
+ // ── Tier 1: exact content ──
2301
+ const byContent = new Map();
2302
+ for (const to of destinations) {
2303
+ const h = sha256hex(after.get(to));
2304
+ (byContent.get(h) ?? byContent.set(h, []).get(h)).push(to);
2305
+ }
2306
+ const exact = new Map();
2307
+ for (const from of sources) {
2308
+ const hits = byContent.get(sha256hex(before.get(from)));
2309
+ if (hits)
2310
+ exact.set(from, new Set(hits));
2311
+ }
2312
+ const pairs = unambiguous(exact);
2313
+ // ── Tier 2: line similarity over what tier 1 left ──
2314
+ const takenFrom = new Set(pairs.map((p) => p.from));
2315
+ const takenTo = new Set(pairs.map((p) => p.to));
2316
+ const similar = new Map();
2317
+ const linesCache = new Map();
2318
+ const linesOf = (path, buf) => {
2319
+ if (!linesCache.has(path))
2320
+ linesCache.set(path, isBinary(buf) ? null : buf.toString("utf8").split("\n"));
2321
+ return linesCache.get(path);
2322
+ };
2323
+ for (const from of sources) {
2324
+ if (takenFrom.has(from))
2325
+ continue;
2326
+ const fromLines = linesOf(`-${from}`, before.get(from));
2327
+ if (!fromLines)
2328
+ continue; // binary: exact match only (rule 4)
2329
+ for (const to of destinations) {
2330
+ if (takenTo.has(to))
2331
+ continue;
2332
+ const toLines = linesOf(`+${to}`, after.get(to));
2333
+ if (!toLines)
2334
+ continue;
2335
+ const n = fromLines.length;
2336
+ const m = toLines.length;
2337
+ // Sound pre-filter (docs/19 R4): LCS ≤ min(n, m), so this is an upper bound on the
2338
+ // similarity and can never skip a pair that would have passed. It keeps a large
2339
+ // relocation from running an O(n·m) line DP against every unrelated candidate.
2340
+ if ((2 * Math.min(n, m)) / (n + m) < _a.RENAME_SIMILARITY)
2341
+ continue;
2342
+ if ((2 * lcsLineLength(fromLines, toLines)) / (n + m) < _a.RENAME_SIMILARITY)
2343
+ continue;
2344
+ (similar.get(from) ?? similar.set(from, new Set()).get(from)).add(to);
2345
+ }
2346
+ }
2347
+ return [...pairs, ...unambiguous(similar)].sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
2348
+ }
2211
2349
  /**
2212
2350
  * Commit a working tree: diff `workDir`'s files against the materialized view and
2213
2351
  * author edit_file / put_file / delete_file ops for the changes (the git `add`+`commit`
@@ -2220,19 +2358,35 @@ export class Repo {
2220
2358
  * ADDED files keep `put_file` (a create genuinely has no base), and so does any content that
2221
2359
  * is not losslessly UTF-8 text (see `#isMergeableText`).
2222
2360
  *
2223
- * Capture also runs the early conflict warning with CROSS-LINE visibility: the git bridge
2224
- * maps each branch to its own line, so a competing session is on a *different* line and a
2225
- * line-scoped check would never see it. The warnings are returned (`contention`) so the CLI
2226
- * can put them in front of the human running `git commit`.
2361
+ * A MOVED file is recovered from the removed × added pair (`#detectRenames`, docs/19 §3.1)
2362
+ * and captured as `rename_file` plus an `edit_file` at the NEW path, based on the content
2363
+ * from BEFORE the move, when it was edited on the way. Without this the reducer's whole
2364
+ * rename × edit commutativity is unreachable from real usage: a path-set diff turns every
2365
+ * move into a delete racing an edit and a create with nothing to merge against. Recovered
2366
+ * moves are reported in `renamed` and are NOT double-counted in `added`/`removed`.
2367
+ *
2368
+ * `workspace` scopes the capture to a converging workspace (docs/20 §3.3) — the git bridge
2369
+ * maps a topic branch to one. It has to reach BOTH ends: the authored ops carry the tag, and
2370
+ * the projection this diffs against is the WORKSPACE's view. Diffing disk against the base
2371
+ * view instead would re-capture the workspace's own earlier edits as brand-new changes on
2372
+ * every commit, and — since a move is recovered from a removal paired with an addition —
2373
+ * those stale removals could pair into renames that never happened.
2374
+ *
2375
+ * Capture also runs the early conflict warning with CROSS-LINE visibility: a competing
2376
+ * session may be on a different line, and a line-scoped check would never see it. The
2377
+ * warnings are returned (`contention`) so the CLI can put them in front of the human
2378
+ * running `git commit`.
2227
2379
  */
2228
2380
  async commitWorkingTree(workDir, opts) {
2229
2381
  const view = opts.line ?? "main";
2230
- const res = await this.materialize(view);
2382
+ const ws = opts.workspace ? { workspace: opts.workspace } : {};
2383
+ // The base to diff disk against is THIS scope's projection, workspace included.
2384
+ const res = await this.materialize(view, ws);
2231
2385
  const current = new Map((await this.materializedBytes(res)).map((f) => [f.path, f.bytes]));
2232
2386
  const disk = await this.#readWorkTree(workDir, opts.ignorePredicate);
2233
- const added = [];
2387
+ let added = [];
2234
2388
  const modified = [];
2235
- const removed = [];
2389
+ let removed = [];
2236
2390
  for (const [path, content] of disk) {
2237
2391
  if (!current.has(path))
2238
2392
  added.push(path);
@@ -2242,10 +2396,19 @@ export class Repo {
2242
2396
  for (const path of current.keys())
2243
2397
  if (!disk.has(path))
2244
2398
  removed.push(path);
2399
+ // Recover moves before deciding op kinds: a paired path is a move, not an unrelated
2400
+ // delete + create, and must not be counted as both.
2401
+ const renamed = this.#detectRenames(removed, added, current, disk);
2402
+ if (renamed.length) {
2403
+ const movedFrom = new Set(renamed.map((r) => r.from));
2404
+ const movedTo = new Set(renamed.map((r) => r.to));
2405
+ removed = removed.filter((p) => !movedFrom.has(p));
2406
+ added = added.filter((p) => !movedTo.has(p));
2407
+ }
2245
2408
  const ops = [];
2246
2409
  const contention = [];
2247
- if (!added.length && !modified.length && !removed.length)
2248
- return { ops, added, modified, removed, intent: "", contention };
2410
+ if (!added.length && !modified.length && !removed.length && !renamed.length)
2411
+ return { ops, added, modified, removed, renamed, intent: "", contention };
2249
2412
  const intent = await this.createIntent({ title: opts.message, owner: opts.actor.id });
2250
2413
  const sess = await this.startSession({ intentOid: intent, actor: opts.actor });
2251
2414
  const deps = res.headOps;
@@ -2260,21 +2423,45 @@ export class Repo {
2260
2423
  }
2261
2424
  };
2262
2425
  const warn = { warnContention: true, contentionAcrossLines: true, onContention: collect };
2426
+ // Moves first, sorted by source. The paired `edit_file` must causally FOLLOW its own
2427
+ // rename: it names the destination path, so if the two were concurrent the reducer would
2428
+ // read them as a move and an unrelated edit fighting over that path (docs/19 §3.2 leaves
2429
+ // rename-vs-destination a genuine contest, and rightly so). Depending on the rename also
2430
+ // states the truth — the author moved the file, then wrote to where it now lives.
2431
+ for (const { from, to } of renamed) {
2432
+ const rn = await this.proposeOperation({
2433
+ sessionOid: sess, intentOid: intent, actor: opts.actor,
2434
+ target: { entityKind: "file", entityId: from },
2435
+ body: { kind: "rename_file", fromPath: from, path: to },
2436
+ declaredPurpose: `move ${from} → ${to}`, causalDeps: deps, line: opts.line, ...ws, ...warn,
2437
+ });
2438
+ ops.push(rn);
2439
+ const base = current.get(from);
2440
+ const content = disk.get(to);
2441
+ if (base.equals(content))
2442
+ continue; // a pure move needs no second op
2443
+ // Content changed on the way. Only mergeable text can say so as an edit; otherwise the
2444
+ // move stands and the new bytes go in byte-exact as a `put_file` at the new path.
2445
+ const common = { sessionOid: sess, intentOid: intent, actor: opts.actor, path: to, declaredPurpose: opts.message, causalDeps: [...deps, rn], line: opts.line, ...ws, ...warn };
2446
+ ops.push(this.#isMergeableText(base) && this.#isMergeableText(content)
2447
+ ? await this.proposeEdit({ ...common, newText: content.toString("utf8"), baseBlobOid: await this.putBlob(base) })
2448
+ : await this.proposeFileWrite({ ...common, content }));
2449
+ }
2263
2450
  const isModified = new Set(modified);
2264
2451
  // One sorted pass over both categories keeps op authoring order (and therefore lamport
2265
2452
  // assignment) exactly as before; only the op KIND differs per category.
2266
2453
  for (const path of [...added, ...modified].sort()) {
2267
2454
  const content = disk.get(path);
2268
2455
  const base = isModified.has(path) ? current.get(path) : undefined;
2269
- const common = { sessionOid: sess, intentOid: intent, actor: opts.actor, path, declaredPurpose: opts.message, causalDeps: deps, line: opts.line, ...warn };
2456
+ const common = { sessionOid: sess, intentOid: intent, actor: opts.actor, path, declaredPurpose: opts.message, causalDeps: deps, line: opts.line, ...ws, ...warn };
2270
2457
  ops.push(base !== undefined && this.#isMergeableText(base) && this.#isMergeableText(content)
2271
2458
  ? await this.proposeEdit({ ...common, newText: content.toString("utf8"), baseBlobOid: await this.putBlob(base) })
2272
2459
  : await this.proposeFileWrite({ ...common, content }));
2273
2460
  }
2274
2461
  for (const path of removed.sort()) {
2275
- ops.push(await this.proposeOperation({ sessionOid: sess, intentOid: intent, actor: opts.actor, target: { entityKind: "file", entityId: path }, body: { kind: "delete_file", path }, declaredPurpose: `delete ${path}`, causalDeps: deps, line: opts.line, ...warn }));
2462
+ ops.push(await this.proposeOperation({ sessionOid: sess, intentOid: intent, actor: opts.actor, target: { entityKind: "file", entityId: path }, body: { kind: "delete_file", path }, declaredPurpose: `delete ${path}`, causalDeps: deps, line: opts.line, ...ws, ...warn }));
2276
2463
  }
2277
- return { ops, added: added.sort(), modified: modified.sort(), removed: removed.sort(), intent, contention };
2464
+ return { ops, added: added.sort(), modified: modified.sort(), removed: removed.sort(), renamed, intent, contention };
2278
2465
  }
2279
2466
  // ── git bridge (docs/14) ───────────────────────────────────────────────────
2280
2467
  /** Read `.avcs/config.json` (a torn/absent file is treated as empty). */
@@ -2305,6 +2492,32 @@ export class Repo {
2305
2492
  await this.#writeGitignore(mode);
2306
2493
  this.logger.info("git.mode", { mode });
2307
2494
  }
2495
+ /**
2496
+ * The git branch that carries the base view (docs/20 §3.1). The core stays git-agnostic:
2497
+ * this is a recorded NAME, and only the bridge ever compares it against a real branch.
2498
+ * Unset ⇒ `main`, which is what the bridge assumed before trunk existed.
2499
+ */
2500
+ async getTrunk() {
2501
+ const t = (await this.#readConfig()).trunk;
2502
+ return typeof t === "string" && t.length ? t : DEFAULT_TRUNK;
2503
+ }
2504
+ /**
2505
+ * Every branch name that counts as trunk. With `trunk` configured it is the single
2506
+ * answer; with nothing configured BOTH `main` and `master` are trunk — exactly the pair
2507
+ * the pre-trunk bridge special-cased, so an unconfigured repository (a `master`-default
2508
+ * one included) keeps behaving as it always did (docs/20 W7).
2509
+ */
2510
+ async trunkBranches() {
2511
+ const t = (await this.#readConfig()).trunk;
2512
+ return typeof t === "string" && t.length ? [t] : [...LEGACY_TRUNK_BRANCHES];
2513
+ }
2514
+ /** Record the trunk branch. Shares `config.json` with the git mode, so read-modify-write. */
2515
+ async setTrunk(branch) {
2516
+ const cfg = await this.#readConfig();
2517
+ cfg.trunk = branch;
2518
+ await this.store.writeAux("config.json", JSON.stringify(cfg, null, 2) + "\n");
2519
+ this.logger.info("git.trunk", { trunk: branch });
2520
+ }
2308
2521
  /**
2309
2522
  * Build the commit-message trailer block that links a git commit to its AVCS provenance
2310
2523
  * (the git→avcs half). A reader with the `.avcs/` history can resolve the checkpoint;
@@ -2427,20 +2640,25 @@ export class Repo {
2427
2640
  const workDir = opts.workDir ?? this.dir;
2428
2641
  const view = opts.line ?? "main";
2429
2642
  const lineOpt = opts.line ? { line: opts.line } : {};
2643
+ // A workspace scope (docs/20 §3.3) has to travel the WHOLE round trip: the capture tags
2644
+ // its ops and diffs against the workspace's projection, the conflict gate reads the same
2645
+ // view, and the re-projection writes it back. Any one of them left on base would make
2646
+ // this working tree oscillate between two different trees.
2647
+ const wsOpt = opts.workspace ? { workspace: opts.workspace } : undefined;
2430
2648
  // 1. Capture direct working-tree edits as ops before anything else.
2431
- const cap = await this.commitWorkingTree(workDir, { message: opts.message, actor: opts.actor, ...lineOpt, ...(opts.ignorePredicate ? { ignorePredicate: opts.ignorePredicate } : {}) });
2432
- const captured = { ops: cap.ops, added: cap.added, modified: cap.modified, removed: cap.removed, intent: cap.intent };
2649
+ const cap = await this.commitWorkingTree(workDir, { message: opts.message, actor: opts.actor, ...lineOpt, ...(wsOpt ?? {}), ...(opts.ignorePredicate ? { ignorePredicate: opts.ignorePredicate } : {}) });
2650
+ const captured = { ops: cap.ops, added: cap.added, modified: cap.modified, removed: cap.removed, renamed: cap.renamed, intent: cap.intent };
2433
2651
  // Ensure the gitignore reflects the current mode (pre-existing repos never wrote one).
2434
2652
  const mode = await this.getGitMode();
2435
2653
  await this.#writeGitignore(mode);
2436
2654
  // 2. Conflict gate.
2437
- const res = await this.materialize(view);
2655
+ const res = await this.materialize(view, wsOpt);
2438
2656
  if (res.conflicts.length > 0)
2439
2657
  return { mode, captured, contention: cap.contention, conflicts: res.conflicts };
2440
2658
  // 3. Checkpoint the verified state. 4. Re-project the working tree.
2441
- const checkpoint = await this.createCheckpoint(view, opts.message);
2442
- const written = await this.checkoutInto(workDir, view);
2443
- this.logger.info("git.sync", { view, mode, capturedOps: captured.ops.length, checkpoint, treeHash: res.treeHash });
2659
+ const checkpoint = await this.createCheckpoint(view, opts.message, wsOpt);
2660
+ const written = await this.checkoutInto(workDir, view, wsOpt);
2661
+ this.logger.info("git.sync", { view, mode, workspace: opts.workspace, capturedOps: captured.ops.length, checkpoint, treeHash: res.treeHash });
2444
2662
  return { mode, captured, contention: cap.contention, conflicts: [], checkpoint, treeHash: res.treeHash, reprojected: written.length };
2445
2663
  }
2446
2664
  // ── backup / transfer (docs/10 WS-F) ──────────────────────────────────────
@@ -2839,9 +3057,16 @@ export class Repo {
2839
3057
  await writeFile(full, synth ?? await this.readBlob(blobOid));
2840
3058
  }
2841
3059
  }
2842
- async createCheckpoint(viewName, summary) {
3060
+ /**
3061
+ * Freeze a view's verified state. `workspace` freezes that WORKSPACE's projection instead
3062
+ * of the bare base view (docs/20 §3.3): a commit on a topic branch contains the workspace's
3063
+ * tree, so a checkpoint of the base view would describe a tree git does not hold and
3064
+ * `avcs verify-git` would report every such commit as a mismatch. The scope is recorded on
3065
+ * the checkpoint so it can never be mistaken for a base-view one (`finalize` refuses it).
3066
+ */
3067
+ async createCheckpoint(viewName, summary, opts) {
2843
3068
  const view = await this.getView(viewName);
2844
- const result = await this.materialize(viewName);
3069
+ const result = await this.materialize(viewName, opts?.workspace ? { workspace: opts.workspace } : undefined);
2845
3070
  const evidence = {};
2846
3071
  const evidenceBinding = {};
2847
3072
  // Deterministic aggregation: process evidence in canonical (createdAt, oid) order
@@ -2878,12 +3103,15 @@ export class Repo {
2878
3103
  // Only present when some evidence aggregated — an evidence-less checkpoint's
2879
3104
  // bytes (and oid) are identical to pre-13.4.
2880
3105
  ...(Object.keys(evidenceBinding).length ? { evidenceBinding } : {}),
3106
+ ...(opts?.workspace ? { workspace: opts.workspace } : {}),
2881
3107
  status: result.conflicts.length === 0 ? "verified" : "draft",
2882
3108
  summary,
2883
3109
  createdAt: new Date().toISOString(),
2884
3110
  };
2885
3111
  const oid = await this.store.put(cp);
2886
- await this.store.setRef(`checkpoint:${viewName}:latest`, oid);
3112
+ // `checkpoint:<view>:latest` names the view's own latest state; a workspace checkpoint is
3113
+ // a different tree, so it gets its own ref rather than displacing the base view's.
3114
+ await this.store.setRef(opts?.workspace ? `checkpoint:${viewName}:workspace:${opts.workspace}:latest` : `checkpoint:${viewName}:latest`, oid);
2887
3115
  return oid;
2888
3116
  }
2889
3117
  /** Resolve the materialized tree into {path, bytes} entries (byte-preserving). */