@holmes-lab/holmes-kit 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/README.md +5 -3
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.js +17 -16
- package/dist/holmes/cli/doctor.js +19 -7
- package/dist/holmes/cli/mcp-schema-cost.d.ts +18 -0
- package/dist/holmes/cli/mcp-schema-cost.js +28 -0
- package/dist/holmes/config/config.d.ts +8 -0
- package/dist/holmes/config/config.js +1 -1
- package/dist/holmes/governance/constitution.d.ts +26 -0
- package/dist/holmes/governance/constitution.js +33 -0
- package/dist/holmes/governance/ledger-timeline.d.ts +21 -0
- package/dist/holmes/governance/ledger-timeline.js +21 -0
- package/dist/holmes/hooks/stop.d.ts +24 -0
- package/dist/holmes/hooks/stop.js +83 -3
- package/dist/holmes/mcp/elicit-approval.d.ts +9 -0
- package/dist/holmes/mcp/elicit-approval.js +15 -0
- package/dist/holmes/mcp/handlers.d.ts +108 -0
- package/dist/holmes/mcp/handlers.js +239 -3
- package/dist/holmes/mcp/tool-schemas.js +33 -0
- package/dist/holmes/review/mutate.d.ts +17 -0
- package/dist/holmes/review/mutate.js +66 -0
- package/dist/holmes/review/test-outcomes.d.ts +35 -0
- package/dist/holmes/review/test-outcomes.js +108 -0
- package/dist/holmes/review/test-runner.d.ts +30 -0
- package/dist/holmes/review/test-runner.js +71 -5
- package/dist/holmes/spec/approval-status.d.ts +29 -0
- package/dist/holmes/spec/approval-status.js +33 -0
- package/dist/holmes/spec/kills.d.ts +14 -0
- package/dist/holmes/spec/kills.js +28 -0
- package/dist/holmes/spec/spec-store.d.ts +9 -0
- package/dist/holmes/spec/spec-store.js +17 -0
- package/dist/holmes/spec/validator.js +18 -0
- package/dist/holmes/spec/version-conflict.d.ts +21 -0
- package/dist/holmes/spec/version-conflict.js +21 -0
- package/package.json +1 -1
- package/playbooks/tdd-slice/PLAYBOOK.md +82 -0
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
*/
|
|
21
21
|
/** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
|
|
22
22
|
export declare const ELICITABLE_KINDS: ReadonlySet<string>;
|
|
23
|
+
/**
|
|
24
|
+
* @implements A-SPEC-540.1
|
|
25
|
+
* True when the operator has opted OUT of in-client elicitation via `HOLMES_ELICIT`. Then the server
|
|
26
|
+
* folds every elicitation to `silent` (refuse+enqueue) — the same path a client that never advertised
|
|
27
|
+
* the capability takes — so claude/agy/codex converge on ONE out-of-band decision surface
|
|
28
|
+
* (`holmes-kit approve`) instead of depending on each client's prompt rendering. Unset, or any
|
|
29
|
+
* non-disabling value, keeps elicitation ON — byte-identical to before.
|
|
30
|
+
*/
|
|
31
|
+
export declare function elicitationDisabled(env: NodeJS.ProcessEnv): boolean;
|
|
23
32
|
export interface ElicitApprovalRequest {
|
|
24
33
|
kind: string;
|
|
25
34
|
target: string;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @implements A-SPEC-263.1
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
exports.MAX_PRESENTATIONS = exports.ELICIT_TIMEOUT_MS = exports.ELICITABLE_KINDS = void 0;
|
|
5
|
+
exports.elicitationDisabled = elicitationDisabled;
|
|
5
6
|
exports.elicitTimeoutMsFor = elicitTimeoutMsFor;
|
|
6
7
|
exports.classifyElicitError = classifyElicitError;
|
|
7
8
|
exports.expiredNotice = expiredNotice;
|
|
@@ -31,6 +32,20 @@ exports.interpretElicitResult = interpretElicitResult;
|
|
|
31
32
|
*/
|
|
32
33
|
/** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
|
|
33
34
|
exports.ELICITABLE_KINDS = new Set(['spec-approve', 'review-resolve']);
|
|
35
|
+
/** The `HOLMES_ELICIT` values that turn the in-client prompt OFF. */
|
|
36
|
+
const ELICIT_DISABLED_TOKENS = new Set(['off', '0', 'false', 'no', 'disabled']);
|
|
37
|
+
/**
|
|
38
|
+
* @implements A-SPEC-540.1
|
|
39
|
+
* True when the operator has opted OUT of in-client elicitation via `HOLMES_ELICIT`. Then the server
|
|
40
|
+
* folds every elicitation to `silent` (refuse+enqueue) — the same path a client that never advertised
|
|
41
|
+
* the capability takes — so claude/agy/codex converge on ONE out-of-band decision surface
|
|
42
|
+
* (`holmes-kit approve`) instead of depending on each client's prompt rendering. Unset, or any
|
|
43
|
+
* non-disabling value, keeps elicitation ON — byte-identical to before.
|
|
44
|
+
*/
|
|
45
|
+
function elicitationDisabled(env) {
|
|
46
|
+
const v = env.HOLMES_ELICIT;
|
|
47
|
+
return typeof v === 'string' && ELICIT_DISABLED_TOKENS.has(v.trim().toLowerCase());
|
|
48
|
+
}
|
|
34
49
|
/**
|
|
35
50
|
* The lock-holding path's timeout truth (and the stage-2 reason dialog's): review-resolve holds
|
|
36
51
|
* the findings-ledger lock while the dialog waits, so its ceiling never grows (REQ-497 기아 방지).
|
|
@@ -158,6 +158,85 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
158
158
|
dependents: string[];
|
|
159
159
|
reason?: undefined;
|
|
160
160
|
}>;
|
|
161
|
+
/**
|
|
162
|
+
* @implements A-SPEC-538.1
|
|
163
|
+
* The inverse of spec_approve: returns a SEALED (approved) spec to an editable `draft`, clearing
|
|
164
|
+
* `approved_digest` and `parent_digests` in ONE act, and records `spec-unsealed`. Hand-editing
|
|
165
|
+
* only `status` leaves seal residue that later trips validation; this atomizes the reverse.
|
|
166
|
+
*
|
|
167
|
+
* Un-sealing WITHDRAWS a seal, so — like retiring a sealed document — it demands a covering
|
|
168
|
+
* out-of-band HOLMES_APPROVAL (fail-closed): un-sealing an approved T-SPEC removes the code
|
|
169
|
+
* gate's demand, so an unguarded un-seal would be an approval bypass. And it refuses when an
|
|
170
|
+
* APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
|
|
171
|
+
* drift detection compares against — would silently go stale.
|
|
172
|
+
*/
|
|
173
|
+
spec_unseal(a: {
|
|
174
|
+
root?: string;
|
|
175
|
+
id: string;
|
|
176
|
+
}): Promise<{
|
|
177
|
+
ok: boolean;
|
|
178
|
+
reason: string;
|
|
179
|
+
unsealed?: undefined;
|
|
180
|
+
id?: undefined;
|
|
181
|
+
dependents?: undefined;
|
|
182
|
+
} | {
|
|
183
|
+
ok: boolean;
|
|
184
|
+
unsealed: boolean;
|
|
185
|
+
id: string;
|
|
186
|
+
dependents: never[];
|
|
187
|
+
reason: string;
|
|
188
|
+
} | {
|
|
189
|
+
ok: boolean;
|
|
190
|
+
unsealed: boolean;
|
|
191
|
+
id: string;
|
|
192
|
+
dependents: string[];
|
|
193
|
+
reason?: undefined;
|
|
194
|
+
}>;
|
|
195
|
+
/**
|
|
196
|
+
* @implements A-SPEC-538.2
|
|
197
|
+
* Read-only: report a spec's approval/seal state — sealed?, approved_digest, each parent's seal
|
|
198
|
+
* state, and the concrete blockers still standing between it and approval — so a caller need not
|
|
199
|
+
* parse files to ask "what is the approval state right now". Reuses `sealOf` and `approvalBlockers`
|
|
200
|
+
* (the same predicates the code gate and spec_approve read) so the report cannot drift from the
|
|
201
|
+
* acts it describes. No writes, no ledger append.
|
|
202
|
+
*/
|
|
203
|
+
approval_status(a: {
|
|
204
|
+
root?: string;
|
|
205
|
+
id: string;
|
|
206
|
+
}): Promise<{
|
|
207
|
+
ok: boolean;
|
|
208
|
+
reason: string;
|
|
209
|
+
} | {
|
|
210
|
+
id: string;
|
|
211
|
+
type?: string;
|
|
212
|
+
status: string;
|
|
213
|
+
sealed: boolean;
|
|
214
|
+
approvedDigest?: string;
|
|
215
|
+
parents: import("../spec/approval-status").ParentApproval[];
|
|
216
|
+
blockers: string[];
|
|
217
|
+
ok: boolean;
|
|
218
|
+
reason?: undefined;
|
|
219
|
+
}>;
|
|
220
|
+
/**
|
|
221
|
+
* @implements A-SPEC-538.3
|
|
222
|
+
* Read-only: return the provenance ledger's events in time order (optionally narrowed to one
|
|
223
|
+
* spec) so the governance history — approved / unsealed / retired / review-needed … — is legible
|
|
224
|
+
* at a glance without reading raw JSONL. Reuses FileLedgerStore.loadAll(); the ordering/filtering/
|
|
225
|
+
* projection is the pure `timelineFrom`. No writes. An absent ledger is an empty history, not an
|
|
226
|
+
* error.
|
|
227
|
+
*/
|
|
228
|
+
ledger_timeline(a: {
|
|
229
|
+
root?: string;
|
|
230
|
+
id?: string;
|
|
231
|
+
}): Promise<{
|
|
232
|
+
ok: boolean;
|
|
233
|
+
reason: string;
|
|
234
|
+
events?: undefined;
|
|
235
|
+
} | {
|
|
236
|
+
ok: boolean;
|
|
237
|
+
events: import("../governance/ledger-timeline").TimelineEntry[];
|
|
238
|
+
reason?: undefined;
|
|
239
|
+
}>;
|
|
161
240
|
spec_approve(a: {
|
|
162
241
|
root?: string;
|
|
163
242
|
id: string;
|
|
@@ -165,12 +244,21 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
165
244
|
ok: boolean;
|
|
166
245
|
reason: string;
|
|
167
246
|
findings?: undefined;
|
|
247
|
+
conflict?: undefined;
|
|
168
248
|
approved?: undefined;
|
|
169
249
|
digest?: undefined;
|
|
170
250
|
} | {
|
|
171
251
|
ok: boolean;
|
|
172
252
|
reason: string;
|
|
173
253
|
findings: import("../spec/validator").Finding[];
|
|
254
|
+
conflict?: undefined;
|
|
255
|
+
approved?: undefined;
|
|
256
|
+
digest?: undefined;
|
|
257
|
+
} | {
|
|
258
|
+
ok: boolean;
|
|
259
|
+
reason: string;
|
|
260
|
+
conflict: import("../spec/version-conflict").ConflictDetail;
|
|
261
|
+
findings?: undefined;
|
|
174
262
|
approved?: undefined;
|
|
175
263
|
digest?: undefined;
|
|
176
264
|
} | {
|
|
@@ -179,6 +267,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
179
267
|
ok?: undefined;
|
|
180
268
|
reason?: undefined;
|
|
181
269
|
findings?: undefined;
|
|
270
|
+
conflict?: undefined;
|
|
182
271
|
}>;
|
|
183
272
|
spec_list(a: any): Promise<{
|
|
184
273
|
specs: {
|
|
@@ -347,7 +436,25 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
347
436
|
head?: string;
|
|
348
437
|
since?: string;
|
|
349
438
|
mark?: string;
|
|
439
|
+
mutate?: string;
|
|
350
440
|
}): Promise<{
|
|
441
|
+
mutate: {
|
|
442
|
+
tspec: string;
|
|
443
|
+
aspec: string | undefined;
|
|
444
|
+
coveringFiles: string[];
|
|
445
|
+
results: ({
|
|
446
|
+
mutation: import("../spec/kills").Mutation;
|
|
447
|
+
applied: boolean;
|
|
448
|
+
reason: string;
|
|
449
|
+
} | {
|
|
450
|
+
applied: boolean;
|
|
451
|
+
verdict?: "killed" | "survived";
|
|
452
|
+
mutation: import("../spec/kills").Mutation;
|
|
453
|
+
reason?: undefined;
|
|
454
|
+
})[];
|
|
455
|
+
survivors: import("../spec/kills").Mutation[];
|
|
456
|
+
};
|
|
457
|
+
} | {
|
|
351
458
|
baselineRecorded?: string | undefined;
|
|
352
459
|
scopeFallback?: "full" | undefined;
|
|
353
460
|
tier: import("../rtm/test-scope").RegressionTier;
|
|
@@ -359,6 +466,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
359
466
|
tail: string;
|
|
360
467
|
unresolvedFiles: string[];
|
|
361
468
|
changeSource: ChangeSourceInfo;
|
|
469
|
+
mutate?: undefined;
|
|
362
470
|
}>;
|
|
363
471
|
issue_localize(a: {
|
|
364
472
|
root: string;
|
|
@@ -68,7 +68,10 @@ const rtm_check_1 = require("../rtm/rtm-check");
|
|
|
68
68
|
const test_scope_1 = require("../rtm/test-scope");
|
|
69
69
|
const gap_analyzer_1 = require("../rtm/gap-analyzer");
|
|
70
70
|
const test_runner_1 = require("../review/test-runner");
|
|
71
|
+
const kills_1 = require("../spec/kills");
|
|
72
|
+
const mutate_1 = require("../review/mutate");
|
|
71
73
|
const test_evidence_1 = require("../review/test-evidence");
|
|
74
|
+
const test_outcomes_1 = require("../review/test-outcomes");
|
|
72
75
|
const localize_1 = require("../rtm/localize");
|
|
73
76
|
const maintenance_analyze_1 = require("./maintenance-analyze");
|
|
74
77
|
const maintenance_evidence_1 = require("./maintenance-evidence");
|
|
@@ -90,6 +93,53 @@ const root_1 = require("../project/root");
|
|
|
90
93
|
// under that tree stopped at the minted marker. This is the harm §8 closed for `review_record`,
|
|
91
94
|
// left open on the scan path. When the store is bound, the cache is the bound project's; otherwise
|
|
92
95
|
// only an anchored answer may be written to, and an unanchored one falls back to a temp cache.
|
|
96
|
+
// @implements A-SPEC-534.8
|
|
97
|
+
// The governed source file a `kills` mutation targets: one that @implements the A-SPEC AND contains
|
|
98
|
+
// the `where` literal. Bounded walk, skips vendored/test/hidden dirs. null when none qualifies.
|
|
99
|
+
function sourceFileWithMutation(root, aspecId, where) {
|
|
100
|
+
if (!where)
|
|
101
|
+
return null;
|
|
102
|
+
const SOURCE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|rb|php|swift)$/;
|
|
103
|
+
let found = null;
|
|
104
|
+
const walk = (d) => {
|
|
105
|
+
if (found)
|
|
106
|
+
return;
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
if (found)
|
|
116
|
+
return;
|
|
117
|
+
if (e.name === 'node_modules' || e.name === 'dist' || e.name === 'reference'
|
|
118
|
+
|| e.name === 'vendor' || e.name === 'third_party' || e.name.startsWith('.'))
|
|
119
|
+
continue;
|
|
120
|
+
const p = path.join(d, e.name);
|
|
121
|
+
if (e.isDirectory()) {
|
|
122
|
+
walk(p);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!SOURCE.test(e.name) || /\.test\./.test(e.name))
|
|
126
|
+
continue;
|
|
127
|
+
let text;
|
|
128
|
+
try {
|
|
129
|
+
text = fs.readFileSync(p, 'utf8');
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (text.includes(`@implements ${aspecId}`) && text.includes(where)) {
|
|
135
|
+
found = p;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
walk(root);
|
|
141
|
+
return found;
|
|
142
|
+
}
|
|
93
143
|
const cacheDirFor = (root) => {
|
|
94
144
|
const r = (0, root_2.resolveProjectRoot)(root);
|
|
95
145
|
if (r.marker !== 'given')
|
|
@@ -139,6 +189,9 @@ const spec_digest_1 = require("../spec/spec-digest");
|
|
|
139
189
|
const spec_store_2 = require("../spec/spec-store");
|
|
140
190
|
const breaking_change_1 = require("../spec/breaking-change");
|
|
141
191
|
const approval_blockers_1 = require("../spec/approval-blockers");
|
|
192
|
+
const approval_status_1 = require("../spec/approval-status");
|
|
193
|
+
const ledger_timeline_1 = require("../governance/ledger-timeline");
|
|
194
|
+
const version_conflict_1 = require("../spec/version-conflict");
|
|
142
195
|
const ledger_store_1 = require("../governance/ledger-store");
|
|
143
196
|
const provenance_chain_1 = require("../governance/provenance-chain");
|
|
144
197
|
const ledger_lock_1 = require("../governance/ledger-lock");
|
|
@@ -653,6 +706,11 @@ function makeRawHandlers(store, opts) {
|
|
|
653
706
|
// stays byte-identical to the pre-elicitation one — nothing is ever worse than before the
|
|
654
707
|
// channel existed.
|
|
655
708
|
const tryElicit = async (kind, target, summary) => {
|
|
709
|
+
// @implements A-SPEC-540.1 — operator opt-out: with HOLMES_ELICIT=off the server never shows the
|
|
710
|
+
// in-client prompt, folding to the byte-identical `silent` (refuse+enqueue) path so claude/agy/
|
|
711
|
+
// codex all converge on one out-of-band surface (holmes-kit approve). Unset ⇒ unchanged.
|
|
712
|
+
if ((0, elicit_approval_1.elicitationDisabled)(process.env))
|
|
713
|
+
return { kind: 'silent' };
|
|
656
714
|
if (!opts?.elicit || !elicit_approval_1.ELICITABLE_KINDS.has(kind))
|
|
657
715
|
return { kind: 'silent' };
|
|
658
716
|
try {
|
|
@@ -1124,6 +1182,146 @@ function makeRawHandlers(store, opts) {
|
|
|
1124
1182
|
});
|
|
1125
1183
|
return { ok: true, retired: true, id: a.id, dependents: dependents.map((s) => s.id) };
|
|
1126
1184
|
},
|
|
1185
|
+
/**
|
|
1186
|
+
* @implements A-SPEC-538.1
|
|
1187
|
+
* The inverse of spec_approve: returns a SEALED (approved) spec to an editable `draft`, clearing
|
|
1188
|
+
* `approved_digest` and `parent_digests` in ONE act, and records `spec-unsealed`. Hand-editing
|
|
1189
|
+
* only `status` leaves seal residue that later trips validation; this atomizes the reverse.
|
|
1190
|
+
*
|
|
1191
|
+
* Un-sealing WITHDRAWS a seal, so — like retiring a sealed document — it demands a covering
|
|
1192
|
+
* out-of-band HOLMES_APPROVAL (fail-closed): un-sealing an approved T-SPEC removes the code
|
|
1193
|
+
* gate's demand, so an unguarded un-seal would be an approval bypass. And it refuses when an
|
|
1194
|
+
* APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
|
|
1195
|
+
* drift detection compares against — would silently go stale.
|
|
1196
|
+
*/
|
|
1197
|
+
async spec_unseal(a) {
|
|
1198
|
+
const all = await store.list();
|
|
1199
|
+
if (all.filter((s) => s.id === a.id).length > 1) {
|
|
1200
|
+
return {
|
|
1201
|
+
ok: false,
|
|
1202
|
+
reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 봉인 해제 전에 거부합니다.`,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
const cur = await store.read(a.id);
|
|
1206
|
+
if (!cur) {
|
|
1207
|
+
const dir = store.specsRoot;
|
|
1208
|
+
const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
|
|
1209
|
+
return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
|
|
1210
|
+
}
|
|
1211
|
+
const spec = cur.spec;
|
|
1212
|
+
if (!spec.type) {
|
|
1213
|
+
return {
|
|
1214
|
+
ok: false,
|
|
1215
|
+
reason: `${a.id}에는 \`type:\` 선언이 없습니다(구형식 문서) — 어느 폴더에 속하는지 알 수 없어 봉인 해제할 수 없습니다.`
|
|
1216
|
+
+ ` 먼저 spec_upgrade({ id: "${a.id}" })로 형식을 올린 뒤 다시 시도하십시오.`,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
// Sealed ⇔ carries approved_digest. Un-sealing an already-draft spec writes NOTHING (a second
|
|
1220
|
+
// un-seal is history, not graffiti) — mirrors spec_retire's idempotent no-op.
|
|
1221
|
+
const sealed = typeof spec.frontmatter.approved_digest === 'string';
|
|
1222
|
+
if (!sealed) {
|
|
1223
|
+
return { ok: true, unsealed: false, id: a.id, dependents: [], reason: `${a.id}은(는) 이미 미봉인(draft) 상태입니다` };
|
|
1224
|
+
}
|
|
1225
|
+
const approvalRaw = process.env.HOLMES_APPROVAL;
|
|
1226
|
+
let approval;
|
|
1227
|
+
try {
|
|
1228
|
+
approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
|
|
1229
|
+
}
|
|
1230
|
+
catch {
|
|
1231
|
+
approval = undefined;
|
|
1232
|
+
}
|
|
1233
|
+
const resolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
|
|
1234
|
+
if (resolved === undefined) {
|
|
1235
|
+
return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
reason: `${a.id}은(는) 봉인된 문서입니다 — 봉인 해제는 이 행위를 덮는 유효한 대역외 HOLMES_APPROVAL 이 필요합니다.`
|
|
1238
|
+
+ ' 봉인을 해제하면 코드 게이트가 요구하던 approved 스펙의 봉인이 사라지므로, 해제가 승인 우회 경로가 되지 않도록 fail-closed 로 막습니다.'
|
|
1239
|
+
+ ' (범위를 쓰면 kind "spec-approve")'
|
|
1240
|
+
+ refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '봉인된 스펙의 해제' }),
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
// @implements A-SPEC-245 — a grant that authorized breaking a seal is spent by it.
|
|
1244
|
+
if (resolved.source === 'grant' && resolved.root && resolved.approval.nonce) {
|
|
1245
|
+
(0, approval_grants_1.consumeGrantFile)(resolved.root, resolved.approval.nonce);
|
|
1246
|
+
}
|
|
1247
|
+
const dependents = all.filter((s) => s.id !== a.id && s.dependsOn.includes(a.id));
|
|
1248
|
+
const blocking = dependents.filter((s) => s.status === 'approved').map((s) => s.id);
|
|
1249
|
+
if (blocking.length > 0) {
|
|
1250
|
+
return {
|
|
1251
|
+
ok: false,
|
|
1252
|
+
reason: `${a.id}의 봉인을 해제하면 approved 문서 ${blocking.join(', ')}의 parent_digests 가 stale 이 됩니다 — 지금 그 사슬을 지탱하고 있으므로 거부합니다.`
|
|
1253
|
+
+ ' 해당 문서를 먼저 해제/폐기하거나 부모를 다른 문서로 옮기십시오.',
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
// Destination BEFORE the write (A-SPEC-188 order): a resolution failure leaves nothing behind.
|
|
1257
|
+
const dest = resolveLedgerRoot(a.root);
|
|
1258
|
+
if (!dest.ok)
|
|
1259
|
+
return { ok: false, reason: dest.reason };
|
|
1260
|
+
const ledgerRoot = dest.root;
|
|
1261
|
+
const fm = { ...spec.frontmatter };
|
|
1262
|
+
delete fm.approved_digest; // the seal this act withdraws
|
|
1263
|
+
delete fm.parent_digests;
|
|
1264
|
+
try {
|
|
1265
|
+
await store.write({ ...spec, status: 'draft', frontmatter: fm }, { expectedVersion: cur.version });
|
|
1266
|
+
}
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
if (e instanceof spec_store_2.SpecVersionConflictError) {
|
|
1269
|
+
return { ok: false, reason: `봉인 해제 진행 중 ${a.id}이(가) 바뀌었습니다 — 확인 후 다시 시도하십시오. 이번 해제는 아무것도 쓰지 않았습니다.` };
|
|
1270
|
+
}
|
|
1271
|
+
throw e;
|
|
1272
|
+
}
|
|
1273
|
+
new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger')).append({
|
|
1274
|
+
ts: new Date().toISOString(),
|
|
1275
|
+
actor: approval?.actor ?? 'unattributed',
|
|
1276
|
+
kind: 'spec-unsealed',
|
|
1277
|
+
summary: `unsealed ${a.id}`,
|
|
1278
|
+
inputs: [a.id],
|
|
1279
|
+
rationale: approval?.rationale ?? 'unsealed',
|
|
1280
|
+
...(approval ? { authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token) } : {}),
|
|
1281
|
+
});
|
|
1282
|
+
return { ok: true, unsealed: true, id: a.id, dependents: dependents.map((s) => s.id) };
|
|
1283
|
+
},
|
|
1284
|
+
/**
|
|
1285
|
+
* @implements A-SPEC-538.2
|
|
1286
|
+
* Read-only: report a spec's approval/seal state — sealed?, approved_digest, each parent's seal
|
|
1287
|
+
* state, and the concrete blockers still standing between it and approval — so a caller need not
|
|
1288
|
+
* parse files to ask "what is the approval state right now". Reuses `sealOf` and `approvalBlockers`
|
|
1289
|
+
* (the same predicates the code gate and spec_approve read) so the report cannot drift from the
|
|
1290
|
+
* acts it describes. No writes, no ledger append.
|
|
1291
|
+
*/
|
|
1292
|
+
async approval_status(a) {
|
|
1293
|
+
assertSpecStoreReachable('approval_status', store, a.root); // @implements A-SPEC-419
|
|
1294
|
+
const all = await store.list();
|
|
1295
|
+
if (all.filter((s) => s.id === a.id).length > 1) {
|
|
1296
|
+
return {
|
|
1297
|
+
ok: false,
|
|
1298
|
+
reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본의 상태를 물었는지 도구가 고를 수 없어 거부합니다.`,
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
const cur = await store.read(a.id);
|
|
1302
|
+
if (!cur) {
|
|
1303
|
+
const dir = store.specsRoot;
|
|
1304
|
+
const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
|
|
1305
|
+
return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
|
|
1306
|
+
}
|
|
1307
|
+
return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, resolver(all)) };
|
|
1308
|
+
},
|
|
1309
|
+
/**
|
|
1310
|
+
* @implements A-SPEC-538.3
|
|
1311
|
+
* Read-only: return the provenance ledger's events in time order (optionally narrowed to one
|
|
1312
|
+
* spec) so the governance history — approved / unsealed / retired / review-needed … — is legible
|
|
1313
|
+
* at a glance without reading raw JSONL. Reuses FileLedgerStore.loadAll(); the ordering/filtering/
|
|
1314
|
+
* projection is the pure `timelineFrom`. No writes. An absent ledger is an empty history, not an
|
|
1315
|
+
* error.
|
|
1316
|
+
*/
|
|
1317
|
+
async ledger_timeline(a) {
|
|
1318
|
+
const dest = resolveLedgerRoot(a.root);
|
|
1319
|
+
if (!dest.ok)
|
|
1320
|
+
return { ok: false, reason: dest.reason };
|
|
1321
|
+
const dir = path.join(dest.root, '.ax', 'ledger');
|
|
1322
|
+
const events = fs.existsSync(dir) ? new ledger_store_1.FileLedgerStore(dir).loadAll() : [];
|
|
1323
|
+
return { ok: true, events: (0, ledger_timeline_1.timelineFrom)(events, a.id) };
|
|
1324
|
+
},
|
|
1127
1325
|
async spec_approve(a) {
|
|
1128
1326
|
const approvalRaw = process.env.HOLMES_APPROVAL;
|
|
1129
1327
|
let approval;
|
|
@@ -1259,8 +1457,15 @@ function makeRawHandlers(store, opts) {
|
|
|
1259
1457
|
// element instead leaves a window in which an external edit is silently destroyed and the
|
|
1260
1458
|
// STALE content gets sealed (measured: 17 of 40 concurrent edits lost, 35-55ms window).
|
|
1261
1459
|
const cur = await store.read(a.id);
|
|
1262
|
-
|
|
1263
|
-
|
|
1460
|
+
// @implements A-SPEC-536.1 — BUG-1: a spec whose YAML is broken is dropped by read()/list(),
|
|
1461
|
+
// so a bare "not found" hid that the file EXISTS but cannot be parsed. Surface the skipped
|
|
1462
|
+
// files when there are any; byte-identical to the legacy message when there are none. The
|
|
1463
|
+
// store's specsRoot is read through the same cast the reachability checks use (A-SPEC-169).
|
|
1464
|
+
if (!cur) {
|
|
1465
|
+
const dir = store.specsRoot;
|
|
1466
|
+
const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
|
|
1467
|
+
return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
|
|
1468
|
+
}
|
|
1264
1469
|
const spec = cur.spec;
|
|
1265
1470
|
// @implements A-SPEC-188 — duplicates make the id ambiguous, for the SPEC and for its
|
|
1266
1471
|
// PARENTS alike. Round-3 probed the parent half: with a stray duplicate of the parent
|
|
@@ -1358,10 +1563,15 @@ function makeRawHandlers(store, opts) {
|
|
|
1358
1563
|
}
|
|
1359
1564
|
catch (e) {
|
|
1360
1565
|
if (e instanceof spec_store_2.SpecVersionConflictError) {
|
|
1566
|
+
// @implements A-SPEC-538.4 — the refusal is unchanged (refuse, write nothing, the edit
|
|
1567
|
+
// wins); it now also CARRIES the conflict: the version this act read, the version now on
|
|
1568
|
+
// disk, and what to retry. Re-read to learn the current version (null if it vanished).
|
|
1569
|
+
const now = await store.read(a.id).catch(() => null);
|
|
1361
1570
|
return {
|
|
1362
1571
|
ok: false,
|
|
1363
1572
|
reason: `승인 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 승인하십시오.`
|
|
1364
1573
|
+ ' 이번 승인은 아무것도 쓰지 않았습니다.',
|
|
1574
|
+
conflict: (0, version_conflict_1.conflictDetail)({ id: a.id, expected: cur.version, current: now?.version ?? null }),
|
|
1365
1575
|
};
|
|
1366
1576
|
}
|
|
1367
1577
|
// @implements A-SPEC-188 — approval can RELOCATE the file (req_type classification moves
|
|
@@ -1829,6 +2039,26 @@ function makeRawHandlers(store, opts) {
|
|
|
1829
2039
|
// Closes the decision->execution loop: scope -> run -> durable per-A-SPEC EXECUTION evidence
|
|
1830
2040
|
// (what the constitution's ART-4 prefers over the syntactic count).
|
|
1831
2041
|
const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'test_run');
|
|
2042
|
+
// @implements A-SPEC-534.8 — `mutate`: run a T-SPEC's declared `kills` mutations against the
|
|
2043
|
+
// A-SPEC's source and report which SURVIVED (a discriminating-power gap). Selective, opt-in via
|
|
2044
|
+
// the argument; absent → the ordinary run below is untouched.
|
|
2045
|
+
if (a.mutate) {
|
|
2046
|
+
const tspec = specs.find((s) => s.id === a.mutate && s.type === 'T-SPEC');
|
|
2047
|
+
const kills = tspec ? (0, kills_1.parseKills)(tspec.frontmatter) : [];
|
|
2048
|
+
const aspecId = tspec?.dependsOn[0];
|
|
2049
|
+
const anchors = (0, test_scope_1.scanTestAnchors)(root);
|
|
2050
|
+
const coveringFiles = aspecId
|
|
2051
|
+
? Object.entries(anchors).filter(([, ids]) => ids.includes(aspecId)).map(([f]) => f) : [];
|
|
2052
|
+
const results = kills.map((m) => {
|
|
2053
|
+
const src = aspecId ? sourceFileWithMutation(root, aspecId, m.where) : null;
|
|
2054
|
+
if (!src)
|
|
2055
|
+
return { mutation: m, applied: false, reason: 'no governed source anchors this A-SPEC and contains `where`' };
|
|
2056
|
+
const r = (0, mutate_1.runKillsOnFile)(src, m, coveringFiles, (files) => (0, test_runner_1.runJestOutcomes)(files, root));
|
|
2057
|
+
return { mutation: m, ...r };
|
|
2058
|
+
});
|
|
2059
|
+
const survivors = results.filter((r) => r.verdict === 'survived').map((r) => r.mutation);
|
|
2060
|
+
return { mutate: { tspec: a.mutate, aspec: aspecId, coveringFiles, results, survivors } };
|
|
2061
|
+
}
|
|
1832
2062
|
const g = new rtm_graph_1.RtmGraph();
|
|
1833
2063
|
let testScope;
|
|
1834
2064
|
try {
|
|
@@ -1869,6 +2099,12 @@ function makeRawHandlers(store, opts) {
|
|
|
1869
2099
|
if (verified) {
|
|
1870
2100
|
(0, test_evidence_1.writeTestEvidence)(root, { ts: new Date().toISOString(), head, tier: testScope.tier, passed: true, executedByAspec });
|
|
1871
2101
|
}
|
|
2102
|
+
// @implements A-SPEC-534.5 — RED-first outcome evidence for ART-8. Recorded UNCONDITIONALLY,
|
|
2103
|
+
// unlike the green-only baseline above: a red-assertion recorded before the code is exactly what
|
|
2104
|
+
// the red→green sequence needs. Append-only, one record per (A-SPEC, outcome) at this HEAD.
|
|
2105
|
+
if (result.outcomeByFile) {
|
|
2106
|
+
(0, test_outcomes_1.appendOutcomes)(root, (0, test_outcomes_1.buildOutcomeRecords)(result.outcomeByFile, anchors, head, new Date().toISOString()));
|
|
2107
|
+
}
|
|
1872
2108
|
// @implements A-SPEC-128
|
|
1873
2109
|
// The baseline is written under EXACTLY the condition that already gates evidence: a run that
|
|
1874
2110
|
// actually executed and passed. A red or skipped run must never become the reference point for
|
|
@@ -3060,7 +3296,7 @@ id: ${aspecId}
|
|
|
3060
3296
|
type: A-SPEC
|
|
3061
3297
|
title: ${(0, yaml_scalar_1.yamlScalar)(`Architecture Specification for ${a.title}`)}
|
|
3062
3298
|
status: draft
|
|
3063
|
-
slice: ${a.sliceName}
|
|
3299
|
+
slice: ${(0, yaml_scalar_1.yamlScalar)(a.sliceName)}
|
|
3064
3300
|
priority: P1
|
|
3065
3301
|
independent_test: true
|
|
3066
3302
|
depends_on:
|
|
@@ -101,6 +101,38 @@ exports.TOOL_SCHEMAS = {
|
|
|
101
101
|
required: ['id'],
|
|
102
102
|
},
|
|
103
103
|
},
|
|
104
|
+
spec_unseal: {
|
|
105
|
+
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
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
id: str('Id of the spec to un-seal (return to draft).'),
|
|
110
|
+
root: str('Optional when the server is bound to a file store — the ledger location is derived from the store itself; if supplied it must resolve to the SAME project.'),
|
|
111
|
+
},
|
|
112
|
+
required: ['id'],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
approval_status: {
|
|
116
|
+
description: "Read-only: report ONE spec's approval/seal state without the caller parsing files — { sealed, approvedDigest?, status, parents: [{ id, status, sealed, resolved }], blockers: [] }. `sealed` is the same approved_digest predicate the code gate reads; `blockers` is the exact list spec_approve would refuse on (empty once approved), so the report cannot drift from the acts it describes. Each parent entry shows that parent's OWN live seal state (an unresolved depends_on id is reported as status:'missing', resolved:false — not dropped). No writes, no ledger append. Refuses a duplicate or unknown id.",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
id: str('Id of the spec whose approval/seal state to report.'),
|
|
121
|
+
root: str('Optional when the server is bound to a file store; if supplied it must resolve to the SAME project.'),
|
|
122
|
+
},
|
|
123
|
+
required: ['id'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
ledger_timeline: {
|
|
127
|
+
description: "Read-only: return the provenance ledger's events in time order — { events: [{ ts, kind, actor, summary, inputs }] } ascending by ts (tiebroken by chain seq). Pass an `id` to narrow to one spec (only events whose `inputs` reference it: spec-approved, spec-unsealed, spec-retired, review-needed …). The chain-integrity fields (hash/prevHash/seq/replicaId) are plumbing and are projected away — this is the governance history, not the tamper-evidence chain. No writes. An absent ledger is an empty history, not an error.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {
|
|
131
|
+
id: str('Optional spec id — narrow the timeline to events that reference it.'),
|
|
132
|
+
root: str('Optional when the server is bound to a file store; the ledger location is derived from the store, or from this root.'),
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
104
136
|
spec_approve: {
|
|
105
137
|
description: 'Approve a spec as a sealing ACT: confirm the ledger destination BEFORE sealing → validate (zero errors) → record approved_digest + parent_digests snapshots → status: approved (written only at the version this act read; a concurrent edit wins and the approval is refused for retry) → append spec-approved to the provenance ledger. Requires a valid out-of-band HOLMES_APPROVAL in the SERVER environment (fail-closed; nothing in the request can substitute). Refuses an unsealed approved parent — seal parents first.',
|
|
106
138
|
inputSchema: {
|
|
@@ -203,6 +235,7 @@ exports.TOOL_SCHEMAS = {
|
|
|
203
235
|
head: str('Head rev (e.g. HEAD). Optional — omit to compare against a baseline.'),
|
|
204
236
|
since: str('Baseline label to compare against when no git range is given (default `last-green`, recorded by a passing test_run). Works with or without version control.'),
|
|
205
237
|
mark: str('Baseline label to record when the run passes (default `last-green`). Nothing is recorded for a red or skipped run.'),
|
|
238
|
+
mutate: str('T-SPEC id (e.g. T-SPEC-129.1). When set, runs that spec’s declared `kills` mutations against the A-SPEC’s source and reports which SURVIVED — a discriminating-power gap. Selective, opt-in; the ordinary run is skipped.'),
|
|
206
239
|
},
|
|
207
240
|
required: ['root'],
|
|
208
241
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Mutation } from '../spec/kills';
|
|
2
|
+
import { TestOutcome } from './test-runner';
|
|
3
|
+
/**
|
|
4
|
+
* Did the mutation KILL the covering tests? A kill is a `red-assertion` in at least one covering
|
|
5
|
+
* file — the mutation broke behaviour and a test caught it. A green (or absent, or red-error) result
|
|
6
|
+
* is `survived`: the tests did not catch the change, which is a discriminating-power gap. Pure.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mutationVerdict(outcomeByFile: Record<string, TestOutcome>, coveringFiles: string[]): 'killed' | 'survived';
|
|
9
|
+
/**
|
|
10
|
+
* Apply one mutation to a source file, run the covering tests through the injected runner, judge the
|
|
11
|
+
* verdict, and ALWAYS restore the original source. `applied:false` when `where` is absent (the file
|
|
12
|
+
* is never touched).
|
|
13
|
+
*/
|
|
14
|
+
export declare function runKillsOnFile(sourceFile: string, mutation: Mutation, coveringFiles: string[], run: (files: string[]) => Record<string, TestOutcome>): {
|
|
15
|
+
applied: boolean;
|
|
16
|
+
verdict?: 'killed' | 'survived';
|
|
17
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.mutationVerdict = mutationVerdict;
|
|
37
|
+
exports.runKillsOnFile = runKillsOnFile;
|
|
38
|
+
// @implements A-SPEC-534.8
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const kills_1 = require("../spec/kills");
|
|
41
|
+
/**
|
|
42
|
+
* Did the mutation KILL the covering tests? A kill is a `red-assertion` in at least one covering
|
|
43
|
+
* file — the mutation broke behaviour and a test caught it. A green (or absent, or red-error) result
|
|
44
|
+
* is `survived`: the tests did not catch the change, which is a discriminating-power gap. Pure.
|
|
45
|
+
*/
|
|
46
|
+
function mutationVerdict(outcomeByFile, coveringFiles) {
|
|
47
|
+
return coveringFiles.some((f) => outcomeByFile[f] === 'red-assertion') ? 'killed' : 'survived';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Apply one mutation to a source file, run the covering tests through the injected runner, judge the
|
|
51
|
+
* verdict, and ALWAYS restore the original source. `applied:false` when `where` is absent (the file
|
|
52
|
+
* is never touched).
|
|
53
|
+
*/
|
|
54
|
+
function runKillsOnFile(sourceFile, mutation, coveringFiles, run) {
|
|
55
|
+
const original = fs.readFileSync(sourceFile, 'utf8');
|
|
56
|
+
const mutated = (0, kills_1.applyMutation)(original, mutation);
|
|
57
|
+
if (mutated === null)
|
|
58
|
+
return { applied: false }; // where absent: never touch the file
|
|
59
|
+
fs.writeFileSync(sourceFile, mutated);
|
|
60
|
+
try {
|
|
61
|
+
return { applied: true, verdict: mutationVerdict(run(coveringFiles), coveringFiles) };
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
fs.writeFileSync(sourceFile, original); // ALWAYS restore, even if run() threw
|
|
65
|
+
}
|
|
66
|
+
}
|