@holmes-lab/holmes-kit 0.19.2 → 0.19.4
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 +177 -0
- package/README.md +22 -4
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +8 -0
- package/dist/holmes/cli/agents.js +26 -2
- package/dist/holmes/cli/codex-toml.d.ts +10 -0
- package/dist/holmes/cli/codex-toml.js +76 -12
- package/dist/holmes/cli/doctor.d.ts +34 -0
- package/dist/holmes/cli/doctor.js +139 -46
- package/dist/holmes/cli/index.js +13 -0
- package/dist/holmes/cli/init.js +10 -3
- package/dist/holmes/cli/native-deps.js +4 -1
- package/dist/holmes/cli/playbook-skills.js +6 -4
- package/dist/holmes/cli/probe-process.d.ts +8 -0
- package/dist/holmes/cli/probe-process.js +73 -0
- package/dist/holmes/cli/spawn-spec.js +3 -1
- package/dist/holmes/cli/test-platform.d.ts +37 -0
- package/dist/holmes/cli/test-platform.js +126 -1
- package/dist/holmes/governance/approval-grants.js +26 -3
- package/dist/holmes/governance/autonomy.d.ts +17 -1
- package/dist/holmes/governance/autonomy.js +37 -5
- package/dist/holmes/mcp/handlers.d.ts +30 -5
- package/dist/holmes/mcp/handlers.js +111 -13
- package/dist/holmes/mcp/spec-id-guard.d.ts +1 -1
- package/dist/holmes/mcp/spec-id-guard.js +9 -13
- package/dist/holmes/mcp/tool-schemas.js +13 -0
- package/dist/holmes/project/install-scripts-policy.d.ts +16 -2
- package/dist/holmes/project/install-scripts-policy.js +16 -2
- package/dist/holmes/review/point-in-time-replay.js +43 -3
- package/dist/holmes/rtm/graph-store.d.ts +2 -0
- package/dist/holmes/rtm/graph-store.js +14 -0
- package/dist/holmes/rtm/rtm-graph.js +42 -30
- package/dist/holmes/semantic/credentials.js +86 -9
- package/dist/holmes/semantic/embedder.js +6 -39
- package/dist/holmes/semantic/local-model.d.ts +30 -0
- package/dist/holmes/semantic/local-model.js +92 -0
- package/dist/holmes/semantic/model-cache.d.ts +8 -0
- package/dist/holmes/semantic/model-cache.js +67 -0
- package/dist/holmes/semantic/tier.d.ts +7 -0
- package/dist/holmes/semantic/tier.js +9 -3
- package/dist/holmes/spec/renumber.d.ts +72 -0
- package/dist/holmes/spec/renumber.js +341 -0
- package/dist/holmes/spec/spec-id.d.ts +9 -0
- package/dist/holmes/spec/spec-id.js +23 -0
- package/docs/install-guide.md +90 -2
- package/package.json +6 -3
- package/scripts/install.ps1 +30 -27
|
@@ -1,8 +1,48 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
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
|
+
})();
|
|
3
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
36
|
exports.posixOnly = posixOnly;
|
|
5
37
|
exports.posixOnlyNonRoot = posixOnlyNonRoot;
|
|
38
|
+
exports.foldsCase = foldsCase;
|
|
39
|
+
exports.posixOnlyWithCommand = posixOnlyWithCommand;
|
|
40
|
+
exports.win32Only = win32Only;
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const path = __importStar(require("node:path"));
|
|
43
|
+
const node_crypto_1 = require("node:crypto");
|
|
44
|
+
// @implements A-SPEC-220.3
|
|
45
|
+
// @implements A-SPEC-597
|
|
6
46
|
/**
|
|
7
47
|
* A test that cannot run here says so, instead of returning early and being counted as a pass.
|
|
8
48
|
*
|
|
@@ -36,3 +76,88 @@ function posixOnlyNonRoot(reason, platform = process.platform, uid = typeof proc
|
|
|
36
76
|
const base = posixOnly(reason, platform);
|
|
37
77
|
return uid === 0 ? it.skip : base;
|
|
38
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* @implements A-SPEC-597
|
|
81
|
+
* Whether THIS directory's filesystem folds case — measured, never inferred from the platform.
|
|
82
|
+
*
|
|
83
|
+
* macOS defaults to case-insensitive APFS and Linux to case-sensitive ext4, but neither is a rule:
|
|
84
|
+
* a case-sensitive volume mounts fine on macOS, and `os.tmpdir()` need not share the repository's
|
|
85
|
+
* volume. The same reasoning already governs the write gate (`pre-tool-use.ts`), which probes the
|
|
86
|
+
* project root rather than trusting `process.platform`; a test must probe the directory it actually
|
|
87
|
+
* writes into.
|
|
88
|
+
*
|
|
89
|
+
* An unanswerable probe returns FALSE. Claiming "folds" when it cannot tell would let a
|
|
90
|
+
* folding-world assertion run — and pass vacuously — on a case-sensitive box; claiming "sensitive"
|
|
91
|
+
* merely runs the other assertion, which fails loudly if it is wrong. Be wrong toward the side that
|
|
92
|
+
* still produces information.
|
|
93
|
+
*/
|
|
94
|
+
function foldsCase(dir) {
|
|
95
|
+
if (typeof dir !== 'string' || dir === '')
|
|
96
|
+
return false;
|
|
97
|
+
const probe = path.join(dir, `.case-probe-${process.pid}-${(0, node_crypto_1.randomUUID)()}`);
|
|
98
|
+
try {
|
|
99
|
+
fs.writeFileSync(probe, '');
|
|
100
|
+
const flipped = path.join(dir, path.basename(probe).toUpperCase());
|
|
101
|
+
return fs.existsSync(flipped);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
// The probe leaves no trace, on every path out of here.
|
|
108
|
+
try {
|
|
109
|
+
fs.rmSync(probe, { force: true });
|
|
110
|
+
}
|
|
111
|
+
catch { /* nothing was created */ }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** PATH lookup without a shell — spawning one to ask about a command is a cost and a surface. */
|
|
115
|
+
function onPath(command) {
|
|
116
|
+
if (typeof command !== 'string' || command === '')
|
|
117
|
+
return false;
|
|
118
|
+
if (command.includes('/') || command.includes('\\'))
|
|
119
|
+
return fs.existsSync(command);
|
|
120
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
121
|
+
const exts = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
|
|
122
|
+
for (const dir of (process.env.PATH ?? '').split(sep)) {
|
|
123
|
+
if (dir === '')
|
|
124
|
+
continue;
|
|
125
|
+
for (const ext of exts) {
|
|
126
|
+
try {
|
|
127
|
+
if (fs.existsSync(path.join(dir, command + ext)))
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
catch { /* unreadable PATH entry */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* @implements A-SPEC-597
|
|
137
|
+
* As `posixOnlyNonRoot`, and additionally skipped when `command` is not on PATH.
|
|
138
|
+
*
|
|
139
|
+
* `chflags` is macOS/BSD only. Guarded by platform and uid alone, the assertion that uses it RAN on
|
|
140
|
+
* Linux and died with ENOENT — which reads as a defect in the code under test rather than as a tool
|
|
141
|
+
* this machine does not have. Availability of the tool is knowable at definition time, exactly like
|
|
142
|
+
* the platform and the uid, so it belongs in the same choice.
|
|
143
|
+
*/
|
|
144
|
+
function posixOnlyWithCommand(reason, command, platform = process.platform, uid = typeof process.getuid === 'function' ? process.getuid() : undefined, lookup = onPath) {
|
|
145
|
+
const base = posixOnlyNonRoot(reason, platform, uid); // reason is validated here, before anything else
|
|
146
|
+
return lookup(command) ? base : it.skip;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* @implements A-SPEC-255
|
|
150
|
+
* The mirror of `posixOnly`: a test whose SUBJECT only exists on Windows.
|
|
151
|
+
*
|
|
152
|
+
* Without this, the Windows work had nowhere to put such a test and improvised
|
|
153
|
+
* `(process.platform === 'win32' ? it : it.skip)('…')` — a skip with no stated reason, which is the
|
|
154
|
+
* silence `posixOnly` was written to end, in different syntax. NTFS ACLs, PowerShell 5.1 parsing and
|
|
155
|
+
* 8.3 short names are real subjects; they simply cannot be exercised on a POSIX box, and saying so
|
|
156
|
+
* in the report is the honest form. `reason` is required for the same reason it is required there.
|
|
157
|
+
*/
|
|
158
|
+
function win32Only(reason, platform = process.platform) {
|
|
159
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
|
160
|
+
throw new Error('win32Only(reason): 사유 없는 스킵은 문법만 바뀐 침묵입니다 — 왜 이 단언이 POSIX에서 무의미한지 적으십시오.');
|
|
161
|
+
}
|
|
162
|
+
return platform === 'win32' ? it : it.skip;
|
|
163
|
+
}
|
|
@@ -132,11 +132,34 @@ function resolveApproval(root, envApproval, action, now) {
|
|
|
132
132
|
* The nonce is flattened to a basename so a traversal-shaped nonce cannot reach outside the dir.
|
|
133
133
|
*/
|
|
134
134
|
function consumeGrantFile(root, nonce) {
|
|
135
|
+
// @implements A-SPEC-596 — address the grant the SAME way `readGrants` does: by the nonce INSIDE
|
|
136
|
+
// the file, never by a path built from it. Building the path meant a grant whose filename differed
|
|
137
|
+
// from its nonce was honoured (reading scans, and never compares the two) yet never spent, with
|
|
138
|
+
// `force: true` swallowing the miss — a single-use grant that stayed spendable forever. Scanning
|
|
139
|
+
// also makes traversal structurally impossible: no nonce becomes a path, so none can point out.
|
|
140
|
+
const dir = path.join(root, exports.GRANTS_RELDIR);
|
|
141
|
+
const wanted = String(nonce);
|
|
142
|
+
let removed = 0;
|
|
135
143
|
try {
|
|
136
|
-
const name
|
|
137
|
-
|
|
144
|
+
for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.json'))) {
|
|
145
|
+
const file = path.join(dir, name);
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
148
|
+
if (String(parsed?.nonce) !== wanted)
|
|
149
|
+
continue; // someone else's grant is not ours to spend
|
|
150
|
+
fs.rmSync(file, { force: true });
|
|
151
|
+
removed++;
|
|
152
|
+
}
|
|
153
|
+
catch { /* corrupt or vanished: it was never honoured either, so it is not ours to spend */ }
|
|
154
|
+
}
|
|
138
155
|
}
|
|
139
156
|
catch {
|
|
140
|
-
|
|
157
|
+
// No directory, or it cannot be read: there is nothing to spend, which is an ordinary state.
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (removed === 0) {
|
|
161
|
+
// Consumption runs AFTER the act succeeded, so this must never throw — but it must not be silent
|
|
162
|
+
// either: an unspent single-use grant is still spendable, and nobody would know.
|
|
163
|
+
process.stderr.write(`[Holmes-Kit] approval grant was used but no grant file matched it — the grant may still be spendable. Remove it by hand from ${exports.GRANTS_RELDIR}.\n`);
|
|
141
164
|
}
|
|
142
165
|
}
|
|
@@ -9,7 +9,23 @@ export declare function isHighRiskPath(p: string): boolean;
|
|
|
9
9
|
* parent-aware rules (kept in the signature so callers wire it once); the current bound is decided
|
|
10
10
|
* from the spec itself.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* @implements A-SPEC-587
|
|
14
|
+
* The A-SPECs whose chain leads up to `spec`: one hop for an H-SPEC (A-SPECs that depend on it),
|
|
15
|
+
* two for a REQ (through its H-SPECs). Anything else has no downstream A-SPECs. Pure; `depends_on`
|
|
16
|
+
* is the only edge this repository's chain uses.
|
|
17
|
+
*/
|
|
18
|
+
export declare function descendantAspecs(spec: Spec, all: Spec[]): Spec[];
|
|
19
|
+
/**
|
|
20
|
+
* @implements A-SPEC-587
|
|
21
|
+
* The autonomy verdict for approving THIS spec. `resolveParent` is accepted for future parent-aware
|
|
22
|
+
* rules. `descendants` — the A-SPECs beneath a REQ/H-SPEC (see `descendantAspecs`) — is what lets an
|
|
23
|
+
* upstream document be graded by the code it will admit: a REQ or H-SPEC carries no code of its
|
|
24
|
+
* own, so its risk IS the risk its A-SPECs declare (REQ-587, the owner's line: only irreversible,
|
|
25
|
+
* architectural or high-risk changes ask a human). Every downstream A-SPEC auto → auto; any hitl,
|
|
26
|
+
* or no A-SPEC at all (unknown scope) → human. The two-argument call keeps the old answer.
|
|
27
|
+
*/
|
|
28
|
+
export declare function specApprovalAutonomy(spec: Spec, resolveParent: (id: string) => Spec | null, descendants?: Spec[]): ApprovalAutonomy;
|
|
13
29
|
export declare const SESSION_AUTONOMY_MARKER: readonly [".ax", "state", "autonomy.json"];
|
|
14
30
|
export declare function sessionAutonomyActive(root: string, now: string): boolean;
|
|
15
31
|
/** Whether autonomous approval is enabled — the out-of-band env switch, OR a valid session envelope
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.SESSION_AUTONOMY_MARKER = exports.AUTONOMY_ENV = void 0;
|
|
37
37
|
exports.isHighRiskPath = isHighRiskPath;
|
|
38
|
+
exports.descendantAspecs = descendantAspecs;
|
|
38
39
|
exports.specApprovalAutonomy = specApprovalAutonomy;
|
|
39
40
|
exports.sessionAutonomyActive = sessionAutonomyActive;
|
|
40
41
|
exports.projectDefaultAutonomy = projectDefaultAutonomy;
|
|
@@ -48,8 +49,9 @@ exports.releaseAutonomy = releaseAutonomy;
|
|
|
48
49
|
// the MCP elicitation TUI. The governance chain (REQ→H→A→T→CPG + RTM + taint + phase gate) already
|
|
49
50
|
// forces quality structurally, so a low/mid-risk spec self-approving under an explicitly-enabled
|
|
50
51
|
// autonomous mode is a small, bounded relaxation (REQ-532). The bound is what this module owns: the
|
|
51
|
-
// governance-critical specs — gate behaviour, architecture, taint/security boundaries
|
|
52
|
-
// upstream REQ/H-SPEC
|
|
52
|
+
// governance-critical specs — gate behaviour, architecture (C-SPEC/ADR), taint/security boundaries —
|
|
53
|
+
// never leave the human channel. An upstream REQ/H-SPEC is graded by the A-SPECs beneath it
|
|
54
|
+
// (REQ-587): all auto → auto; any hitl, or none yet → human.
|
|
53
55
|
//
|
|
54
56
|
// PURE: the spec, a parent resolver, and (separately) the env are the only inputs; the wiring layer
|
|
55
57
|
// (A-SPEC-532.2) injects process.env and the elicitor.
|
|
@@ -126,15 +128,45 @@ const AUTO_GRADES = new Set(['none', 'persisted-artifact', 'derived-artifact', '
|
|
|
126
128
|
* parent-aware rules (kept in the signature so callers wire it once); the current bound is decided
|
|
127
129
|
* from the spec itself.
|
|
128
130
|
*/
|
|
129
|
-
|
|
131
|
+
/**
|
|
132
|
+
* @implements A-SPEC-587
|
|
133
|
+
* The A-SPECs whose chain leads up to `spec`: one hop for an H-SPEC (A-SPECs that depend on it),
|
|
134
|
+
* two for a REQ (through its H-SPECs). Anything else has no downstream A-SPECs. Pure; `depends_on`
|
|
135
|
+
* is the only edge this repository's chain uses.
|
|
136
|
+
*/
|
|
137
|
+
function descendantAspecs(spec, all) {
|
|
138
|
+
const dependsOn = (s, id) => Array.isArray(s.dependsOn) && s.dependsOn.includes(id);
|
|
139
|
+
if (spec.type === 'H-SPEC')
|
|
140
|
+
return all.filter((s) => s.type === 'A-SPEC' && dependsOn(s, spec.id));
|
|
141
|
+
if (spec.type === 'REQ') {
|
|
142
|
+
const hs = all.filter((s) => s.type === 'H-SPEC' && dependsOn(s, spec.id));
|
|
143
|
+
return all.filter((s) => s.type === 'A-SPEC' && hs.some((h) => dependsOn(s, h.id)));
|
|
144
|
+
}
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* @implements A-SPEC-587
|
|
149
|
+
* The autonomy verdict for approving THIS spec. `resolveParent` is accepted for future parent-aware
|
|
150
|
+
* rules. `descendants` — the A-SPECs beneath a REQ/H-SPEC (see `descendantAspecs`) — is what lets an
|
|
151
|
+
* upstream document be graded by the code it will admit: a REQ or H-SPEC carries no code of its
|
|
152
|
+
* own, so its risk IS the risk its A-SPECs declare (REQ-587, the owner's line: only irreversible,
|
|
153
|
+
* architectural or high-risk changes ask a human). Every downstream A-SPEC auto → auto; any hitl,
|
|
154
|
+
* or no A-SPEC at all (unknown scope) → human. The two-argument call keeps the old answer.
|
|
155
|
+
*/
|
|
156
|
+
function specApprovalAutonomy(spec, resolveParent, descendants) {
|
|
130
157
|
switch (spec.type) {
|
|
131
158
|
case 'T-SPEC':
|
|
132
159
|
return 'auto'; // tests are low risk
|
|
133
160
|
case 'REQ':
|
|
134
|
-
case 'H-SPEC':
|
|
161
|
+
case 'H-SPEC': {
|
|
162
|
+
const below = (descendants ?? []).filter((s) => s.type === 'A-SPEC');
|
|
163
|
+
if (below.length === 0)
|
|
164
|
+
return 'hitl'; // unknown scope stays human
|
|
165
|
+
return below.every((a) => specApprovalAutonomy(a, resolveParent) === 'auto') ? 'auto' : 'hitl';
|
|
166
|
+
}
|
|
135
167
|
case 'C-SPEC':
|
|
136
168
|
case 'ADR': // @implements A-SPEC-571.1 — a decision is the human's to seal
|
|
137
|
-
return 'hitl'; //
|
|
169
|
+
return 'hitl'; // architecture / structural constraint — the owner's exception
|
|
138
170
|
case 'A-SPEC': {
|
|
139
171
|
const grade = breakingGrade(spec);
|
|
140
172
|
if (grade === null || !AUTO_GRADES.has(grade))
|
|
@@ -183,6 +183,30 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
183
183
|
* APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
|
|
184
184
|
* drift detection compares against — would silently go stale.
|
|
185
185
|
*/
|
|
186
|
+
spec_renumber(a: {
|
|
187
|
+
root?: string;
|
|
188
|
+
oldBase: string;
|
|
189
|
+
newBase: string;
|
|
190
|
+
dryRun?: boolean;
|
|
191
|
+
}): Promise<{
|
|
192
|
+
ok: boolean;
|
|
193
|
+
reason: string;
|
|
194
|
+
dryRun?: undefined;
|
|
195
|
+
plan?: undefined;
|
|
196
|
+
movedSpecs?: undefined;
|
|
197
|
+
} | {
|
|
198
|
+
ok: boolean;
|
|
199
|
+
dryRun: boolean;
|
|
200
|
+
plan: import("../spec/renumber").RenumberPlan;
|
|
201
|
+
reason?: undefined;
|
|
202
|
+
movedSpecs?: undefined;
|
|
203
|
+
} | {
|
|
204
|
+
ok: boolean;
|
|
205
|
+
dryRun: boolean;
|
|
206
|
+
movedSpecs: number;
|
|
207
|
+
plan: import("../spec/renumber").RenumberPlan;
|
|
208
|
+
reason?: undefined;
|
|
209
|
+
}>;
|
|
186
210
|
spec_unseal(a: {
|
|
187
211
|
root?: string;
|
|
188
212
|
id: string;
|
|
@@ -579,15 +603,15 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
579
603
|
head?: string;
|
|
580
604
|
since?: string;
|
|
581
605
|
}): Promise<{
|
|
582
|
-
semanticWarm?: {
|
|
583
|
-
tier: string;
|
|
584
|
-
computed: number;
|
|
585
|
-
cached: number;
|
|
586
|
-
} | undefined;
|
|
587
606
|
changed: number;
|
|
588
607
|
nodes: number;
|
|
589
608
|
edges: number;
|
|
590
609
|
changeSource: ChangeSourceInfo;
|
|
610
|
+
semanticWarm?: {
|
|
611
|
+
tier: string;
|
|
612
|
+
computed: number;
|
|
613
|
+
cached: number;
|
|
614
|
+
};
|
|
591
615
|
}>;
|
|
592
616
|
context_bundle(a: {
|
|
593
617
|
root: string;
|
|
@@ -779,6 +803,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
779
803
|
}): Promise<{
|
|
780
804
|
ok: boolean;
|
|
781
805
|
approvedSpecs: string[];
|
|
806
|
+
alreadySealed: string[];
|
|
782
807
|
refused: {
|
|
783
808
|
id: string;
|
|
784
809
|
reason: string;
|
|
@@ -38,6 +38,7 @@ exports.isHandlerRefusal = isHandlerRefusal;
|
|
|
38
38
|
exports.unreadableAmong = unreadableAmong;
|
|
39
39
|
exports.makeHandlers = makeHandlers;
|
|
40
40
|
exports.collectDecisions = collectDecisions;
|
|
41
|
+
// @implements A-SPEC-599
|
|
41
42
|
// @implements A-SPEC-293
|
|
42
43
|
// @implements A-SPEC-292
|
|
43
44
|
// @implements A-SPEC-290
|
|
@@ -255,6 +256,7 @@ const ignore_1 = require("../project/ignore");
|
|
|
255
256
|
// @implements A-SPEC-126
|
|
256
257
|
const scan_1 = require("../reverse/scan");
|
|
257
258
|
const draft_1 = require("../reverse/draft");
|
|
259
|
+
const renumber_1 = require("../spec/renumber");
|
|
258
260
|
const adr_refs_1 = require("../spec/adr-refs");
|
|
259
261
|
const spec_id_guard_1 = require("./spec-id-guard");
|
|
260
262
|
const anchor_1 = require("../reverse/anchor");
|
|
@@ -376,7 +378,7 @@ function specStoreBlindReason(store, root, loaded) {
|
|
|
376
378
|
// Compared through realpath: on macOS a temp root is `/var/...` while the same directory resolves
|
|
377
379
|
// to `/private/var/...`, and a plain string compare calls the correctly-bound store foreign.
|
|
378
380
|
const real = (p) => { try {
|
|
379
|
-
return
|
|
381
|
+
return (0, root_2.canonicalPath)(p);
|
|
380
382
|
}
|
|
381
383
|
catch {
|
|
382
384
|
return path.resolve(p);
|
|
@@ -779,10 +781,11 @@ function makeRawHandlers(store, opts) {
|
|
|
779
781
|
// rides the existing seal path, but its actor names `autonomous:<client>` so an audit can tell a
|
|
780
782
|
// self-approved seal from a human-approved (elicitation) or operator (env/grant) one. Single-use
|
|
781
783
|
// by construction — it exists only inside this call, persisted nowhere.
|
|
782
|
-
const autonomousApproval = () => ({
|
|
784
|
+
const autonomousApproval = (derivedFrom) => ({
|
|
783
785
|
actor: `autonomous:${opts?.clientName?.() ?? 'unknown'}`,
|
|
784
786
|
token: crypto.randomUUID(),
|
|
785
|
-
|
|
787
|
+
// @implements A-SPEC-587 — an upstream grade is DERIVED; the audit line names the A-SPECs it came from.
|
|
788
|
+
rationale: `autonomous grant (${'HOLMES_AUTONOMOUS_APPROVAL'} enabled, spec grade auto${derivedFrom && derivedFrom.length > 0 ? ` — derived from ${derivedFrom.join(', ')}` : ''})`,
|
|
786
789
|
});
|
|
787
790
|
/**
|
|
788
791
|
* Where the audit record for a governance act belongs — resolved BEFORE the act writes anything.
|
|
@@ -1241,6 +1244,38 @@ function makeRawHandlers(store, opts) {
|
|
|
1241
1244
|
* APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
|
|
1242
1245
|
* drift detection compares against — would silently go stale.
|
|
1243
1246
|
*/
|
|
1247
|
+
// @implements A-SPEC-255 — the WIRING only. The judgment is `planRenumber`, which is pure and
|
|
1248
|
+
// tested directly; this handler adds no rules of its own. Re-sealing is deliberately absent:
|
|
1249
|
+
// `spec_approve` is the only sealer, so the plan reports the two ORDERS and the caller runs them.
|
|
1250
|
+
// @implements A-SPEC-255 — WIRING only. The judgment lives in `planRenumber`, which is pure and
|
|
1251
|
+
// tested directly; nothing here adds a rule. Re-sealing is deliberately absent: `spec_approve`
|
|
1252
|
+
// is the only sealer (a second sealer becomes a second truth), so the plan reports the two
|
|
1253
|
+
// ORDERS and the caller runs them. `dryRun` defaults to true — a renumber is read before it runs.
|
|
1254
|
+
async spec_renumber(a) {
|
|
1255
|
+
const specsRoot = store.specsRoot;
|
|
1256
|
+
if (typeof specsRoot !== 'string')
|
|
1257
|
+
return { ok: false, reason: '파일 스토어에 묶인 서버에서만 리넘버할 수 있습니다.' };
|
|
1258
|
+
const projectRoot = path.resolve(specsRoot, '..', '..');
|
|
1259
|
+
const plan = (0, renumber_1.planRenumber)({
|
|
1260
|
+
specs: (0, renumber_1.readSpecsForRenumber)(specsRoot),
|
|
1261
|
+
sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
|
|
1262
|
+
oldBase: String(a.oldBase), newBase: String(a.newBase),
|
|
1263
|
+
});
|
|
1264
|
+
if (plan.refusal)
|
|
1265
|
+
return { ok: false, reason: plan.refusal };
|
|
1266
|
+
if (a.dryRun !== false)
|
|
1267
|
+
return { ok: true, dryRun: true, plan };
|
|
1268
|
+
const movedSpecs = (0, renumber_1.applyRenumber)(specsRoot, { ...plan, anchors: [] });
|
|
1269
|
+
(0, renumber_1.applyRenumber)(projectRoot, { ...plan, moves: [], dependsOn: [], slices: [] });
|
|
1270
|
+
new ledger_store_1.FileLedgerStore(path.join(projectRoot, '.ax', 'ledger')).append({
|
|
1271
|
+
ts: new Date().toISOString(),
|
|
1272
|
+
actor: 'spec_renumber',
|
|
1273
|
+
kind: 'spec-renumbered',
|
|
1274
|
+
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`,
|
|
1275
|
+
inputs: plan.moves.map((m) => `${m.oldId}->${m.newId}`),
|
|
1276
|
+
});
|
|
1277
|
+
return { ok: true, dryRun: false, movedSpecs, plan };
|
|
1278
|
+
},
|
|
1244
1279
|
async spec_unseal(a) {
|
|
1245
1280
|
const all = await store.list();
|
|
1246
1281
|
if (all.filter((s) => s.id === a.id).length > 1) {
|
|
@@ -1526,9 +1561,14 @@ function makeRawHandlers(store, opts) {
|
|
|
1526
1561
|
// @implements A-SPEC-553.1 — autonomy is the out-of-band env switch OR a valid, non-expired
|
|
1527
1562
|
// session envelope marker under this project's `.ax/state/` (which an agent cannot write).
|
|
1528
1563
|
const autonomyOn = (0, autonomy_1.autonomousApprovalEnabled)(process.env, a.root, new Date().toISOString());
|
|
1564
|
+
// @implements A-SPEC-587 — an upstream REQ/H-SPEC is graded by the A-SPECs beneath it, so
|
|
1565
|
+
// the store is consulted for its descendants (only then: the list is a cost the auto-grade
|
|
1566
|
+
// A-SPEC/T-SPEC path does not pay). Unknown scope (none yet) keeps the human answer.
|
|
1567
|
+
const upstream = target.spec.type === 'REQ' || target.spec.type === 'H-SPEC';
|
|
1568
|
+
const below = autonomyOn && upstream ? (0, autonomy_1.descendantAspecs)(target.spec, await store.list()) : undefined;
|
|
1529
1569
|
if (autonomyOn
|
|
1530
|
-
&& (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec])) === 'auto') {
|
|
1531
|
-
approveResolved = { approval: autonomousApproval(), source: 'autonomous' };
|
|
1570
|
+
&& (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec]), below) === 'auto') {
|
|
1571
|
+
approveResolved = { approval: autonomousApproval(below?.map((s) => s.id)), source: 'autonomous' };
|
|
1532
1572
|
}
|
|
1533
1573
|
else if (autonomyOn) {
|
|
1534
1574
|
// @implements A-SPEC-551.1 — hitl-grade spec under autonomy: the in-session elicitation
|
|
@@ -2765,7 +2805,7 @@ function makeRawHandlers(store, opts) {
|
|
|
2765
2805
|
graphSchema: RTM_GRAPH_SCHEMA,
|
|
2766
2806
|
extractorVersion: RTM_EXTRACTOR_VERSION,
|
|
2767
2807
|
sourceCommit: head,
|
|
2768
|
-
specFingerprint:
|
|
2808
|
+
specFingerprint: (0, graph_store_1.specFingerprint)(specs),
|
|
2769
2809
|
scanDigest: (0, graph_store_1.scanDigest)(scanned),
|
|
2770
2810
|
});
|
|
2771
2811
|
const g = opened.graph;
|
|
@@ -2863,8 +2903,26 @@ function makeRawHandlers(store, opts) {
|
|
|
2863
2903
|
deleted: changes.deleted,
|
|
2864
2904
|
renamed: changes.renamed,
|
|
2865
2905
|
};
|
|
2866
|
-
|
|
2906
|
+
let head = '';
|
|
2867
2907
|
try {
|
|
2908
|
+
head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
2909
|
+
}
|
|
2910
|
+
catch { /* non-git */ }
|
|
2911
|
+
const graphPath = path.join(root, '.ax', 'rtm.sqlite');
|
|
2912
|
+
const tempGraphPath = `${graphPath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
2913
|
+
const opened = (0, graph_store_1.openReusableGraph)(tempGraphPath, {
|
|
2914
|
+
graphSchema: RTM_GRAPH_SCHEMA,
|
|
2915
|
+
extractorVersion: RTM_EXTRACTOR_VERSION,
|
|
2916
|
+
sourceCommit: head,
|
|
2917
|
+
specFingerprint: (0, graph_store_1.specFingerprint)(specs),
|
|
2918
|
+
scanDigest: (0, graph_store_1.scanDigest)(scanned),
|
|
2919
|
+
});
|
|
2920
|
+
const g = opened.graph;
|
|
2921
|
+
let result;
|
|
2922
|
+
try {
|
|
2923
|
+
// Explicit reindex is a persistence operation: discard the previous contents, rebuild the
|
|
2924
|
+
// complete graph, then commit the matching basis before releasing the file (A-SPEC-589).
|
|
2925
|
+
g.clear();
|
|
2868
2926
|
(0, rtm_builder_1.buildRtm)(specs, scanned, g);
|
|
2869
2927
|
// Scaffold note: `g` was just full-built at HEAD, so applying the
|
|
2870
2928
|
// diff on top of it is currently an idempotent no-op in practice
|
|
@@ -2877,6 +2935,7 @@ function makeRawHandlers(store, opts) {
|
|
|
2877
2935
|
// graph equals what a full rebuild would have produced. Measured: without this, rtm_reindex
|
|
2878
2936
|
// returned a graph with no call edges at all.
|
|
2879
2937
|
(0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne, allScanned: () => scanned });
|
|
2938
|
+
opened.commitBasis();
|
|
2880
2939
|
// @implements A-SPEC-478 — the semantic cache WARMING lives here, in the explicit heavy
|
|
2881
2940
|
// operation, so the ranking hot path only ever LOOKS UP vectors. Idempotent through the
|
|
2882
2941
|
// cache; a tier of none (the shipped default) computes nothing.
|
|
@@ -2891,12 +2950,39 @@ function makeRawHandlers(store, opts) {
|
|
|
2891
2950
|
}
|
|
2892
2951
|
}
|
|
2893
2952
|
catch { /* warming is best-effort; reindex's own result is unaffected */ }
|
|
2894
|
-
|
|
2953
|
+
result = { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource,
|
|
2895
2954
|
...(semanticWarm !== undefined ? { semanticWarm } : {}) };
|
|
2896
2955
|
}
|
|
2897
2956
|
finally {
|
|
2898
2957
|
g.close(); // release native SQLite handle even if build/apply throws
|
|
2899
2958
|
}
|
|
2959
|
+
// Publish only after the complete temporary graph is closed. Rename the old file aside so a
|
|
2960
|
+
// Windows reader either keeps the old complete graph or sees the new complete graph; it never
|
|
2961
|
+
// observes a partially built database or a basis that belongs to another file (A-SPEC-589).
|
|
2962
|
+
const backupPath = `${graphPath}.bak-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
2963
|
+
const hadOld = fs.existsSync(graphPath);
|
|
2964
|
+
try {
|
|
2965
|
+
if (hadOld)
|
|
2966
|
+
fs.renameSync(graphPath, backupPath);
|
|
2967
|
+
fs.renameSync(tempGraphPath, graphPath);
|
|
2968
|
+
if (hadOld)
|
|
2969
|
+
fs.rmSync(backupPath, { force: true });
|
|
2970
|
+
}
|
|
2971
|
+
catch (error) {
|
|
2972
|
+
try {
|
|
2973
|
+
if (!fs.existsSync(graphPath) && fs.existsSync(backupPath))
|
|
2974
|
+
fs.renameSync(backupPath, graphPath);
|
|
2975
|
+
}
|
|
2976
|
+
catch { /* preserve the publication error; the old path is reported as unavailable */ }
|
|
2977
|
+
throw error;
|
|
2978
|
+
}
|
|
2979
|
+
finally {
|
|
2980
|
+
try {
|
|
2981
|
+
fs.rmSync(tempGraphPath, { force: true });
|
|
2982
|
+
}
|
|
2983
|
+
catch { /* already renamed or retained for diagnosis */ }
|
|
2984
|
+
}
|
|
2985
|
+
return result;
|
|
2900
2986
|
},
|
|
2901
2987
|
async context_bundle(a) {
|
|
2902
2988
|
assertSpecStoreReachable('context_bundle', store, a.root); // @implements A-SPEC-433
|
|
@@ -3779,14 +3865,26 @@ depends_on:
|
|
|
3779
3865
|
}
|
|
3780
3866
|
const rawHandlers = makeRawHandlers(rawStore);
|
|
3781
3867
|
const approvedSpecs = [];
|
|
3868
|
+
const alreadySealed = [];
|
|
3782
3869
|
const refused = [];
|
|
3783
3870
|
if (idsToApprove.length === 0) {
|
|
3784
3871
|
// Reporting this as a success is how a caller ends up believing an unapproved slice was
|
|
3785
3872
|
// approved. Measured 2026-08-28: a broken-frontmatter REQ made the whole slice invisible
|
|
3786
3873
|
// here, and the tool answered `ok: true, approvedSpecs: []`.
|
|
3787
|
-
return { ok: false, approvedSpecs, refused: [{ id: a.sliceName, reason: 'no specs matched this slice name' }] };
|
|
3788
|
-
}
|
|
3874
|
+
return { ok: false, approvedSpecs, alreadySealed, refused: [{ id: a.sliceName, reason: 'no specs matched this slice name' }] };
|
|
3875
|
+
}
|
|
3876
|
+
// @implements A-SPEC-587.2 — a sealed spec is SKIPPED, not re-sealed: re-approving asked the
|
|
3877
|
+
// human the same question again (same request id back in the queue) and stopped the chain
|
|
3878
|
+
// there, so the H-SPEC was never even attempted (measured 2026-09-10, three slices).
|
|
3879
|
+
const sealed = (id) => {
|
|
3880
|
+
const s = specs.find((x) => x.id === id);
|
|
3881
|
+
return s?.status === 'approved' && typeof s.frontmatter?.approved_digest === 'string';
|
|
3882
|
+
};
|
|
3789
3883
|
for (const id of idsToApprove) {
|
|
3884
|
+
if (sealed(id)) {
|
|
3885
|
+
alreadySealed.push(id);
|
|
3886
|
+
continue;
|
|
3887
|
+
}
|
|
3790
3888
|
const res = (await rawHandlers.spec_approve({ root, id }));
|
|
3791
3889
|
if (res.approved || res.ok) {
|
|
3792
3890
|
approvedSpecs.push(id);
|
|
@@ -3800,11 +3898,11 @@ depends_on:
|
|
|
3800
3898
|
// again or, worse, succeed against an unsealed parent. Stop and say what is left.
|
|
3801
3899
|
break;
|
|
3802
3900
|
}
|
|
3803
|
-
for (const id of idsToApprove.slice(approvedSpecs.length + refused.length)) {
|
|
3901
|
+
for (const id of idsToApprove.slice(approvedSpecs.length + alreadySealed.length + refused.length)) {
|
|
3804
3902
|
refused.push({ id, reason: 'not attempted — an earlier spec in the chain was refused' });
|
|
3805
3903
|
}
|
|
3806
|
-
// `ok` means the slice is approved. Anything less is not a success.
|
|
3807
|
-
return { ok: refused.length === 0 && approvedSpecs.length > 0, approvedSpecs, refused };
|
|
3904
|
+
// `ok` means the slice is approved — freshly or already. Anything less is not a success.
|
|
3905
|
+
return { ok: refused.length === 0 && approvedSpecs.length + alreadySealed.length > 0, approvedSpecs, alreadySealed, refused };
|
|
3808
3906
|
},
|
|
3809
3907
|
async spec_remediate(a) {
|
|
3810
3908
|
const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 현재 max 를 한 칸 넘게 뛸 수 없다. 막으면 max+1 은 다시 오염되지 않고 넘버링이 영구히 예측 가능하다.
|
|
8
8
|
*/
|
|
9
9
|
/** id 의 base 숫자. "REQ-1403"→1403, "A-SPEC-250.1"→250(서브번호 무시), 레거시 "H-SPEC-050"→50. 못 뽑으면 null. */
|
|
10
|
-
export
|
|
10
|
+
export { specIdBase } from '../spec/spec-id';
|
|
11
11
|
export type IdVerdict = {
|
|
12
12
|
ok: true;
|
|
13
13
|
} | {
|
|
@@ -9,19 +9,15 @@
|
|
|
9
9
|
* 현재 max 를 한 칸 넘게 뛸 수 없다. 막으면 max+1 은 다시 오염되지 않고 넘버링이 영구히 예측 가능하다.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.specIdBase =
|
|
12
|
+
exports.specIdBase = void 0;
|
|
13
13
|
exports.sequentialIdVerdict = sequentialIdVerdict;
|
|
14
14
|
/** id 의 base 숫자. "REQ-1403"→1403, "A-SPEC-250.1"→250(서브번호 무시), 레거시 "H-SPEC-050"→50. 못 뽑으면 null. */
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return null;
|
|
22
|
-
const n = parseInt(m[1], 10);
|
|
23
|
-
return Number.isNaN(n) ? null : n;
|
|
24
|
-
}
|
|
15
|
+
// @implements A-SPEC-255 — the parser moved DOWN to the spec layer so `spec/renumber.ts` can share it
|
|
16
|
+
// without importing mcp (C-SPEC-224 forbids that direction). Re-exported so existing callers and the
|
|
17
|
+
// A-SPEC-252 tests keep their import path.
|
|
18
|
+
var spec_id_1 = require("../spec/spec-id");
|
|
19
|
+
Object.defineProperty(exports, "specIdBase", { enumerable: true, get: function () { return spec_id_1.specIdBase; } });
|
|
20
|
+
const spec_id_2 = require("../spec/spec-id");
|
|
25
21
|
/**
|
|
26
22
|
* 새 id 의 base 가 코퍼스 max base 를 한 칸 넘게 뛰면 거부한다.
|
|
27
23
|
*
|
|
@@ -37,13 +33,13 @@ function idSpaceOf(id) {
|
|
|
37
33
|
return /^ADR-/.test(id.trim()) ? 'ADR' : 'functional';
|
|
38
34
|
}
|
|
39
35
|
function sequentialIdVerdict(newId, existingIds) {
|
|
40
|
-
const base = specIdBase(newId);
|
|
36
|
+
const base = (0, spec_id_2.specIdBase)(newId);
|
|
41
37
|
if (base === null)
|
|
42
38
|
return { ok: true };
|
|
43
39
|
// @implements A-SPEC-571.1 — compare only within the same number space.
|
|
44
40
|
const space = idSpaceOf(newId);
|
|
45
41
|
const bases = (existingIds ?? []).filter((id) => idSpaceOf(id) === space)
|
|
46
|
-
.map(specIdBase).filter((n) => n !== null);
|
|
42
|
+
.map(spec_id_2.specIdBase).filter((n) => n !== null);
|
|
47
43
|
if (bases.length === 0)
|
|
48
44
|
return { ok: true };
|
|
49
45
|
const maxBase = Math.max(...bases);
|
|
@@ -101,6 +101,19 @@ exports.TOOL_SCHEMAS = {
|
|
|
101
101
|
required: ['id'],
|
|
102
102
|
},
|
|
103
103
|
},
|
|
104
|
+
spec_renumber: {
|
|
105
|
+
description: "Move a whole spec FAMILY (REQ/H-SPEC/A-SPEC/T-SPEC sharing one base, dot-suffixed members included) from one number to another, and report what a machine must not touch. The tool rewrites only places with a definite grammar: filenames, frontmatter `id`, every `depends_on` in the store, `slice` tags, and source `@implements` anchors. Prose — comments, test titles, CHANGELOG entries, `source.ref` citations — is REPORTED as candidates with file and line, never substituted: measured 2026-08-23, a bulk regex doing exactly that turned REQ-253's own source citation into a reference to itself, while a hand renumber left seven prose sites behind including a comment in the shipped install.ps1. Re-sealing is NOT performed here; `spec_approve` remains the only sealer. Instead the plan returns two orders, which are opposites because the two acts refuse in opposite directions: `unsealOrder` is child-first (spec_unseal refuses while an approved child depends on the target) and `approveOrder` is parent-first (spec_approve refuses while a parent is unsealed). A spec whose `depends_on` is empty appears in neither, because specDigest does not hash `id` and its seal survives the move. Refuses outright when the destination base is already claimed or the source base does not exist, emptying every list so a partial plan cannot become a partial move. `dryRun` defaults to TRUE.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
oldBase: str('The base number to move FROM, digits only (e.g. "596").'),
|
|
110
|
+
newBase: str('The base number to move TO, digits only (e.g. "599"). Must be unclaimed.'),
|
|
111
|
+
dryRun: { type: 'boolean', description: 'Default true — return the plan without touching anything. Pass false to apply.' },
|
|
112
|
+
root: str('Optional when the server is bound to a file store; if supplied it must resolve to the SAME project.'),
|
|
113
|
+
},
|
|
114
|
+
required: ['oldBase', 'newBase'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
104
117
|
spec_unseal: {
|
|
105
118
|
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.",
|
|
106
119
|
inputSchema: {
|