akm-cli 0.10.261007-beta → 0.10.261009-beta
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/CHANGELOG.md +72 -0
- package/STABILITY.md +10 -4
- package/dist/api.d.ts +6 -0
- package/dist/api.js +12 -1
- package/dist/assets/prompts/consolidate-pair-check.md +2 -0
- package/dist/assets/prompts/consolidate-pair-same-age.md +1 -0
- package/dist/assets/prompts/consolidate-pair-terms.md +6 -0
- package/dist/assets/prompts/consolidate-pair.md +4 -12
- package/dist/commands/health.js +1 -2
- package/dist/commands/improve/consolidate/pair-pass.js +59 -31
- package/dist/commands/improve/extract.js +18 -12
- package/dist/commands/improve/memory/memory-belief.js +2 -2
- package/dist/commands/proposal/repository.js +44 -13
- package/dist/commands/read/curate.js +77 -18
- package/dist/commands/read/search-cli.js +25 -11
- package/dist/commands/read/search.js +3 -3
- package/dist/commands/read/show.js +47 -1
- package/dist/core/adapter/adapters/akm-adapter.js +1 -0
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
- package/dist/core/adapter/adapters/okf-adapter.js +3 -16
- package/dist/core/asset/markdown-link-targets.js +43 -0
- package/dist/core/common.js +17 -2
- package/dist/core/config/config-io.js +14 -4
- package/dist/indexer/passes/metadata.js +17 -0
- package/dist/indexer/scan/doc-to-entry.js +1 -0
- package/dist/indexer/search/db-search.js +16 -7
- package/dist/indexer/search/search-hit-enrichers.js +3 -3
- package/dist/output/shapes/curate.js +3 -1
- package/dist/output/shapes/helpers.js +6 -0
- package/dist/output/text/show-format.js +7 -0
- package/dist/scripts/akm-migrate-node.js +1824 -324
- package/dist/scripts/akm-migrate.js +1824 -324
- package/dist/storage/repositories/index-entries-repository.js +16 -0
- package/dist/storage/repositories/index-fts-repository.js +28 -6
- package/dist/storage/repositories/index-vec-repository.js +19 -3
- package/docs/architecture/0.10-process-rules.md +4 -4
- package/docs/architecture/architecture.md +4 -0
- package/docs/architecture/persisted-data-compat.md +1 -1
- package/docs/reference/api.md +19 -1
- package/docs/reference/cli.md +56 -4
- package/docs/reference/data-and-telemetry.md +1 -1
- package/docs/reference/memory.md +7 -6
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,78 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.261009] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Search and curate default to `--belief current`, retaining unflagged reference
|
|
14
|
+
knowledge and active/asserted assets while excluding historical beliefs (#1199).
|
|
15
|
+
Use `--belief all` for broad investigation or `--belief historical` for old
|
|
16
|
+
evidence. Curate accepts search's repeatable `--filter` scope controls, and the
|
|
17
|
+
public `akm-cli/api` options add `source`, normalized `filters`, and `belief`.
|
|
18
|
+
The same eligibility follows derived surfaces, linked support and packed
|
|
19
|
+
content; historical results are labelled. Belief labels do not establish factual
|
|
20
|
+
truth. No migration or reindex is required; direct show remains available. The default
|
|
21
|
+
also applies to `akm ui` search, `akm remember --show-similar` and the related assets
|
|
22
|
+
distill reads.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Search applies eligibility filters (scope, source, type, belief state, proposed quality)
|
|
27
|
+
before each channel's candidate limit instead of after it (#1198). An eligible asset ranked
|
|
28
|
+
below many ineligible ones in the keyword or semantic channel is no longer dropped before
|
|
29
|
+
filtering. The vector channel checks eligibility in similarity order, so unfiltered searches
|
|
30
|
+
load only the top candidates. No index migration or reindex is needed.
|
|
31
|
+
|
|
32
|
+
- Extraction proposals retain captured `evidence`, optional guidance and known session sources
|
|
33
|
+
in the Markdown actually published by acceptance (#1196). Reindexing and full/agent `show` preserve
|
|
34
|
+
the support; compact discovery continues to provide refs for follow-up. Earlier pending
|
|
35
|
+
proposals require explicit review/repair of their separate evidence payload; existing accepted
|
|
36
|
+
notes are not backfilled. Run `akm index --full` to project evidence already present in old files.
|
|
37
|
+
- Invalid health comparison windows are rejected before runtime probes can prepend an unrelated
|
|
38
|
+
installed-agent advisory to the JSON usage error.
|
|
39
|
+
|
|
40
|
+
- OKF and LLM Wiki indexing no longer records relationships from links inside code spans,
|
|
41
|
+
fenced or indented code, including code nested in lists and blockquotes (#1200). Links are
|
|
42
|
+
read with the `marked` tokenizer (new runtime dependency) instead of raw regex scans; the
|
|
43
|
+
indexed link syntax is otherwise unchanged. Run `akm index --full` once to drop false
|
|
44
|
+
relationships stored by earlier builds; incremental indexing keeps unchanged files' old links.
|
|
45
|
+
|
|
46
|
+
- Consolidation preserves the existing `scope_user`, `scope_agent`, `scope_run`, and
|
|
47
|
+
`scope_channel` tuple (#1197). Pairing requires exact normalized equality before
|
|
48
|
+
consuming an eligible neighbor slot. Retirement acceptance, including resume, checks
|
|
49
|
+
live scopes against the judged tuple; promotion refuses incompatible source/output
|
|
50
|
+
scopes and preserves differently scoped derived twins. Older retirements without a
|
|
51
|
+
recorded judged tuple remain readable but require rejection/reassessment before
|
|
52
|
+
acceptance. Staged stale decisions use the existing stale-target rejection/retry
|
|
53
|
+
path. No database migration is needed; review pending work before unattended drain.
|
|
54
|
+
|
|
55
|
+
- Consolidation assesses complete note bodies in both retirement checks instead of silently
|
|
56
|
+
cutting each at 12,000 characters. Requests exceeding the selected engine's context window fail conservatively;
|
|
57
|
+
failed second checks leave proposals pending and unstaged. Before unattended proposal drain,
|
|
58
|
+
review and reject/reassess pending or staged retirements produced by older builds when either
|
|
59
|
+
body exceeded the former cutoff. An upgrade does not revalidate those decisions. No storage
|
|
60
|
+
migration is needed; accepted retirements remain recoverable with `akm proposal revert <id>`.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- The consolidation pair judge reports claim differences; AKM derives `duplicate`, `subsumed`,
|
|
65
|
+
or `overlap` and the redundant side from those lists. It no longer asks the model to repeat
|
|
66
|
+
that decision in relation/pointer fields. Replacement retirements use `subsumed`; historical
|
|
67
|
+
queued labels remain readable. Lexical attention hints accompany complete bodies and never
|
|
68
|
+
substitute for assessing their meaning.
|
|
69
|
+
|
|
70
|
+
## [0.10.261008] - 2026-10-20
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **`akm remember`, config saves and other writes work on filesystems that refuse chmod.** On a CIFS/SMB mount
|
|
75
|
+
such as an Azure Files share, chmod fails with `EPERM`, and akm treated that as fatal: every atomic write
|
|
76
|
+
(`akm remember`, `akm setup`, `akm env`, `akm secret`) and every config backup failed. akm now keeps the mode the
|
|
77
|
+
file was created with when the filesystem refuses chmod (`EPERM`, `ENOTSUP`, `EINVAL`); the mount options decide
|
|
78
|
+
it there, so `env/`, `secrets/` and config backups are not guaranteed owner-only on such a share. Any other chmod
|
|
79
|
+
error still fails the write.
|
|
80
|
+
|
|
9
81
|
## [0.10.261007] - 2026-10-20
|
|
10
82
|
|
|
11
83
|
### Added
|
package/STABILITY.md
CHANGED
|
@@ -471,10 +471,16 @@ lane (#1143): the `default` strategy runs it without the opt-in, bounded by
|
|
|
471
471
|
`akm proposal drain --promote`. Only a promote that turns the judgment tier on
|
|
472
472
|
needs `experimental.improveAutonomy`.
|
|
473
473
|
|
|
474
|
-
Consolidation remains enabled with autonomy off
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
474
|
+
Consolidation remains enabled with autonomy off. Both passes emit proposals;
|
|
475
|
+
the pair pass stages a retirement when its claim checks pass and there is no
|
|
476
|
+
continuity risk. `triage` with `applyMode: "promote"` can accept that staged
|
|
477
|
+
retirement unattended; an unstaged retirement stays pending. Both retirement
|
|
478
|
+
checks receive complete note bodies with lexical attention hints, so the selected
|
|
479
|
+
engine must accommodate them. The pair judge supplies distinct-claim lists; AKM
|
|
480
|
+
derives duplicate/subsumed/overlap outcomes and retirement direction from those
|
|
481
|
+
lists. Historical queued retirement labels remain readable. Before unattended drain after an upgrade, review pending/staged
|
|
482
|
+
retirements produced by older builds from notes over 12,000 body characters:
|
|
483
|
+
upgrading does not revalidate their evidence (#1195).
|
|
478
484
|
|
|
479
485
|
Because the gate is applied before the LLM preflight, a review-first workspace
|
|
480
486
|
also needs fewer engines configured: a strategy whose only model-backed process
|
package/dist/api.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export interface CurateOptions {
|
|
|
10
10
|
limit?: number;
|
|
11
11
|
/** Asset type filter, same as `--type`. */
|
|
12
12
|
type?: string;
|
|
13
|
+
/** Named bundle or local|registry|all, same as --from. */
|
|
14
|
+
source?: string;
|
|
15
|
+
/** AND-joined exact scope values, same as repeated --filter. */
|
|
16
|
+
filters?: { user?: string; agent?: string; run?: string; channel?: string };
|
|
17
|
+
/** Default current; labels do not establish factual truth. */
|
|
18
|
+
belief?: "all" | "current" | "historical";
|
|
13
19
|
/** Output format, same as `--format`. Default `"json"`, as in the CLI. */
|
|
14
20
|
format?: "text" | "json";
|
|
15
21
|
}
|
package/dist/api.js
CHANGED
|
@@ -35,7 +35,18 @@ export async function curate(query, options = {}) {
|
|
|
35
35
|
const mode = resolveOutputMode(["--detail", "agent", "--format", format], loadConfig().output ?? {});
|
|
36
36
|
const disposeLlmUsageSink = installLlmUsagePersistenceIfAbsent();
|
|
37
37
|
try {
|
|
38
|
-
const curated = await runCurate({
|
|
38
|
+
const curated = await runCurate({
|
|
39
|
+
query,
|
|
40
|
+
type: options.type,
|
|
41
|
+
limit: options.limit === undefined ? undefined : String(options.limit),
|
|
42
|
+
from: options.source,
|
|
43
|
+
belief: options.belief,
|
|
44
|
+
filters: options.filters === undefined
|
|
45
|
+
? undefined
|
|
46
|
+
: Object.entries(options.filters)
|
|
47
|
+
.filter(([, value]) => value !== undefined)
|
|
48
|
+
.map(([key, value]) => `${key}=${value}`),
|
|
49
|
+
}, mode);
|
|
39
50
|
return stdoutText(renderOutput("curate", curated, { ...mode, format }));
|
|
40
51
|
}
|
|
41
52
|
finally {
|
|
@@ -2,4 +2,6 @@ You check whether deleting note X from one person's agent-memory library would l
|
|
|
2
2
|
|
|
3
3
|
A durable claim is a fact, decision, value, command, flag, path, file name, URL, number, version, name, error message, condition or rule someone would act on. Ignore wording, headings, dates of writing, tags and boilerplate. A claim is stated when Y says it in any wording or format, or replaces it with a newer value. It is not stated when Y is vaguer (a config file instead of the specific path) or drops a number, command, step or condition. Read X's description as part of X. Write each item as a short phrase that quotes its identifier. Use [] when Y states everything.
|
|
4
4
|
|
|
5
|
+
Check the complete X, including its ending, one claim at a time against Y. A missing claim must actually be stated by X and absent from Y in meaning, not just in wording. Extra detail stated only by Y does not belong in missing. Repetition adds no claim. Before answering, remove any item that Y already states or replaces for the same named thing.
|
|
6
|
+
|
|
5
7
|
Answer ONLY with JSON: {"missing": ["..."]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
The two notes have the same creation date: neither is newer, so the chronological replacement rule does not apply. Different values for the same named thing conflict unless the text explicitly says one replaces the other. Treat an unresolved conflicting value as a distinct claim. A note's position in this prompt does not establish which value is current.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Lexical attention hints from the complete notes above:
|
|
2
|
+
{{DIFFERENCES}}
|
|
3
|
+
|
|
4
|
+
Check these terms in their original context. They are not claim lists: different tokens can express the same claim or a replaced value, and equal token sets do not prove equal claims. Assess the complete notes, including claims that these hints do not distinguish.
|
|
5
|
+
|
|
6
|
+
Before leaving a side's claim list empty, check the source sentence of every exclusive term. Commands, flags, paths, URLs, numbers, versions and conditions must keep their specific meaning: a shared generic word does not preserve an omitted command or rule. Verify that every claim you list is absent from the other complete note in meaning.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
You compare two assets from one person's agent-memory library
|
|
1
|
+
You compare the complete claims of two assets from one person's agent-memory library. Use the supplied dates: when both dates are equal, neither note is newer. A note can be deleted only when the other preserves every claim it states.
|
|
2
2
|
|
|
3
|
-
First
|
|
3
|
+
Compare the complete meaning of both assets before answering. Claims are equivalent when they give a reader the same facts, conditions and action, even with different wording. For example, "the build runs `make test`" and "`make test` is the build test command" are the same claim; `make test-fast` is a different command. First give a concise account of what distinguishes the claims of the two notes, including whether a value is explicitly replaced. There may be no safe deletion. Then list only claims that the other asset genuinely lacks:
|
|
4
4
|
- "onlyInA": every durable claim of A that B does not state or replace.
|
|
5
5
|
- "onlyInB": every durable claim of B that A does not state or replace.
|
|
6
6
|
|
|
@@ -8,14 +8,6 @@ A durable claim is a fact, decision, value, command, flag, path, file name, URL,
|
|
|
8
8
|
|
|
9
9
|
B replaces a claim of A only when both are about the same named thing (the same service, integration, partner, component, region or file) and B gives a new value for it that is current: B says so (now, instead of, since, renamed, corrected, no longer) or B is created later than A. Notes about different named things never replace each other, whatever their dates: list each one's own name, value and path as its claims. A different value for the same thing with neither sign is a conflict, not a replacement: list A's value in "onlyInA" and B's value in "onlyInB".
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- "duplicate": both lists are empty.
|
|
13
|
-
- "subsumed": exactly one list is empty. The asset with the empty list is redundant.
|
|
14
|
-
- "supersedes": B replaces a claim in A as defined above (a newer version, a changed decision, a fixed bug, a new value), and onlyInA is empty.
|
|
15
|
-
- "contradicts": they make logically exclusive claims about the same thing and nothing shows which one is current: B does not say it replaces the claim and was not created later.
|
|
16
|
-
- "overlap": same subject, and both lists have items.
|
|
17
|
-
- "unrelated": different subjects, or different named things written to one layout (two services, partners or regions), or different facts that happen to share words.
|
|
11
|
+
Before answering, check the final claim lists against the complete notes, including their endings. Every item in onlyInA must be stated by A and neither stated in other wording nor replaced by B; check onlyInB symmetrically. Remove equivalent or replaced claims. The reason must agree with the final lists.
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Answer ONLY with JSON: {"onlyInA": ["..."], "onlyInB": ["..."], "relation": "...", "redundant": "A"|"B"|null, "stale": "A"|null, "confidence": 0.0-1.0, "reason": "<at most 25 words: name the asset that can be deleted and what the other asset still holds; for supersedes, quote the words of B that replace the claim or say B is created later>"}
|
|
13
|
+
Answer ONLY with JSON: {"reason": "<at most 25 words: the distinct claims of each side, or that their claims are equivalent; quote any explicit replacement>", "onlyInA": ["..."], "onlyInB": ["..."], "confidence": 0.0-1.0}
|
package/dist/commands/health.js
CHANGED
|
@@ -580,6 +580,7 @@ export async function akmHealth(options = {}) {
|
|
|
580
580
|
}
|
|
581
581
|
const { db } = opened;
|
|
582
582
|
try {
|
|
583
|
+
const { windowResults, deltas } = resolveWindowComparePhase(options, db, stateDbPath, now);
|
|
583
584
|
// Network probes overlap the local database phases below; awaited where consumed.
|
|
584
585
|
const engineProbesPromise = runHealthEngineProbes({ probeReachable: options.probe ? probeLlmEndpoint : undefined });
|
|
585
586
|
engineProbesPromise.catch(() => undefined);
|
|
@@ -666,8 +667,6 @@ export async function akmHealth(options = {}) {
|
|
|
666
667
|
const hardFailure = hardChecks.some((check) => check.status === "fail");
|
|
667
668
|
const deterministicWarnings = [...hardChecks, ...advisories].some((check) => check.status === "warn" && check.kind === "deterministic");
|
|
668
669
|
const status = hardFailure ? "fail" : deterministicWarnings ? "warn" : "pass";
|
|
669
|
-
// ── Window-compare mode (Phase 3) ─────────────────────────────────────
|
|
670
|
-
const { windowResults, deltas } = resolveWindowComparePhase(options, db, stateDbPath, now);
|
|
671
670
|
// ── Per-run mode (Phase 2) ────────────────────────────────────────────
|
|
672
671
|
const runs = options.groupBy === "run" ? perRunSummaries : undefined;
|
|
673
672
|
return {
|
|
@@ -5,15 +5,13 @@
|
|
|
5
5
|
* The consolidate pair pass (0.9.17-alpha.9 plan §5.2, brief §A): runs
|
|
6
6
|
* alongside the existing promote pass inside `akmConsolidate`, over new and
|
|
7
7
|
* changed memory-tier assets. Judges each near-duplicate/superseding pair
|
|
8
|
-
* with
|
|
9
|
-
* for
|
|
10
|
-
* never writes belief edges
|
|
11
|
-
* triage (`drain.ts`).
|
|
8
|
+
* with complete claim comparisons and mints a reviewed `retire` proposal
|
|
9
|
+
* for derived `duplicate` / `subsumed` outcomes — never merges,
|
|
10
|
+
* never writes belief edges. A staged retirement can be accepted unattended
|
|
11
|
+
* by triage (`drain.ts`); an unstaged one stays pending.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* supersedes) is 20/22 = 0.91 precision against the owner. `T_PAIR` is 0.93,
|
|
16
|
-
* not the initial 0.90, because the 0.90–0.93 band alone graded 3/4 (R1).
|
|
13
|
+
* Pair-selection calibration (owner grades, 2026-09-28): `T_PAIR` stays
|
|
14
|
+
* 0.93, not the initial 0.90, because the 0.90–0.93 band alone graded 3/4 (R1).
|
|
17
15
|
*
|
|
18
16
|
* Initiator eligibility and dating (post-review, alpha.9): "created" is the
|
|
19
17
|
* asset's git first-add time (one `git log` per run, {@link loadGitFirstAddedMap}),
|
|
@@ -30,6 +28,8 @@ import fs from "node:fs";
|
|
|
30
28
|
import path from "node:path";
|
|
31
29
|
import consolidatePairPrompt from "../../../assets/prompts/consolidate-pair.md" with { type: "text" };
|
|
32
30
|
import consolidatePairCheckPrompt from "../../../assets/prompts/consolidate-pair-check.md" with { type: "text" };
|
|
31
|
+
import consolidatePairSameAge from "../../../assets/prompts/consolidate-pair-same-age.md" with { type: "text" };
|
|
32
|
+
import consolidatePairTerms from "../../../assets/prompts/consolidate-pair-terms.md" with { type: "text" };
|
|
33
33
|
import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
|
|
34
34
|
import { conceptIdFromTypeName } from "../../../core/asset/resolve-ref.js";
|
|
35
35
|
import { asNonEmptyString } from "../../../core/common.js";
|
|
@@ -37,6 +37,7 @@ import { concurrentMap } from "../../../core/concurrent.js";
|
|
|
37
37
|
import { parseEmbeddedJsonResponse } from "../../../core/parse.js";
|
|
38
38
|
import { DERIVED_SUFFIX } from "../../../core/recognition-util.js";
|
|
39
39
|
import { warnOnce } from "../../../core/warn.js";
|
|
40
|
+
import { sameScope, scopeFromFrontmatter } from "../../../indexer/passes/metadata.js";
|
|
40
41
|
import { runnerLlmConnection } from "../../../integrations/agent/runner.js";
|
|
41
42
|
import { assertRunnerCredentials } from "../../../integrations/agent/runner-dispatch.js";
|
|
42
43
|
import { runGit } from "../../../sources/providers/git-install.js";
|
|
@@ -67,28 +68,22 @@ export const BACKFILL_FLOOR = 0.95;
|
|
|
67
68
|
export const NEW_MATERIAL_DAYS = 7;
|
|
68
69
|
/** Pairs judged per run, highest cosine first (plan §7's nightly cost budget). */
|
|
69
70
|
export const MAX_PAIRS_PER_RUN = 300;
|
|
70
|
-
/** Body characters sent to the judge per side: enough for nearly every note, so a retirement is judged on the whole text. */
|
|
71
|
-
const PAIR_BODY_TRUNCATE_CHARS = 12_000;
|
|
72
71
|
const MS_PER_DAY = 86_400_000;
|
|
73
|
-
const RELATION_LABELS = ["duplicate", "subsumed", "supersedes", "contradicts", "overlap", "unrelated"];
|
|
74
72
|
const RETIRE_LABELS = new Set(["duplicate", "subsumed", "supersedes"]);
|
|
75
|
-
/** The
|
|
73
|
+
/** The labels represented by the existing retirement metadata. */
|
|
76
74
|
function isRetireLabel(label) {
|
|
77
75
|
return RETIRE_LABELS.has(label);
|
|
78
76
|
}
|
|
79
77
|
/** Exported, with {@link buildPairUserPrompt}, so a replay can drive the exact judge call. */
|
|
80
78
|
export const PAIR_JUDGE_JSON_SCHEMA = {
|
|
81
79
|
type: "object",
|
|
82
|
-
required: ["
|
|
80
|
+
required: ["reason", "onlyInA", "onlyInB", "confidence"],
|
|
83
81
|
additionalProperties: false,
|
|
84
82
|
properties: {
|
|
83
|
+
reason: { type: "string", maxLength: 400 },
|
|
85
84
|
onlyInA: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 },
|
|
86
85
|
onlyInB: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 },
|
|
87
|
-
relation: { type: "string", enum: [...RELATION_LABELS] },
|
|
88
|
-
redundant: { type: ["string", "null"], enum: ["A", "B", null] },
|
|
89
|
-
stale: { type: ["string", "null"], enum: ["A", null] },
|
|
90
86
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
|
91
|
-
reason: { type: "string", maxLength: 400 },
|
|
92
87
|
},
|
|
93
88
|
};
|
|
94
89
|
export const PAIR_CHECK_JSON_SCHEMA = {
|
|
@@ -97,17 +92,11 @@ export const PAIR_CHECK_JSON_SCHEMA = {
|
|
|
97
92
|
additionalProperties: false,
|
|
98
93
|
properties: { missing: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 } },
|
|
99
94
|
};
|
|
100
|
-
/**
|
|
95
|
+
/** Validate both claim lists, then derive the outcome instead of asking the model to duplicate that decision. */
|
|
101
96
|
export function parsePairJudgeResponse(raw) {
|
|
102
97
|
const parsed = parseEmbeddedJsonResponse(raw);
|
|
103
98
|
if (!parsed)
|
|
104
99
|
return undefined;
|
|
105
|
-
if (typeof parsed.relation !== "string" || !RELATION_LABELS.includes(parsed.relation)) {
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
const redundant = parsed.redundant;
|
|
109
|
-
if (redundant !== "A" && redundant !== "B" && redundant !== null)
|
|
110
|
-
return undefined;
|
|
111
100
|
if (typeof parsed.confidence !== "number" || !Number.isFinite(parsed.confidence))
|
|
112
101
|
return undefined;
|
|
113
102
|
const confidence = Math.max(0, Math.min(1, parsed.confidence));
|
|
@@ -116,7 +105,13 @@ export function parsePairJudgeResponse(raw) {
|
|
|
116
105
|
const onlyInB = claimList(parsed.onlyInB);
|
|
117
106
|
if (!onlyInA || !onlyInB)
|
|
118
107
|
return undefined;
|
|
119
|
-
|
|
108
|
+
const relation = onlyInA.length === 0 && onlyInB.length === 0
|
|
109
|
+
? "duplicate"
|
|
110
|
+
: onlyInA.length === 0 || onlyInB.length === 0
|
|
111
|
+
? "subsumed"
|
|
112
|
+
: "overlap";
|
|
113
|
+
const redundant = onlyInA.length === 0 ? "A" : onlyInB.length === 0 ? "B" : null;
|
|
114
|
+
return { onlyInA, onlyInB, relation, redundant, confidence, reason };
|
|
120
115
|
}
|
|
121
116
|
function claimList(value) {
|
|
122
117
|
if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
|
|
@@ -383,6 +378,10 @@ export function selectCandidates(db, initiators, bundleId, unvectored) {
|
|
|
383
378
|
// rest of the backlog (no row, not recently git-added) needs the higher
|
|
384
379
|
// BACKFILL_FLOOR.
|
|
385
380
|
const floor = initiator.backlog && !initiator.newMaterial ? BACKFILL_FLOOR : T_PAIR;
|
|
381
|
+
const initiatorSide = loadSide(initiator, undefined, "");
|
|
382
|
+
if (!initiatorSide)
|
|
383
|
+
continue;
|
|
384
|
+
const scope = scopeFromFrontmatter(initiatorSide.frontmatter);
|
|
386
385
|
let kept = 0;
|
|
387
386
|
for (const hit of getNeighborsByEntryId(db, initiator.entryId, PAIR_NEIGHBOR_FETCH_K)) {
|
|
388
387
|
if (kept >= PAIR_NEIGHBOR_K)
|
|
@@ -411,6 +410,9 @@ export function selectCandidates(db, initiators, bundleId, unvectored) {
|
|
|
411
410
|
const cosine = 1 - (hit.distance * hit.distance) / 2;
|
|
412
411
|
if (cosine < floor)
|
|
413
412
|
continue;
|
|
413
|
+
const otherSide = loadSide(other, undefined, "");
|
|
414
|
+
if (!otherSide || !sameScope(scope, scopeFromFrontmatter(otherSide.frontmatter)))
|
|
415
|
+
continue;
|
|
414
416
|
const key = pairKey(initiator.ref, other.ref);
|
|
415
417
|
if (seenPairs.has(key))
|
|
416
418
|
continue;
|
|
@@ -460,11 +462,22 @@ function sideSection(label, side) {
|
|
|
460
462
|
`Description: ${asNonEmptyString(side.frontmatter.description) ?? "(none)"}`,
|
|
461
463
|
"Content:",
|
|
462
464
|
"```",
|
|
463
|
-
stripFrontmatterBody(side.raw)
|
|
465
|
+
stripFrontmatterBody(side.raw),
|
|
464
466
|
"```",
|
|
465
467
|
"",
|
|
466
468
|
];
|
|
467
469
|
}
|
|
470
|
+
/** Lexical attention hints only: equal token sets do not prove equal claims. */
|
|
471
|
+
function exclusiveTerms(side, other) {
|
|
472
|
+
const tokens = (note) => new Set(`${asNonEmptyString(note.frontmatter.description) ?? ""}\n${stripFrontmatterBody(note.raw)}`
|
|
473
|
+
.split(/\s+/)
|
|
474
|
+
.filter(Boolean));
|
|
475
|
+
const otherTerms = tokens(other);
|
|
476
|
+
return JSON.stringify([...tokens(side)].filter((term) => !otherTerms.has(term)));
|
|
477
|
+
}
|
|
478
|
+
function termHints(lines) {
|
|
479
|
+
return consolidatePairTerms.replace("{{DIFFERENCES}}", lines.join("\n"));
|
|
480
|
+
}
|
|
468
481
|
/** Orders two loaded sides by date (older/newer) for the "A (older)"/"B (newer)" labelling the prompt requires. */
|
|
469
482
|
function orderByAge(x, y) {
|
|
470
483
|
const xMs = Date.parse(x.createdIso);
|
|
@@ -480,6 +493,11 @@ export function buildPairUserPrompt(older, newer) {
|
|
|
480
493
|
return [
|
|
481
494
|
...sideSection(tied ? "A (same age as B)" : "A (older)", older),
|
|
482
495
|
...sideSection(tied ? "B (same age as A)" : "B (newer)", newer),
|
|
496
|
+
termHints([
|
|
497
|
+
`Tokens only in A: ${exclusiveTerms(older, newer)}`,
|
|
498
|
+
`Tokens only in B: ${exclusiveTerms(newer, older)}`,
|
|
499
|
+
]),
|
|
500
|
+
...(tied ? [consolidatePairSameAge] : []),
|
|
483
501
|
].join("\n");
|
|
484
502
|
}
|
|
485
503
|
/** The tombstone-vocabulary reason a judge label maps to (`supersedes` -> `superseded`; the rest unchanged). */
|
|
@@ -489,8 +507,7 @@ export function tombstoneReason(label) {
|
|
|
489
507
|
/**
|
|
490
508
|
* The calibrated outcome table (owner grades, replacing plan §5.2's
|
|
491
509
|
* "shorter body" rule): `duplicate`/`supersedes` keep the newer copy;
|
|
492
|
-
* `subsumed` keeps the side the
|
|
493
|
-
* if that pointer is missing or invalid). A side is retired only when the
|
|
510
|
+
* `subsumed` keeps the side opposite the derived `redundant` pointer. A side is retired only when the
|
|
494
511
|
* judge listed nothing that it alone holds.
|
|
495
512
|
*/
|
|
496
513
|
export function decideRetirement(label, redundant, older, newer, only) {
|
|
@@ -512,7 +529,7 @@ function checkSection(label, side) {
|
|
|
512
529
|
`Description: ${asNonEmptyString(side.frontmatter.description) ?? "(none)"}`,
|
|
513
530
|
"Content:",
|
|
514
531
|
"```",
|
|
515
|
-
stripFrontmatterBody(side.raw)
|
|
532
|
+
stripFrontmatterBody(side.raw),
|
|
516
533
|
"```",
|
|
517
534
|
"",
|
|
518
535
|
].join("\n");
|
|
@@ -528,7 +545,7 @@ async function confirmNothingLost(ctx, retired, successor) {
|
|
|
528
545
|
feature: "memory_consolidation",
|
|
529
546
|
runner: ctx.llmRunner,
|
|
530
547
|
system: consolidatePairCheckPrompt,
|
|
531
|
-
prompt: `${checkSection("X (to delete)", retired)}\n${checkSection("Y (kept)", successor)}`,
|
|
548
|
+
prompt: `${checkSection("X (to delete)", retired)}\n${checkSection("Y (kept)", successor)}\n${termHints([`Tokens only in X: ${exclusiveTerms(retired, successor)}`])}${Date.parse(retired.createdIso) === Date.parse(successor.createdIso) ? `\n${consolidatePairSameAge}` : ""}`,
|
|
532
549
|
gate: { config: ctx.config, enabled: true },
|
|
533
550
|
request: {
|
|
534
551
|
responseSchema: PAIR_CHECK_JSON_SCHEMA,
|
|
@@ -554,6 +571,13 @@ async function judgeOne(ctx, candidate) {
|
|
|
554
571
|
const otherSide = loadSide(candidate.other, ctx.gitFirstAdded, ctx.stashDir);
|
|
555
572
|
if (!initiatorSide || !otherSide)
|
|
556
573
|
return { failed: false }; // unreadable since selection — skip, not a judge failure
|
|
574
|
+
const judgedScope = scopeFromFrontmatter(initiatorSide.frontmatter);
|
|
575
|
+
if (!sameScope(judgedScope, scopeFromFrontmatter(otherSide.frontmatter)))
|
|
576
|
+
return { failed: false };
|
|
577
|
+
const scopesStillFresh = () => [initiatorSide, otherSide].every((side) => {
|
|
578
|
+
const live = loadSide(side.asset, ctx.gitFirstAdded, ctx.stashDir);
|
|
579
|
+
return live !== undefined && sameScope(judgedScope, scopeFromFrontmatter(live.frontmatter));
|
|
580
|
+
});
|
|
557
581
|
// Item 0 / S1: this exact pair (same two refs, same two content hashes,
|
|
558
582
|
// EITHER orientation) was already judged and rejected OR reverted. Checked
|
|
559
583
|
// HERE, before the judge call, not after — the judge (not yet run) is what
|
|
@@ -651,7 +675,10 @@ async function judgeOne(ctx, candidate) {
|
|
|
651
675
|
ledgerAccess: { proposalsCtx: ctx.opts.proposalsCtx },
|
|
652
676
|
search: ctx.continuitySearch,
|
|
653
677
|
});
|
|
678
|
+
if (!scopesStillFresh())
|
|
679
|
+
return { failed: true };
|
|
654
680
|
const retirement = {
|
|
681
|
+
judgedScope,
|
|
655
682
|
retiredRef: retired.asset.ref,
|
|
656
683
|
successorRef: successor.asset.ref,
|
|
657
684
|
cosine: candidate.cosine,
|
|
@@ -691,7 +718,8 @@ async function judgeOne(ctx, candidate) {
|
|
|
691
718
|
// reviewed pairs (2026-10-04). Anything else waits for a person.
|
|
692
719
|
if ((verdict.relation !== "duplicate" || verdict.onlyInA.length + verdict.onlyInB.length === 0) &&
|
|
693
720
|
!continuityRisk &&
|
|
694
|
-
(await confirmNothingLost(ctx, retired, successor))
|
|
721
|
+
(await confirmNothingLost(ctx, retired, successor)) &&
|
|
722
|
+
scopesStillFresh()) {
|
|
695
723
|
recordGateDecision(ctx.stashDir, proposal.id, {
|
|
696
724
|
outcome: "staged",
|
|
697
725
|
reason: verdict.relation,
|
|
@@ -189,17 +189,27 @@ export function parseSinceArg(value, now = Date.now()) {
|
|
|
189
189
|
throw new UsageError(`--since value "${value}" could not be parsed (expected ISO timestamp or duration like 24h / 7d / 30m)`, "INVALID_FLAG_VALUE");
|
|
190
190
|
}
|
|
191
191
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
192
|
+
* Assemble candidate metadata once in the authoritative Markdown that acceptance
|
|
193
|
+
* publishes. The payload projection is for proposal readers, not another write path.
|
|
194
194
|
*/
|
|
195
195
|
function buildCandidateProposal(candidate, sourceRef, sessionAssetRef) {
|
|
196
196
|
const ref = deriveExtractCandidateRef(candidate, sourceRef);
|
|
197
197
|
// Complete a description the model cut mid-sentence (no-op for valid ones).
|
|
198
198
|
const description = repairTruncatedDescription(candidate.description, candidate.body);
|
|
199
|
-
const fm = {
|
|
200
|
-
|
|
199
|
+
const fm = {
|
|
200
|
+
description,
|
|
201
|
+
evidence: candidate.evidence,
|
|
202
|
+
...(sessionAssetRef ? { xrefs: [sessionAssetRef], evidenceSources: [sessionAssetRef] } : {}),
|
|
203
|
+
};
|
|
204
|
+
// With no published session asset, keep the known native session identity in
|
|
205
|
+
// the existing structured provenance channel, never invent an AKM asset ref.
|
|
206
|
+
// The identity, not the log's absolute path, so published notes carry no local paths.
|
|
207
|
+
if (!sessionAssetRef && sourceRef.filePath) {
|
|
208
|
+
fm.provenance = { sources: [{ resource: `${sourceRef.harness}:${sourceRef.sessionId}` }] };
|
|
209
|
+
}
|
|
210
|
+
if (candidate.when_to_use)
|
|
201
211
|
fm.when_to_use = candidate.when_to_use;
|
|
202
|
-
return { ref, content: assembleAsset(fm, candidate.body),
|
|
212
|
+
return { ref, content: assembleAsset(fm, candidate.body), frontmatter: fm };
|
|
203
213
|
}
|
|
204
214
|
function canonicalSegment(value) {
|
|
205
215
|
return value
|
|
@@ -496,13 +506,9 @@ async function processSession(run, sessionRef, gate) {
|
|
|
496
506
|
attemptedRefs: [built.ref],
|
|
497
507
|
payload: {
|
|
498
508
|
content: built.content,
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
confidence: candidate.confidence,
|
|
503
|
-
...(sessionAsset.sessionAssetRef ? { xrefs: [sessionAsset.sessionAssetRef] } : {}),
|
|
504
|
-
evidence: candidate.evidence,
|
|
505
|
-
},
|
|
509
|
+
// Model confidence stays on the proposal for reviewers; it is not published,
|
|
510
|
+
// where promotion scoring would read it as a signal.
|
|
511
|
+
frontmatter: { ...built.frontmatter, confidence: candidate.confidence },
|
|
506
512
|
},
|
|
507
513
|
});
|
|
508
514
|
proposalIds.push(proposal.id);
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* present edge AND demotion is a no-op; an edge without its demotion is
|
|
8
8
|
* repaired) and never weakens a stronger demotion — severity is
|
|
9
9
|
* superseded > contradicted > archived. (alpha.4 removed belief weights from
|
|
10
|
-
* ranking:
|
|
11
|
-
*
|
|
10
|
+
* ranking: beliefState never changes rank. Search/curate filter current
|
|
11
|
+
* by default; --belief all|historical explicitly includes older context.)
|
|
12
12
|
* The SCC resolver in memory-improve.ts is a state-transition writer (it
|
|
13
13
|
* replaces and clears edges) and deliberately does not use this (#885).
|
|
14
14
|
*/
|
|
@@ -33,9 +33,10 @@ import { assertAkmAssetWrite, commitAcceptedPaths, commitWriteTargetBoundary, pr
|
|
|
33
33
|
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
34
34
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
35
35
|
import { deriveInstallations } from "../../indexer/installations.js";
|
|
36
|
+
import { sameScope, scopeFromFrontmatter } from "../../indexer/passes/metadata.js";
|
|
36
37
|
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
37
38
|
import { insertEventOnce } from "../../storage/repositories/events-repository.js";
|
|
38
|
-
import { CONSOLIDATE_LEDGER_SOURCE, forgetImproveLedgerDecision, recordImproveLedger, recordImproveLedgerDecision, reopenImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
39
|
+
import { CONSOLIDATE_LEDGER_SOURCE, forgetImproveLedgerDecision, PAIR_PASS_LEDGER_SOURCE, recordImproveLedger, recordImproveLedgerDecision, reopenImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
39
40
|
import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
|
|
40
41
|
import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
|
|
41
42
|
import { pkgVersion } from "../../version.js";
|
|
@@ -528,6 +529,14 @@ export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision)
|
|
|
528
529
|
at: decidedAt,
|
|
529
530
|
...(reason !== undefined ? { detail: reason } : {}),
|
|
530
531
|
});
|
|
532
|
+
// The pair pass settles initiators under bare refs, independently of the
|
|
533
|
+
// proposal's durable ref. A stale decision must release both inputs for
|
|
534
|
+
// reassessment through that same ledger, rather than hold an old body forever.
|
|
535
|
+
if (isRetireProposal(updated) && gateDecision?.reason === STALE_TARGET_GATE_REASON) {
|
|
536
|
+
for (const ref of retireHeldRefs(updated)) {
|
|
537
|
+
recordImproveLedger(db, { stashDir, ref, source: PAIR_PASS_LEDGER_SOURCE, outcome: "failed", at: decidedAt });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
531
540
|
return updated;
|
|
532
541
|
}));
|
|
533
542
|
}
|
|
@@ -1007,6 +1016,14 @@ export function preflightProposalPromotion(config, proposal, options = {}, ctx)
|
|
|
1007
1016
|
// best-effort; the freshness check is the real staleness gate
|
|
1008
1017
|
}
|
|
1009
1018
|
}
|
|
1019
|
+
if (prepared.promotionSource) {
|
|
1020
|
+
const sourcePath = resolveAssetFilePathSafe(target.source, parseRefInput(prepared.promotionSource));
|
|
1021
|
+
if (sourcePath &&
|
|
1022
|
+
fs.existsSync(sourcePath) &&
|
|
1023
|
+
!sameScope(scopeFromFrontmatter(parseFrontmatter(fs.readFileSync(sourcePath, "utf8")).data), scopeFromFrontmatter(parseFrontmatter(stampedContent).data))) {
|
|
1024
|
+
throw new UsageError(`Promotion ${proposal.id} has incompatible source/output scope — reject and reassess.`, "INVALID_FLAG_VALUE");
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1010
1027
|
const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
|
|
1011
1028
|
if (lintBlockers.length > 0) {
|
|
1012
1029
|
const summary = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("; ");
|
|
@@ -1127,7 +1144,12 @@ function retirePromotionSource(mutationTarget, accepted, paths) {
|
|
|
1127
1144
|
warn(`[proposal] O1: ${accepted.id} has no recorded source hash (minted by an older release) — leaving its source ${accepted.promotionSource} unarchived.`);
|
|
1128
1145
|
return;
|
|
1129
1146
|
}
|
|
1130
|
-
const
|
|
1147
|
+
const sourceContent = fs.readFileSync(sourcePath, "utf8");
|
|
1148
|
+
if (!sameScope(scopeFromFrontmatter(parseFrontmatter(sourceContent).data), scopeFromFrontmatter(parseFrontmatter(proposalContent(accepted)).data))) {
|
|
1149
|
+
warn(`[proposal] source scope changed for ${accepted.id} — leaving ${accepted.promotionSource} unarchived.`);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const currentHash = contentHash(sourceContent, "body");
|
|
1131
1153
|
if (currentHash !== accepted.promotionSourceHash) {
|
|
1132
1154
|
warn(`[proposal] O1: source ${accepted.promotionSource} for ${accepted.id} changed since the promotion was queued — leaving it unarchived.`);
|
|
1133
1155
|
return;
|
|
@@ -1141,7 +1163,8 @@ function retirePromotionSource(mutationTarget, accepted, paths) {
|
|
|
1141
1163
|
const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, sourcePath);
|
|
1142
1164
|
paths.push(sourcePath, path.join(mutationTarget.source.path, record.archivedPath), path.join(mutationTarget.source.path, record.auditPath));
|
|
1143
1165
|
const twin = derivedTwinPath(sourcePath, sourceRef.type);
|
|
1144
|
-
if (twin
|
|
1166
|
+
if (twin &&
|
|
1167
|
+
sameScope(scopeFromFrontmatter(parseFrontmatter(fs.readFileSync(twin, "utf8")).data), scopeFromFrontmatter(parseFrontmatter(sourceContent).data))) {
|
|
1145
1168
|
const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twin);
|
|
1146
1169
|
paths.push(twin, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
|
|
1147
1170
|
}
|
|
@@ -1325,7 +1348,7 @@ function persistRetireAcceptance(stashDir, proposal, info, ctx) {
|
|
|
1325
1348
|
}
|
|
1326
1349
|
/**
|
|
1327
1350
|
* B2: throws a stale-retire `UsageError` unless the successor still exists
|
|
1328
|
-
* and both sides' recorded body hashes
|
|
1351
|
+
* and both sides' recorded body hashes and judged scopes still match — the
|
|
1329
1352
|
* durable half of the chain guard. Used for a fresh accept, and (4b, third
|
|
1330
1353
|
* review round) to re-check a resumed accept whose intent was recorded but
|
|
1331
1354
|
* nothing has moved yet: a separate proposal accepted in between (e.g. this
|
|
@@ -1335,6 +1358,15 @@ function persistRetireAcceptance(stashDir, proposal, info, ctx) {
|
|
|
1335
1358
|
function assertRetirementStillFresh(proposalId, proposalRef, retirement, targetSource, retiredCurrentBytes) {
|
|
1336
1359
|
const successorPath = resolveAssetFilePathSafe(targetSource, parseRefInput(retirement.successorRef));
|
|
1337
1360
|
const successorBytes = successorPath && fs.existsSync(successorPath) ? fs.readFileSync(successorPath) : undefined;
|
|
1361
|
+
const judgedScope = retirement.judgedScope;
|
|
1362
|
+
if (!judgedScope || typeof judgedScope !== "object" || Array.isArray(judgedScope)) {
|
|
1363
|
+
throw new UsageError(`Retire proposal ${proposalId} is stale — judged scope cannot be established; reject it and reassess the pair before acceptance.`, "INVALID_FLAG_VALUE");
|
|
1364
|
+
}
|
|
1365
|
+
if (!sameScope(scopeFromFrontmatter(parseFrontmatter(retiredCurrentBytes.toString("utf8")).data), judgedScope) ||
|
|
1366
|
+
(successorBytes &&
|
|
1367
|
+
!sameScope(scopeFromFrontmatter(parseFrontmatter(successorBytes.toString("utf8")).data), judgedScope))) {
|
|
1368
|
+
throw new UsageError(`Retire proposal ${proposalId} is stale — scope changed since judging; reject and reassess.`, "INVALID_FLAG_VALUE");
|
|
1369
|
+
}
|
|
1338
1370
|
const retiredFresh = contentHash(retiredCurrentBytes, "body") === retirement.retiredContentHash;
|
|
1339
1371
|
const successorFresh = successorBytes !== undefined && contentHash(successorBytes, "body") === retirement.successorContentHash;
|
|
1340
1372
|
if (!successorBytes || !retiredFresh || !successorFresh) {
|
|
@@ -1416,14 +1448,10 @@ async function retireProposalWithLease(stashDir, config, proposal, options, ctx)
|
|
|
1416
1448
|
}
|
|
1417
1449
|
else {
|
|
1418
1450
|
assertAkmAssetWrite(target.source);
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
// though nothing here changed. Once something has moved, it is too late
|
|
1424
|
-
// to cleanly refuse — Phase 2 below already tolerates a partial move.
|
|
1425
|
-
if (working.retirement && fs.existsSync(intent.assetPath)) {
|
|
1426
|
-
assertRetirementStillFresh(proposal.id, proposal.ref, working.retirement, target.source, fs.readFileSync(intent.assetPath));
|
|
1451
|
+
// Revalidate the survivor even after a partial archive. The recorded original
|
|
1452
|
+
// is the judged retired side when its file has already moved.
|
|
1453
|
+
if (working.retirement) {
|
|
1454
|
+
assertRetirementStillFresh(proposal.id, proposal.ref, working.retirement, target.source, fs.existsSync(intent.assetPath) ? fs.readFileSync(intent.assetPath) : Buffer.from(intent.backupContent));
|
|
1427
1455
|
}
|
|
1428
1456
|
}
|
|
1429
1457
|
// Phase 2: move, idempotently — a resumed call skips whichever file a
|
|
@@ -1449,7 +1477,10 @@ async function retireProposalWithLease(stashDir, config, proposal, options, ctx)
|
|
|
1449
1477
|
// tombstone scan above (`alreadyDone`) already finds one archived earlier,
|
|
1450
1478
|
// so storing it was redundant persisted state.
|
|
1451
1479
|
const twinPath = derivedTwinPath(intent.assetPath, ref.type);
|
|
1452
|
-
if (twinPath &&
|
|
1480
|
+
if (twinPath &&
|
|
1481
|
+
!alreadyDone.has(path.resolve(twinPath)) &&
|
|
1482
|
+
fs.existsSync(twinPath) &&
|
|
1483
|
+
sameScope(scopeFromFrontmatter(parseFrontmatter(fs.readFileSync(twinPath, "utf8")).data), scopeFromFrontmatter(parseFrontmatter(intent.backupContent).data))) {
|
|
1453
1484
|
const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twinPath);
|
|
1454
1485
|
archiveDirs.push(path.dirname(twinRecord.auditPath));
|
|
1455
1486
|
paths.push(twinPath, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
|