@holmes-lab/holmes-kit 0.26.1 → 0.26.2
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 +37 -0
- package/README.md +1 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +16 -2
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +238 -66
- package/dist/holmes/mcp/handlers.d.ts +5 -0
- package/dist/holmes/mcp/handlers.js +2 -0
- package/dist/holmes/mcp/tool-schemas.js +13 -0
- package/dist/holmes/spec/entity-store.js +28 -6
- package/dist/holmes/spec/id-collision.js +4 -1
- package/dist/holmes/spec/remote-spec-refs.d.ts +9 -0
- package/dist/holmes/spec/remote-spec-refs.js +88 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
<!-- @implements A-SPEC-209 -->
|
|
8
|
+
## [0.26.2] - 2026-09-20
|
|
9
|
+
|
|
10
|
+
The last stretch of the number-collision repair: the plan `doctor` has been printing since 0.26.1 can
|
|
11
|
+
now be executed — the plan a person saw, under one approval, before the merge.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **`spec_reconcile` — the seen plan, one approval, before the merge** (A-SPEC-700.2). 0.26.1 detected a
|
|
15
|
+
spec number another machine took and printed the plan; executing it was still a person calling
|
|
16
|
+
`spec_renumber` once per family. `spec_reconcile({operation:'plan'})` is read-only: it chains the
|
|
17
|
+
existing collectors, collision detector, planner and `planRenumber` — no new judgement — and returns
|
|
18
|
+
the moves, how many documents / `depends_on` / anchors each rewrites, the re-seal order, the prose
|
|
19
|
+
candidates, and a `target` derived from the plan's content alone (no clock, no nonce: the same state
|
|
20
|
+
plans to the same target). `apply` takes that plan back, **rebuilds it inside the store hold**, and
|
|
21
|
+
refuses `plan-changed` if it is no longer the one that was seen — a fetch in between may have given
|
|
22
|
+
the destination away. It then demands one `config-write` approval bound to `target`, and runs every
|
|
23
|
+
move through the same path `spec_renumber` uses. Found while building it: `spec_renumber`'s own apply
|
|
24
|
+
asks for **no approval at all**, although it rewrites approved documents and source anchors; this
|
|
25
|
+
tool is the first path that honours REQ-700's "one approval" constraint, and the standalone tool is
|
|
26
|
+
unchanged (a separate decision). It never seals — `spec_approve` stays the only sealer, and the order
|
|
27
|
+
is returned. Prose is reported, never rewritten. No remote, or no git, is an answer (`moves: []` with
|
|
28
|
+
a note), not a refusal. Measured cost: the tool adds ≈414 schema tokens (41 tools ≈ 10,724).
|
|
29
|
+
An adversarial pass **run, not read**: forged `moves` under a true `target` are never read (what runs
|
|
30
|
+
is the recomputed plan); a grant is spent once and a refused apply spends nothing; a second move that
|
|
31
|
+
stops reports `moved` / `remaining` and planning again yields only the rest. It found one defect,
|
|
32
|
+
fixed here: with two colliding families that depend on each other, the re-seal order named a document
|
|
33
|
+
a sibling move had already renamed.
|
|
34
|
+
- `doctor`'s collision advice now ends by naming the tool: `… then re-seal with spec_approve, and merge
|
|
35
|
+
(spec_reconcile plan → apply runs every move under one approval)`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **A finished entity operation is no longer read as "interrupted" or "vanished"** (A-SPEC-701). The
|
|
39
|
+
pending-operation scan read each `journal.json` twice while retirement is a single rename, so a scan
|
|
40
|
+
racing a retirement could see the journal on the first read and not the second, and refuse
|
|
41
|
+
`recovery-required` for work that had just completed — measured as the Linux CI red on
|
|
42
|
+
`entity-store-boundaries` at a commit that changed no code. The scan reads once and skips an
|
|
43
|
+
operation whose directory has retired; the set of legal outcomes is not widened.
|
|
44
|
+
|
|
8
45
|
## [0.26.1] - 2026-09-20
|
|
9
46
|
|
|
10
47
|
Three judgements that were computed and reached nobody now reach a person, and a spec number another
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
### 🛡️ Currently Supported Features (Production Features)
|
|
18
18
|
|
|
19
|
+
- 🤝 **The collision plan runs under one approval** *(new in 0.26.2)*: 0.26.1 saw a spec number another machine took and printed the plan; carrying it out was still one `spec_renumber` call per family, and that tool's apply asks for no approval although it rewrites approved documents and source anchors. `spec_reconcile` `plan` is read-only and returns the moves, what each rewrites, the re-seal order, the prose it will **not** touch, and a `target` derived from the plan's content alone. `apply` rebuilds the plan inside the store hold, refuses `plan-changed` if a fetch moved it since you looked, demands one `config-write` approval bound to that `target`, and runs every move through the path `spec_renumber` already uses. Forged `moves` are never read — what runs is the recomputed plan. It never seals: `spec_approve` stays the only sealer. No remote is an answer, not a refusal.
|
|
19
20
|
- 🔢 **A spec number another machine took is seen before the merge** *(new in 0.26.1)*: two checkouts allocated `REQ-694` thirteen minutes apart, neither could see the other, and it surfaced only as an add/add conflict after the push was refused — a rebuilt slice and five out-of-band approvals. `doctor --target` now reads each remote-tracking ref **as last fetched** (no network) and judges only the documents each side *added* since they parted, so a spec one side merely edited is divergence, not an alarm. On a collision the advice is the plan: `Before merging — move 901 → 903: spec_renumber(oldBase=901, newBase=903); then re-seal`. The side that moves is the one not published yet, the destination is free on both sides, and "published" means equal content — never path, because a spec's path is its number.
|
|
20
21
|
- 🔁 **The cycle ratchet finally speaks — and takes a named exception** *(new in 0.26.1)*: since the commit that introduced it, the Stop hook computed the code cycles and called the verdict without them, so the `ART-2` line had never been printed; every unit test passed because each handed the evidence in directly. It is emitted now, bounded (five cycles, five members each, the rest counted — one vendored cycle here has 46 members), and `ax.config.json` gains `architecture.cycleIgnore`: prefixes for trees that are not yours to fix. A cycle is excepted only when every member lies under one, `reference` does not pardon `reference-impl/`, a broken config excepts nothing, and excepted cycles stay counted in the ledger.
|
|
21
22
|
- 🧬 **Declare your architecture, in every language the graph can follow** *(new in 0.26.0)*: a C-SPEC's `## Forbidden Edges` takes a third rule kind beside `import` and `call` — `- inherits src/app -x-> ConcreteBase`, an exact match enforced at the pre-edit gate. Nothing is inferred: the rule reads the graph's `inherits` edges and never asks which class is "abstract", which is what makes it hold in Go, where interface satisfaction cannot be inferred at all. A project that already breaks the rule it wants adopts it with a **baseline that expires** — `- allow until 2026-09-27 : inherits src/legacy/a.ts -> LegacyBase`, exact on kind, file and target, never a prefix. Past its date the allowance still holds, because an expiry that blocked would turn the rule into a barrier to adoption; it is **reported** instead, carrying `(reconfirmed n since <date>)` so a twelfth renewal cannot hide.
|
package/dist/.build-id
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
29f20e72-mu9gw8dl
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SpecStore } from '../../spec/spec-store';
|
|
2
2
|
import { Approval } from '../../guardrail/risk-gate';
|
|
3
|
+
import { type RenumberPlan } from '../../spec/renumber';
|
|
3
4
|
export interface SpecLifecycleContext {
|
|
4
5
|
store: SpecStore;
|
|
5
6
|
resolveLedgerRoot(root?: string): {
|
|
@@ -64,6 +65,19 @@ export declare function createSpecLifecycleHandlers(context: SpecLifecycleContex
|
|
|
64
65
|
dependents: string[];
|
|
65
66
|
reason?: undefined;
|
|
66
67
|
}>;
|
|
68
|
+
/**
|
|
69
|
+
* @implements A-SPEC-700.2
|
|
70
|
+
* The plan a person SAW, executed under ONE approval, before the merge. `plan` reads; `apply`
|
|
71
|
+
* rebuilds the plan, refuses `plan-changed` if it is not the one that was seen, demands a
|
|
72
|
+
* `config-write` approval bound to the plan's `target`, then runs every move through the SAME
|
|
73
|
+
* path `spec_renumber` uses, inside one store hold. It never seals: `spec_approve` is the only
|
|
74
|
+
* sealer, so `approveOrder` is returned and the caller runs it.
|
|
75
|
+
*/
|
|
76
|
+
spec_reconcile(a: {
|
|
77
|
+
root?: string;
|
|
78
|
+
operation?: string;
|
|
79
|
+
plan?: unknown;
|
|
80
|
+
}): Promise<Record<string, unknown>>;
|
|
67
81
|
spec_renumber(a: {
|
|
68
82
|
root?: string;
|
|
69
83
|
oldBase: string;
|
|
@@ -79,13 +93,13 @@ export declare function createSpecLifecycleHandlers(context: SpecLifecycleContex
|
|
|
79
93
|
} | {
|
|
80
94
|
ok: boolean;
|
|
81
95
|
dryRun: boolean;
|
|
82
|
-
plan:
|
|
96
|
+
plan: RenumberPlan;
|
|
83
97
|
movedSpecs?: undefined;
|
|
84
98
|
} | {
|
|
85
99
|
ok: boolean;
|
|
86
100
|
dryRun: boolean;
|
|
87
101
|
movedSpecs: number;
|
|
88
|
-
plan:
|
|
102
|
+
plan: RenumberPlan;
|
|
89
103
|
}>;
|
|
90
104
|
/**
|
|
91
105
|
* @implements A-SPEC-538.1
|
|
@@ -47,8 +47,148 @@ const ledger_store_1 = require("../../governance/ledger-store");
|
|
|
47
47
|
const provenance_chain_1 = require("../../governance/provenance-chain");
|
|
48
48
|
const approval_grants_1 = require("../../governance/approval-grants");
|
|
49
49
|
const renumber_1 = require("../../spec/renumber");
|
|
50
|
+
const id_collision_1 = require("../../spec/id-collision");
|
|
51
|
+
const remote_spec_refs_1 = require("../../spec/remote-spec-refs");
|
|
50
52
|
function createSpecLifecycleHandlers(context) {
|
|
51
53
|
const { store, resolveLedgerRoot } = context;
|
|
54
|
+
const renumberRun = (specsRoot, a, journaled) => {
|
|
55
|
+
try {
|
|
56
|
+
if ((0, entity_store_1.inspectEntityStore)(specsRoot).state === 'active')
|
|
57
|
+
return { ok: false, reason: 'An activated store requires entity_renumber to preserve entity UUIDs and references; raw legacy renumber is unavailable.' };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// Unmarked legacy file stores retain the existing API. Corrupt or pending entity state never falls back.
|
|
61
|
+
if (!(error instanceof entity_transaction_1.EntityStoreError) || error.code !== 'missing-workspace') {
|
|
62
|
+
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
63
|
+
return { ok: false, reason: error.message };
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const projectRoot = path.resolve(specsRoot, '..', '..');
|
|
68
|
+
const read = (0, renumber_1.readSpecsForRenumber)(specsRoot);
|
|
69
|
+
const plan = (0, renumber_1.planRenumber)({
|
|
70
|
+
specs: read.specs,
|
|
71
|
+
sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
|
|
72
|
+
oldBase: String(a.oldBase), newBase: String(a.newBase),
|
|
73
|
+
});
|
|
74
|
+
// @implements A-SPEC-699 — a refusal that cannot say whether the store was empty or merely
|
|
75
|
+
// unreadable sends the caller to the source. Measured 2026-09-20: this tool answered
|
|
76
|
+
// "nothing to move" while 300-odd specs sat in front of it, and the answer alone could not
|
|
77
|
+
// distinguish the two. Carry the counts, and name the files while there are few.
|
|
78
|
+
if (plan.refusal)
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: plan.refusal
|
|
82
|
+
+ ` (읽은 스펙 ${read.specs.length}개`
|
|
83
|
+
+ (read.unreadable.length > 0
|
|
84
|
+
? `, 프론트매터를 읽지 못한 파일 ${read.unreadable.length}개: ${read.unreadable.slice(0, 5).join(', ')}${read.unreadable.length > 5 ? ' …' : ''})`
|
|
85
|
+
: ')'),
|
|
86
|
+
...(read.unreadable.length > 0 ? { unreadable: read.unreadable } : {}),
|
|
87
|
+
};
|
|
88
|
+
if (a.dryRun !== false)
|
|
89
|
+
return { ok: true, dryRun: true, plan };
|
|
90
|
+
// @implements A-SPEC-638 — in a registered workspace the raw publication is preceded by a
|
|
91
|
+
// rollback journal holding the original bytes of every file it will touch, so an interrupted
|
|
92
|
+
// run is inspectable and reversible through entity_store recovery.
|
|
93
|
+
let retire;
|
|
94
|
+
const identity = journaled ? (0, workspace_identity_1.workspaceIdentity)(journaled.root) : undefined;
|
|
95
|
+
if (journaled && identity && identity.state === 'registered') {
|
|
96
|
+
const operationId = (0, node_crypto_1.randomUUID)();
|
|
97
|
+
const rel = (abs) => path.relative(journaled.root, abs).split(path.sep).join('/');
|
|
98
|
+
const touched = [...new Set([
|
|
99
|
+
...plan.moves.flatMap((m) => [rel(path.join(specsRoot, m.from)), rel(path.join(specsRoot, m.to))]),
|
|
100
|
+
...plan.dependsOn.map((d) => rel(path.join(specsRoot, d.file))),
|
|
101
|
+
...plan.slices.map((s) => rel(path.join(specsRoot, s.file))),
|
|
102
|
+
...plan.anchors.map((anchor) => rel(path.join(projectRoot, anchor.file))),
|
|
103
|
+
])].sort();
|
|
104
|
+
const changes = touched.map((locator) => ({ locator, before: (0, entity_transaction_1.readEntityBytes)(path.join(journaled.root, locator))?.toString('base64') ?? null }));
|
|
105
|
+
const text = JSON.stringify({ schema: entity_store_1.LEGACY_RENUMBER_JOURNAL_SCHEMA, operationId, workspaceId: identity.workspaceId, storeLocator: journaled.locator, oldBase: String(a.oldBase), newBase: String(a.newBase), changes }) + '\n';
|
|
106
|
+
(0, entity_transaction_1.publishEntityRecord)(journaled.root, '.ax/state/entity-transactions/' + operationId + '/journal.json', text);
|
|
107
|
+
retire = () => (0, entity_transaction_1.retireEntityOperation)(journaled.root, operationId, journaled.locator, (0, entity_transaction_1.entityContentVersion)(text));
|
|
108
|
+
}
|
|
109
|
+
const movedSpecs = (0, renumber_1.applyRenumber)(specsRoot, { ...plan, anchors: [] });
|
|
110
|
+
(0, renumber_1.applyRenumber)(projectRoot, { ...plan, moves: [], dependsOn: [], slices: [] });
|
|
111
|
+
new ledger_store_1.FileLedgerStore(path.join(projectRoot, '.ax', 'ledger')).append({
|
|
112
|
+
ts: new Date().toISOString(),
|
|
113
|
+
actor: 'spec_renumber',
|
|
114
|
+
kind: 'spec-renumbered',
|
|
115
|
+
summary: `renumbered base ${a.oldBase} -> ${a.newBase}: ${movedSpecs} spec(s), ${plan.anchors.length} anchored file(s), ${plan.proseCandidates.length} prose candidate(s) left for a human`,
|
|
116
|
+
inputs: plan.moves.map((m) => `${m.oldId}->${m.newId}`),
|
|
117
|
+
});
|
|
118
|
+
retire?.();
|
|
119
|
+
return { ok: true, dryRun: false, movedSpecs, plan };
|
|
120
|
+
};
|
|
121
|
+
// @implements A-SPEC-700.2
|
|
122
|
+
const RECONCILE_SCHEMA = 'holmes-spec-reconcile/1';
|
|
123
|
+
/** Same refusal, same words, as `spec_renumber`: an adopted store moves ids with `entity_renumber`. */
|
|
124
|
+
const adoptedRefusal = (specsRoot) => {
|
|
125
|
+
try {
|
|
126
|
+
if ((0, entity_store_1.inspectEntityStore)(specsRoot).state === 'active')
|
|
127
|
+
return 'An activated store requires entity_renumber to preserve entity UUIDs and references; raw legacy renumber is unavailable.';
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (!(error instanceof entity_transaction_1.EntityStoreError) || error.code !== 'missing-workspace') {
|
|
131
|
+
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
132
|
+
return error.message;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* READ-ONLY. No new judgment: the collectors, `detectIdCollisions`, `planIdReconcile` and
|
|
140
|
+
* `planRenumber` decide everything; this only chains them and names the result. `target` is derived
|
|
141
|
+
* from content alone — no clock, no nonce — so the plan a person approved can be rebuilt and compared.
|
|
142
|
+
*/
|
|
143
|
+
const computeReconcile = (specsRoot) => {
|
|
144
|
+
const refused = adoptedRefusal(specsRoot);
|
|
145
|
+
if (refused !== undefined)
|
|
146
|
+
return { ok: false, reason: refused };
|
|
147
|
+
const projectRoot = path.resolve(specsRoot, '..', '..');
|
|
148
|
+
const local = (0, remote_spec_refs_1.collectLocalSpecEntries)(specsRoot);
|
|
149
|
+
const remote = (0, remote_spec_refs_1.collectRemoteAddedSpecs)(projectRoot);
|
|
150
|
+
const out = { schema: RECONCILE_SCHEMA, moves: [], unresolved: [], remoteRefs: [...remote.refs].sort(), rewrites: [], approveOrder: [], proseCandidates: [], target: '' };
|
|
151
|
+
if (remote.unavailable !== undefined)
|
|
152
|
+
out.note = remote.unavailable;
|
|
153
|
+
else {
|
|
154
|
+
const addedLocally = local.entries.filter((e) => !remote.baseFiles.has(e.file));
|
|
155
|
+
const issues = (0, id_collision_1.detectIdCollisions)([...addedLocally, ...remote.entries]).filter((i) => i.kind === 'id-collision');
|
|
156
|
+
const planned = (0, id_collision_1.planIdReconcile)((0, id_collision_1.reconcileInputsFrom)({ issues, local: local.entries, addedLocally, remote }));
|
|
157
|
+
out.moves = planned.moves;
|
|
158
|
+
out.unresolved = planned.unresolved;
|
|
159
|
+
if (planned.moves.length > 0) {
|
|
160
|
+
const read = (0, renumber_1.readSpecsForRenumber)(specsRoot);
|
|
161
|
+
const sources = (0, renumber_1.readSourcesForRenumber)(projectRoot);
|
|
162
|
+
for (const move of planned.moves) {
|
|
163
|
+
const plan = (0, renumber_1.planRenumber)({ specs: read.specs, sources, oldBase: move.from, newBase: move.to });
|
|
164
|
+
// One refusal refuses the WHOLE plan: a partial plan must not become a partial move.
|
|
165
|
+
if (plan.refusal)
|
|
166
|
+
return { ok: false, reason: `move ${move.from} → ${move.to} cannot be planned: ${plan.refusal}` };
|
|
167
|
+
out.rewrites.push({ base: move.from, specs: plan.moves.length, dependsOn: plan.dependsOn.length, anchors: plan.anchors.reduce((n, x) => n + x.count, 0) });
|
|
168
|
+
out.approveOrder.push(...plan.approveOrder);
|
|
169
|
+
out.proseCandidates.push(...plan.proseCandidates);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// `planRenumber` knows ONE family, so an id it returned may itself be moved by a sibling move
|
|
174
|
+
// (measured: `H-SPEC-902` depending on REQ-901 came back after 902 had gone to 904). Destinations
|
|
175
|
+
// are free on both sides, so none is another move's source and one pass settles every name.
|
|
176
|
+
const settle = (id) => {
|
|
177
|
+
for (const m of out.moves) {
|
|
178
|
+
const next = id.replace(new RegExp(`-${m.from}(\\.\\d+)?$`), `-${m.to}$1`);
|
|
179
|
+
if (next !== id)
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
return id;
|
|
183
|
+
};
|
|
184
|
+
const PARENT_FIRST = ['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC'];
|
|
185
|
+
const rank = (id) => PARENT_FIRST.findIndex((k) => id.startsWith(k + '-'));
|
|
186
|
+
out.approveOrder = [...new Set(out.approveOrder.map(settle))]
|
|
187
|
+
.map((id, i) => ({ id, i })).sort((x, y) => rank(x.id) - rank(y.id) || x.i - y.i).map((x) => x.id);
|
|
188
|
+
out.target = 'spec-reconcile:sha256:' + (0, node_crypto_1.createHash)('sha256')
|
|
189
|
+
.update(JSON.stringify({ moves: out.moves, unresolved: out.unresolved, remoteRefs: out.remoteRefs, rewrites: out.rewrites })).digest('hex');
|
|
190
|
+
return { ok: true, plan: out };
|
|
191
|
+
};
|
|
52
192
|
return {
|
|
53
193
|
/**
|
|
54
194
|
* Move a document to `outdated` — the only path there.
|
|
@@ -156,84 +296,116 @@ function createSpecLifecycleHandlers(context) {
|
|
|
156
296
|
});
|
|
157
297
|
return { ok: true, retired: true, id: a.id, dependents: dependents.map((s) => s.id) };
|
|
158
298
|
},
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
299
|
+
/**
|
|
300
|
+
* @implements A-SPEC-700.2
|
|
301
|
+
* The plan a person SAW, executed under ONE approval, before the merge. `plan` reads; `apply`
|
|
302
|
+
* rebuilds the plan, refuses `plan-changed` if it is not the one that was seen, demands a
|
|
303
|
+
* `config-write` approval bound to the plan's `target`, then runs every move through the SAME
|
|
304
|
+
* path `spec_renumber` uses, inside one store hold. It never seals: `spec_approve` is the only
|
|
305
|
+
* sealer, so `approveOrder` is returned and the caller runs it.
|
|
306
|
+
*/
|
|
307
|
+
async spec_reconcile(a) {
|
|
167
308
|
const specsRoot = store.specsRoot;
|
|
168
309
|
if (typeof specsRoot !== 'string')
|
|
169
|
-
return { ok: false, reason: '파일 스토어에 묶인 서버에서만
|
|
170
|
-
|
|
310
|
+
return { ok: false, reason: '파일 스토어에 묶인 서버에서만 번호 충돌을 수습할 수 있습니다.' };
|
|
311
|
+
if (a.operation !== 'plan' && a.operation !== 'apply')
|
|
312
|
+
return { ok: false, reason: "operation must be 'plan' or 'apply'" };
|
|
313
|
+
if (a.operation === 'plan') {
|
|
314
|
+
const computed = computeReconcile(specsRoot);
|
|
315
|
+
return computed.ok ? { ok: true, operation: 'plan', plan: computed.plan } : computed;
|
|
316
|
+
}
|
|
317
|
+
const seen = a.plan;
|
|
318
|
+
if (!seen || typeof seen !== 'object')
|
|
319
|
+
return { ok: false, reason: "apply requires `plan` — the object `operation: 'plan'` returned" };
|
|
320
|
+
if (seen.schema !== RECONCILE_SCHEMA || typeof seen.target !== 'string')
|
|
321
|
+
return { ok: false, reason: `apply requires a plan with schema ${RECONCILE_SCHEMA} and its target` };
|
|
322
|
+
const execute = (journaled) => {
|
|
323
|
+
// Recomputed INSIDE the hold: the plan that runs is the plan that was compared.
|
|
324
|
+
const computed = computeReconcile(specsRoot);
|
|
325
|
+
if (!computed.ok)
|
|
326
|
+
return computed;
|
|
327
|
+
const plan = computed.plan;
|
|
328
|
+
if (plan.target !== seen.target)
|
|
329
|
+
return { ok: false, code: 'plan-changed', reason: `the plan changed since it was seen (seen ${seen.target}, now ${plan.target}) — a fetch or a new spec moved it. Plan again and approve that one.`, plan };
|
|
330
|
+
if (plan.moves.length === 0)
|
|
331
|
+
return { ok: true, operation: 'apply', moved: [], approveOrder: [], proseCandidates: [], reason: 'nothing to move' };
|
|
332
|
+
const approvalRaw = process.env.HOLMES_APPROVAL;
|
|
333
|
+
let approval;
|
|
171
334
|
try {
|
|
172
|
-
|
|
173
|
-
return { ok: false, reason: 'An activated store requires entity_renumber to preserve entity UUIDs and references; raw legacy renumber is unavailable.' };
|
|
335
|
+
approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
|
|
174
336
|
}
|
|
175
|
-
catch
|
|
176
|
-
|
|
177
|
-
if (!(error instanceof entity_transaction_1.EntityStoreError) || error.code !== 'missing-workspace') {
|
|
178
|
-
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
179
|
-
return { ok: false, reason: error.message };
|
|
180
|
-
throw error;
|
|
181
|
-
}
|
|
337
|
+
catch {
|
|
338
|
+
approval = undefined;
|
|
182
339
|
}
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
specs: read.specs,
|
|
187
|
-
sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
|
|
188
|
-
oldBase: String(a.oldBase), newBase: String(a.newBase),
|
|
189
|
-
});
|
|
190
|
-
// @implements A-SPEC-699 — a refusal that cannot say whether the store was empty or merely
|
|
191
|
-
// unreadable sends the caller to the source. Measured 2026-09-20: this tool answered
|
|
192
|
-
// "nothing to move" while 300-odd specs sat in front of it, and the answer alone could not
|
|
193
|
-
// distinguish the two. Carry the counts, and name the files while there are few.
|
|
194
|
-
if (plan.refusal)
|
|
340
|
+
const action = { kind: 'config-write', target: plan.target };
|
|
341
|
+
const resolved = context.resolveHandlerApproval(a.root, approval, action, new Date().toISOString());
|
|
342
|
+
if (!resolved)
|
|
195
343
|
return {
|
|
196
344
|
ok: false,
|
|
197
|
-
reason: plan.
|
|
198
|
-
+
|
|
199
|
-
+ (read.unreadable.length > 0
|
|
200
|
-
? `, 프론트매터를 읽지 못한 파일 ${read.unreadable.length}개: ${read.unreadable.slice(0, 5).join(', ')}${read.unreadable.length > 5 ? ' …' : ''})`
|
|
201
|
-
: ')'),
|
|
202
|
-
...(read.unreadable.length > 0 ? { unreadable: read.unreadable } : {}),
|
|
345
|
+
reason: `spec_reconcile apply rewrites approved documents and source anchors — it requires one approval covering { kind: "config-write", target: "${plan.target}" }.`
|
|
346
|
+
+ context.refusalQueueHint(a.root, { ...action, why: `번호 충돌 수습: ${plan.moves.map((m) => `${m.from}→${m.to}`).join(', ')}` }),
|
|
203
347
|
};
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
(0, entity_transaction_1.publishEntityRecord)(journaled.root, '.ax/state/entity-transactions/' + operationId + '/journal.json', text);
|
|
223
|
-
retire = () => (0, entity_transaction_1.retireEntityOperation)(journaled.root, operationId, journaled.locator, (0, entity_transaction_1.entityContentVersion)(text));
|
|
348
|
+
// @implements A-SPEC-245 — a grant that authorized the move is spent by it.
|
|
349
|
+
if (resolved.source === 'grant' && resolved.root && resolved.approval.nonce)
|
|
350
|
+
(0, approval_grants_1.consumeGrantFile)(resolved.root, resolved.approval.nonce);
|
|
351
|
+
const moved = [];
|
|
352
|
+
for (const move of plan.moves) {
|
|
353
|
+
// A throw mid-move (disk, permissions) is reported like a refusal: the caller must learn
|
|
354
|
+
// which families DID move, or the only way to find out is to read the tree.
|
|
355
|
+
let result;
|
|
356
|
+
try {
|
|
357
|
+
result = renumberRun(specsRoot, { oldBase: move.from, newBase: move.to, dryRun: false }, journaled);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
result = { ok: false, reason: String(error instanceof Error ? error.message : error).split('\n')[0] };
|
|
361
|
+
}
|
|
362
|
+
// Finished moves stay finished and no longer collide, so planning again yields the rest.
|
|
363
|
+
if (!result.ok)
|
|
364
|
+
return { ok: false, reason: `move ${move.from} → ${move.to} stopped: ${result.reason}`, moved, remaining: plan.moves.slice(moved.length) };
|
|
365
|
+
moved.push({ from: move.from, to: move.to, specs: result.movedSpecs ?? 0 });
|
|
224
366
|
}
|
|
225
|
-
|
|
226
|
-
(0, renumber_1.applyRenumber)(projectRoot, { ...plan, moves: [], dependsOn: [], slices: [] });
|
|
227
|
-
new ledger_store_1.FileLedgerStore(path.join(projectRoot, '.ax', 'ledger')).append({
|
|
367
|
+
new ledger_store_1.FileLedgerStore(path.join(path.resolve(specsRoot, '..', '..'), '.ax', 'ledger')).append({
|
|
228
368
|
ts: new Date().toISOString(),
|
|
229
|
-
actor: '
|
|
230
|
-
kind: 'spec-
|
|
231
|
-
summary: `
|
|
232
|
-
inputs: plan.
|
|
369
|
+
actor: 'spec_reconcile',
|
|
370
|
+
kind: 'spec-reconciled',
|
|
371
|
+
summary: `reconciled ${moved.length} base(s) against ${plan.remoteRefs.join(', ')}: ${moved.map((m) => `${m.from}->${m.to}`).join(', ')}; approved by ${resolved.approval.actor ?? 'unknown'}`,
|
|
372
|
+
inputs: [plan.target, ...moved.map((m) => `${m.from}->${m.to}`), ...plan.remoteRefs],
|
|
373
|
+
authorization: (0, provenance_chain_1.authorizationRef)(resolved.approval.actor, resolved.approval.token),
|
|
233
374
|
});
|
|
234
|
-
|
|
235
|
-
return { ok: true, dryRun: false, movedSpecs, plan };
|
|
375
|
+
return { ok: true, operation: 'apply', moved, approveOrder: plan.approveOrder, proseCandidates: plan.proseCandidates };
|
|
236
376
|
};
|
|
377
|
+
let bound;
|
|
378
|
+
try {
|
|
379
|
+
bound = (0, entity_store_1.entityStoreBinding)(specsRoot);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (error instanceof entity_transaction_1.EntityStoreError && error.code === 'missing-workspace')
|
|
383
|
+
return execute();
|
|
384
|
+
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
385
|
+
return { ok: false, reason: error.message };
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
return (0, entity_transaction_1.withEntityStoreLock)(bound.root, bound.locator, () => execute({ root: bound.root, locator: bound.locator }));
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
393
|
+
return { ok: false, reason: error.message };
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
// @implements A-SPEC-255 — the WIRING only. The judgment is `planRenumber`, which is pure and
|
|
398
|
+
// tested directly; this handler adds no rules of its own. Re-sealing is deliberately absent:
|
|
399
|
+
// `spec_approve` is the only sealer, so the plan reports the two ORDERS and the caller runs them.
|
|
400
|
+
// @implements A-SPEC-255 — WIRING only. The judgment lives in `planRenumber`, which is pure and
|
|
401
|
+
// tested directly; nothing here adds a rule. Re-sealing is deliberately absent: `spec_approve`
|
|
402
|
+
// is the only sealer (a second sealer becomes a second truth), so the plan reports the two
|
|
403
|
+
// ORDERS and the caller runs them. `dryRun` defaults to true — a renumber is read before it runs.
|
|
404
|
+
async spec_renumber(a) {
|
|
405
|
+
const specsRoot = store.specsRoot;
|
|
406
|
+
if (typeof specsRoot !== 'string')
|
|
407
|
+
return { ok: false, reason: '파일 스토어에 묶인 서버에서만 리넘버할 수 있습니다.' };
|
|
408
|
+
const run = (journaled) => renumberRun(specsRoot, a, journaled);
|
|
237
409
|
if (a.dryRun !== false)
|
|
238
410
|
return run();
|
|
239
411
|
let bound;
|
|
@@ -395,6 +395,11 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
395
395
|
pending?: undefined;
|
|
396
396
|
code?: undefined;
|
|
397
397
|
}>;
|
|
398
|
+
spec_reconcile: (a: {
|
|
399
|
+
root?: string;
|
|
400
|
+
operation?: string;
|
|
401
|
+
plan?: unknown;
|
|
402
|
+
}) => Promise<Record<string, unknown>>;
|
|
398
403
|
rtm_impact: (a: {
|
|
399
404
|
root: string;
|
|
400
405
|
changed: string[];
|
|
@@ -930,6 +930,8 @@ function makeRawHandlers(store, opts) {
|
|
|
930
930
|
// citation_pin+4, phase_check+3, cpg_scan+3, taint_scan..issue_localize, test_run+3, issue_localize+3,
|
|
931
931
|
// rtm_impact+4, rtm_reindex..review_record, review_prepare..risk_check, risk_check±, and the tail).
|
|
932
932
|
ledger_reconcile: operatorInspection.ledger_reconcile,
|
|
933
|
+
// @implements A-SPEC-700.2 — same unpinned slot: `spec_retire..spec_unseal` is a pinned run.
|
|
934
|
+
spec_reconcile: specLifecycle.spec_reconcile,
|
|
933
935
|
rtm_impact: graphOperations.rtm_impact,
|
|
934
936
|
rtm_reindex: graphOperations.rtm_reindex,
|
|
935
937
|
context_bundle: reviewQueries.context_bundle,
|
|
@@ -145,6 +145,19 @@ exports.TOOL_SCHEMAS = {
|
|
|
145
145
|
required: ['oldBase', 'newBase'],
|
|
146
146
|
},
|
|
147
147
|
},
|
|
148
|
+
// @implements A-SPEC-700.2
|
|
149
|
+
spec_reconcile: {
|
|
150
|
+
description: "Before a merge: find spec numbers this checkout ADDED that a remote-tracking ref also added with different content, plan which side moves and where, and apply that exact plan under ONE approval. `plan` is read-only and needs no network (remote-tracking refs are read as last fetched; `fetch` is yours): it returns the moves (the side NOT published yet moves; the destination is free on both sides), what each move rewrites, the documents to re-seal afterwards, prose candidates, and a `target` derived from the plan's content. `apply` takes that plan back VERBATIM, recomputes it, and refuses `plan-changed` if anything moved in between; it then requires an out-of-band approval of kind `config-write` bound to `target`, and moves each base through the same journaled path `spec_renumber` uses. It rewrites only places with a definite grammar (filenames, frontmatter `id`, `depends_on`, `slice`, source `@implements` anchors); prose is REPORTED, never substituted. It does NOT re-seal — `spec_approve` remains the only sealer; `approveOrder` is parent-first. An empty plan applies without asking anyone. An adopted (entity) store is refused: use `entity_renumber` there.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
operation: { type: 'string', enum: ['plan', 'apply'], description: 'plan = read-only; apply = execute the exact plan returned by plan.' },
|
|
155
|
+
plan: { type: 'object', description: 'apply only: the entire unchanged plan object returned by operation "plan".' },
|
|
156
|
+
root: str('Optional when the server is bound to a file store; if supplied it must resolve to the SAME project.'),
|
|
157
|
+
},
|
|
158
|
+
required: ['operation'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
148
161
|
spec_unseal: {
|
|
149
162
|
description: "The inverse of spec_approve: return ONE sealed (approved) spec to an editable `status: draft`, clearing `approved_digest` and `parent_digests` in a single ACT, and record `spec-unsealed` in the provenance ledger. Un-sealing WITHDRAWS a seal, so it requires the same out-of-band HOLMES_APPROVAL as spec_approve/spec_retire's sealed path (fail-closed): un-sealing an approved T-SPEC removes the code gate's demand, which unguarded would be an approval bypass. Refuses when an APPROVED spec depends on the target — that dependent's parent_digests would silently go stale — naming the blockers. Idempotent: un-sealing a spec that is already draft (never sealed) writes nothing and returns unsealed:false. Written only at the version this act read; a concurrent edit wins and the un-seal is refused for retry. Distinct from spec_retire (which withdraws authority to status:outdated); un-seal keeps the spec alive and editable.",
|
|
150
163
|
inputSchema: {
|
|
@@ -148,10 +148,8 @@ function validUuid(v) { return typeof v === 'string' && UUID.test(v); }
|
|
|
148
148
|
function locator(v) {
|
|
149
149
|
return typeof v === 'string' && v.length > 0 && !/[\\:\x00]/.test(v) && v.split('/').every(p => p.length > 0 && p !== '.' && p !== '..');
|
|
150
150
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (!bytes)
|
|
154
|
-
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'An entity record disappeared.');
|
|
151
|
+
/** @implements A-SPEC-701 — the parse, shared, so a caller that already HOLDS the bytes never reads the file again. */
|
|
152
|
+
function parseRecord(bytes) {
|
|
155
153
|
try {
|
|
156
154
|
return JSON.parse(bytes.toString('utf8'));
|
|
157
155
|
}
|
|
@@ -159,6 +157,12 @@ function recordJson(file) {
|
|
|
159
157
|
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Entity state contains malformed JSON.');
|
|
160
158
|
}
|
|
161
159
|
}
|
|
160
|
+
function recordJson(file) {
|
|
161
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(file);
|
|
162
|
+
if (!bytes)
|
|
163
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'An entity record disappeared.');
|
|
164
|
+
return parseRecord(bytes);
|
|
165
|
+
}
|
|
162
166
|
function validateEntityAdoptionPlan(value) {
|
|
163
167
|
const p = object(value, ['schema', 'operationId', 'workspaceId', 'storeId', 'storeLocator', 'entries']);
|
|
164
168
|
if (p.schema !== 'holmes-entity-adoption/1' || !validUuid(p.operationId) || !validUuid(p.workspaceId) || !validUuid(p.storeId) || !locator(p.storeLocator) || !Array.isArray(p.entries))
|
|
@@ -247,9 +251,27 @@ function assertNoPending(bound) {
|
|
|
247
251
|
const op = (0, entity_transaction_1.entityDirectory)(dir, name);
|
|
248
252
|
// @implements A-SPEC-634 — a pending directory without a journal is an operation that died
|
|
249
253
|
// between taking its locks and publishing; name it, do not fail on the missing record.
|
|
250
|
-
|
|
254
|
+
//
|
|
255
|
+
// @implements A-SPEC-701 — the journal is read ONCE. It used to be read twice (once to see it
|
|
256
|
+
// exists, once to parse it) while `retireEntityOperation` moves the whole operation directory
|
|
257
|
+
// with a single rename — so an operation that FINISHED while this lock-free scan was looking
|
|
258
|
+
// was reported either as "interrupted before its journal existed" (retired before the first
|
|
259
|
+
// read) or as "An entity record disappeared." (retired between the two). Measured 2026-09-20 on
|
|
260
|
+
// the Linux CI: one of four racing adopters got the second, a code outside the three that race
|
|
261
|
+
// calls legal. Both refusals were safe and both were false.
|
|
262
|
+
//
|
|
263
|
+
// What separates the two causes of "no journal" is whether the DIRECTORY remains. Retirement
|
|
264
|
+
// moves it whole, so there is no ordinary path that leaves the directory and takes the journal:
|
|
265
|
+
// directory present + no journal is A-SPEC-634's dead operation and is still refused by name;
|
|
266
|
+
// directory gone is an operation that is simply no longer pending. The real write re-runs this
|
|
267
|
+
// scan under the lock, so skipping a finished operation here cannot admit a conflicting one.
|
|
268
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(path.join(op, 'journal.json'));
|
|
269
|
+
if (!bytes) {
|
|
270
|
+
if (!(0, entity_transaction_1.entityStat)(op))
|
|
271
|
+
continue;
|
|
251
272
|
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Operation ' + name + ' was interrupted before its journal existed; inspect entity_store recovery-plan and recover it.');
|
|
252
|
-
|
|
273
|
+
}
|
|
274
|
+
const raw = parseRecord(bytes);
|
|
253
275
|
if (raw?.schema === 'holmes-entity-integration-journal/1') {
|
|
254
276
|
const journal = object(raw, ['schema', 'plan', 'storeOwner', 'sourceOwner']);
|
|
255
277
|
const plan = journal.plan;
|
|
@@ -230,5 +230,8 @@ function reconcileAdvice(plan) {
|
|
|
230
230
|
];
|
|
231
231
|
if (parts.length === 0)
|
|
232
232
|
return '';
|
|
233
|
-
|
|
233
|
+
// @implements A-SPEC-700.2 — name the tool that runs the whole plan under one approval. APPENDED:
|
|
234
|
+
// the per-move sentence stays, because `spec_renumber` is still how one family is moved by hand.
|
|
235
|
+
return parts.join('; ') + (plan.moves.length > 0
|
|
236
|
+
? '; then re-seal with spec_approve, and merge (spec_reconcile plan → apply runs every move under one approval)' : '');
|
|
234
237
|
}
|
|
@@ -14,3 +14,12 @@ export interface RemoteAddedSpecs {
|
|
|
14
14
|
/** Runs one git command and returns its stdout as a Buffer. Injectable so a failure can be staged. */
|
|
15
15
|
export type GitRunner = (root: string, args: string[], input?: string) => Buffer;
|
|
16
16
|
export declare function collectRemoteAddedSpecs(root: string, git?: GitRunner): RemoteAddedSpecs;
|
|
17
|
+
/**
|
|
18
|
+
* The local half of the comparison, spelled the way the remote half is: store-relative posix path,
|
|
19
|
+
* BOM stripped, CRLF folded, `collisionKeyOf` as the identity. Regular files only — a FIFO named
|
|
20
|
+
* `*.md` blocks `open(2)` forever (doctor round-6), so the type is asked before the read.
|
|
21
|
+
*/
|
|
22
|
+
export declare function collectLocalSpecEntries(specsRoot: string): {
|
|
23
|
+
entries: IdCollisionEntry[];
|
|
24
|
+
skipped: number;
|
|
25
|
+
};
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.collectRemoteAddedSpecs = collectRemoteAddedSpecs;
|
|
37
|
+
exports.collectLocalSpecEntries = collectLocalSpecEntries;
|
|
4
38
|
// @implements A-SPEC-254.2
|
|
5
39
|
/**
|
|
6
40
|
* The merge-time half of REQ-254: which spec documents did each remote-tracking ref ADD since this
|
|
@@ -24,6 +58,8 @@ exports.collectRemoteAddedSpecs = collectRemoteAddedSpecs;
|
|
|
24
58
|
* READ-ONLY. The only git subcommands are for-each-ref, merge-base, diff, ls-tree and cat-file.
|
|
25
59
|
*/
|
|
26
60
|
const node_child_process_1 = require("node:child_process");
|
|
61
|
+
const fs = __importStar(require("node:fs"));
|
|
62
|
+
const path = __importStar(require("node:path"));
|
|
27
63
|
const spec_parser_1 = require("./spec-parser");
|
|
28
64
|
const id_collision_1 = require("./id-collision");
|
|
29
65
|
const root_1 = require("../project/root");
|
|
@@ -146,3 +182,55 @@ function collectRemoteAddedSpecs(root, git = defaultGit) {
|
|
|
146
182
|
}
|
|
147
183
|
return result;
|
|
148
184
|
}
|
|
185
|
+
// @implements A-SPEC-700.2
|
|
186
|
+
/**
|
|
187
|
+
* The local half of the comparison, spelled the way the remote half is: store-relative posix path,
|
|
188
|
+
* BOM stripped, CRLF folded, `collisionKeyOf` as the identity. Regular files only — a FIFO named
|
|
189
|
+
* `*.md` blocks `open(2)` forever (doctor round-6), so the type is asked before the read.
|
|
190
|
+
*/
|
|
191
|
+
function collectLocalSpecEntries(specsRoot) {
|
|
192
|
+
const entries = [];
|
|
193
|
+
let skipped = 0;
|
|
194
|
+
const walk = (dir) => {
|
|
195
|
+
let listed;
|
|
196
|
+
try {
|
|
197
|
+
listed = fs.readdirSync(dir, { withFileTypes: true });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
skipped++;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
for (const e of listed.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
204
|
+
const p = path.join(dir, e.name);
|
|
205
|
+
if (e.isDirectory()) {
|
|
206
|
+
walk(p);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!e.name.endsWith('.md'))
|
|
210
|
+
continue;
|
|
211
|
+
if (!e.isFile()) {
|
|
212
|
+
skipped++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const folded = fs.readFileSync(p, 'utf8').replace(/^/, '').replace(/\r\n/g, '\n');
|
|
217
|
+
const spec = (0, spec_parser_1.parseSpec)(folded);
|
|
218
|
+
if (!spec.id) {
|
|
219
|
+
skipped++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
entries.push({
|
|
223
|
+
file: path.relative(specsRoot, p).split(path.sep).join('/'),
|
|
224
|
+
id: spec.id,
|
|
225
|
+
approvedDigest: typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined,
|
|
226
|
+
contentDigest: (0, id_collision_1.collisionKeyOf)(folded, spec),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
skipped++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
walk(specsRoot);
|
|
235
|
+
return { entries, skipped };
|
|
236
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@implements A-SPEC-209",
|
|
3
3
|
"name": "@holmes-lab/holmes-kit",
|
|
4
|
-
"version": "0.26.
|
|
4
|
+
"version": "0.26.2",
|
|
5
5
|
"description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
|
|
6
6
|
"main": "dist/holmes/mcp/server.js",
|
|
7
7
|
"types": "dist/holmes/mcp/server.d.ts",
|