@holmes-lab/holmes-kit 0.19.6 → 0.20.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 +96 -0
- package/README.md +8 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +86 -12
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +653 -424
- package/dist/holmes/mcp/handlers.js +267 -3250
- package/dist/holmes/mcp/server.js +2 -1
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +120 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +3 -1
- package/dist/holmes/project/root.js +4 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +132 -4
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +207 -0
- package/dist/holmes/spec/entity-integration.js +747 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1051 -0
- package/dist/holmes/spec/entity-transaction.d.ts +89 -0
- package/dist/holmes/spec/entity-transaction.js +701 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- package/package.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { SpecStore } from '../../spec/spec-store';
|
|
2
|
+
import { Spec } from '../../spec/spec-parser';
|
|
3
|
+
import { ScannedFile } from '../../cpg/cpg-scanner';
|
|
4
|
+
import { Approval } from '../../guardrail/risk-gate';
|
|
5
|
+
import { ElicitOutcome } from '../elicit-approval';
|
|
6
|
+
export interface SpecApprovalContext {
|
|
7
|
+
store: SpecStore;
|
|
8
|
+
resolver(specs: Spec[]): (id: string) => Spec | null;
|
|
9
|
+
projectRootOf(root: string): string;
|
|
10
|
+
cachedScan(root: string, repoRoot?: string): ScannedFile[];
|
|
11
|
+
tryElicit(kind: string, target: string, summary: string): Promise<ElicitOutcome>;
|
|
12
|
+
elicitApproval(reason?: string): Approval;
|
|
13
|
+
autonomousApproval(derivedFrom?: string[]): Approval;
|
|
14
|
+
resolveHandlerApproval(root: string | undefined, approval: Approval | undefined, action: {
|
|
15
|
+
kind: string;
|
|
16
|
+
target: string;
|
|
17
|
+
subject?: string;
|
|
18
|
+
}, now: string): {
|
|
19
|
+
approval: Approval;
|
|
20
|
+
source: 'env' | 'grant';
|
|
21
|
+
root?: string;
|
|
22
|
+
} | undefined;
|
|
23
|
+
refusalQueueHint(root: string | undefined, request: {
|
|
24
|
+
kind: string;
|
|
25
|
+
target: string;
|
|
26
|
+
why: string;
|
|
27
|
+
subject?: string;
|
|
28
|
+
risk?: string;
|
|
29
|
+
}): string;
|
|
30
|
+
foreignRootReason(root?: string): string | null;
|
|
31
|
+
}
|
|
32
|
+
export declare function createSpecApprovalHandlers(context: SpecApprovalContext): {
|
|
33
|
+
/**
|
|
34
|
+
* @implements A-SPEC-132
|
|
35
|
+
* Approval as an ACT: validate → seal → flip → ledger, in one call. This is the designed
|
|
36
|
+
* reversal of "no approval tool exists" (which promote-slice documented while it was true) —
|
|
37
|
+
* the act now includes digest computation a hand edit cannot perform honestly. Fail-closed on
|
|
38
|
+
* the SERVER-environment approval: nothing in the request payload can substitute, because the
|
|
39
|
+
* agent authors the payload and the operator authors the environment.
|
|
40
|
+
*/
|
|
41
|
+
spec_approve(a: {
|
|
42
|
+
root?: string;
|
|
43
|
+
id: string;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
ok: false;
|
|
46
|
+
reason: string;
|
|
47
|
+
findings: import("../../spec/validator").Finding[];
|
|
48
|
+
candidate?: undefined;
|
|
49
|
+
digest?: undefined;
|
|
50
|
+
parentDigests?: undefined;
|
|
51
|
+
} | {
|
|
52
|
+
ok: boolean;
|
|
53
|
+
reason: string;
|
|
54
|
+
conflict?: undefined;
|
|
55
|
+
} | {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
reason: string;
|
|
58
|
+
conflict: import("../../spec/version-conflict").ConflictDetail;
|
|
59
|
+
} | {
|
|
60
|
+
impactAdvisoryUnavailable?: "empty" | "unreadable" | undefined;
|
|
61
|
+
impactGraph?: {
|
|
62
|
+
status: "current" | "stale" | "unverified";
|
|
63
|
+
sourceCommit: string | null;
|
|
64
|
+
head: string | null;
|
|
65
|
+
graphAsOf?: string;
|
|
66
|
+
} | undefined;
|
|
67
|
+
anchorDensity?: import("../../rtm/anchor-density").AnchorDensityFinding[] | undefined;
|
|
68
|
+
impactAdvisory?: import("../../rtm/impact-advisory").ImpactAdvisory | undefined;
|
|
69
|
+
approved: string;
|
|
70
|
+
digest: string;
|
|
71
|
+
ok?: undefined;
|
|
72
|
+
reason?: undefined;
|
|
73
|
+
conflict?: undefined;
|
|
74
|
+
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Pin a REQ's citations: compute the content digest of every cited source that resolves inside
|
|
77
|
+
* the repo and record it as `rev`, so an author never hashes a file by hand and the digest is
|
|
78
|
+
* always derived from what is actually on disk.
|
|
79
|
+
*
|
|
80
|
+
* Explicit-invocation only, dry-run by DEFAULT, and never overwrites an existing digest — a
|
|
81
|
+
* re-pin would replace evidence of upstream drift with a fresh-looking value, converting the
|
|
82
|
+
* detector into a concealer.
|
|
83
|
+
*/
|
|
84
|
+
citation_pin(a: {
|
|
85
|
+
root: string;
|
|
86
|
+
id: string;
|
|
87
|
+
dryRun?: boolean;
|
|
88
|
+
}): Promise<{
|
|
89
|
+
ok: boolean;
|
|
90
|
+
reason: string;
|
|
91
|
+
id?: undefined;
|
|
92
|
+
dryRun?: undefined;
|
|
93
|
+
pinned?: undefined;
|
|
94
|
+
findings?: undefined;
|
|
95
|
+
citations?: undefined;
|
|
96
|
+
} | {
|
|
97
|
+
ok: boolean;
|
|
98
|
+
id: string;
|
|
99
|
+
dryRun: boolean;
|
|
100
|
+
pinned: string[];
|
|
101
|
+
findings: import("../../spec/validator").Finding[];
|
|
102
|
+
citations: unknown[];
|
|
103
|
+
reason?: undefined;
|
|
104
|
+
}>;
|
|
105
|
+
};
|
|
@@ -0,0 +1,504 @@
|
|
|
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.createSpecApprovalHandlers = createSpecApprovalHandlers;
|
|
37
|
+
// @implements A-SPEC-627.1, A-SPEC-622, A-SPEC-100.2, A-SPEC-128, A-SPEC-132, A-SPEC-133, A-SPEC-151, A-SPEC-188, A-SPEC-189, A-SPEC-244, A-SPEC-245
|
|
38
|
+
// @implements A-SPEC-631
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const spec_store_1 = require("../../spec/spec-store");
|
|
42
|
+
const transition_policy_1 = require("../../spec/transition-policy");
|
|
43
|
+
const validator_1 = require("../../spec/validator");
|
|
44
|
+
const elicit_approval_1 = require("../elicit-approval");
|
|
45
|
+
const autonomy_1 = require("../../governance/autonomy");
|
|
46
|
+
const write_target_1 = require("../../guardrail/write-target");
|
|
47
|
+
const approval_grants_1 = require("../../governance/approval-grants");
|
|
48
|
+
const spec_digest_1 = require("../../spec/spec-digest");
|
|
49
|
+
const version_conflict_1 = require("../../spec/version-conflict");
|
|
50
|
+
const ledger_store_1 = require("../../governance/ledger-store");
|
|
51
|
+
const provenance_chain_1 = require("../../governance/provenance-chain");
|
|
52
|
+
const review_targets_1 = require("../../review/review-targets");
|
|
53
|
+
function createSpecApprovalHandlers(context) {
|
|
54
|
+
const { store, resolver, projectRootOf, cachedScan, tryElicit, elicitApproval, autonomousApproval } = context;
|
|
55
|
+
return {
|
|
56
|
+
/**
|
|
57
|
+
* @implements A-SPEC-132
|
|
58
|
+
* Approval as an ACT: validate → seal → flip → ledger, in one call. This is the designed
|
|
59
|
+
* reversal of "no approval tool exists" (which promote-slice documented while it was true) —
|
|
60
|
+
* the act now includes digest computation a hand edit cannot perform honestly. Fail-closed on
|
|
61
|
+
* the SERVER-environment approval: nothing in the request payload can substitute, because the
|
|
62
|
+
* agent authors the payload and the operator authors the environment.
|
|
63
|
+
*/
|
|
64
|
+
async spec_approve(a) {
|
|
65
|
+
const approvalRaw = process.env.HOLMES_APPROVAL;
|
|
66
|
+
let approval;
|
|
67
|
+
try {
|
|
68
|
+
approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
approval = undefined;
|
|
72
|
+
}
|
|
73
|
+
// covers, not merely well-formed (round-3 escalation: a token scoped to review-resolve — or
|
|
74
|
+
// expired outright — completed a FULL SEAL here, so A-SPEC-133's narrowing was decorative at
|
|
75
|
+
// the most consequential consumer). Unscoped {actor,token,rationale} stays the session key.
|
|
76
|
+
// @implements A-SPEC-649 — coverage is resolved by kind/target here; the SUBJECT binding of a
|
|
77
|
+
// grant is checked against the ONE versioned read the seal uses (below), never against an
|
|
78
|
+
// extra read — an extra read moved the optimistic-write window and let an external edit be
|
|
79
|
+
// sealed (measured by the A-SPEC-188 TOCTOU pin).
|
|
80
|
+
const action = { kind: 'spec-approve', target: a.id };
|
|
81
|
+
let approveResolved = context.resolveHandlerApproval(a.root, approval, action, new Date().toISOString());
|
|
82
|
+
// @implements A-SPEC-263.1 — asked ONLY after the existing channels failed to cover (an open
|
|
83
|
+
// door never summons a human), and only about a spec that exists (a question about a missing
|
|
84
|
+
// id helps no one — the standard refusal handles it).
|
|
85
|
+
let elicitExpiredMs;
|
|
86
|
+
let risk;
|
|
87
|
+
let subject;
|
|
88
|
+
if (approveResolved === undefined) {
|
|
89
|
+
const target = await store.read(a.id).catch(() => null);
|
|
90
|
+
// @implements A-SPEC-649 — the digest of what the operator will read rides the request.
|
|
91
|
+
subject = target ? (0, spec_digest_1.specDigest)(target.spec) : undefined;
|
|
92
|
+
if (target) {
|
|
93
|
+
// @implements A-SPEC-532.2 — the autonomous gate sits BEFORE the human ask: when the
|
|
94
|
+
// out-of-band autonomy switch is on AND the spec is low/mid-risk (never gate-behavior, an
|
|
95
|
+
// architecture/taint file, or an upstream REQ/H/C — those stay human), the agent seals it
|
|
96
|
+
// itself. The switch is env-only and an agent cannot set it (pre-tool-use blocks that,
|
|
97
|
+
// A-SPEC-532.2). Off, or a hitl-classed spec, falls straight through to the elicitor
|
|
98
|
+
// unchanged — the autonomous-OFF path is byte-identical to before.
|
|
99
|
+
// @implements A-SPEC-553.1 — autonomy is the out-of-band env switch OR a valid, non-expired
|
|
100
|
+
// session envelope marker under this project's `.ax/state/` (which an agent cannot write).
|
|
101
|
+
const autonomyOn = (0, autonomy_1.autonomousApprovalEnabled)(process.env, a.root, new Date().toISOString());
|
|
102
|
+
// @implements A-SPEC-587 — an upstream REQ/H-SPEC is graded by the A-SPECs beneath it, so
|
|
103
|
+
// the store is consulted for its descendants (only then: the list is a cost the auto-grade
|
|
104
|
+
// A-SPEC/T-SPEC path does not pay). Unknown scope (none yet) keeps the human answer.
|
|
105
|
+
const upstream = target.spec.type === 'REQ' || target.spec.type === 'H-SPEC';
|
|
106
|
+
const below = upstream ? (0, autonomy_1.descendantAspecs)(target.spec, await store.list()) : undefined;
|
|
107
|
+
// @implements A-SPEC-649 — the grade is recorded on the request either way (the screen shows it).
|
|
108
|
+
risk = (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec]), below);
|
|
109
|
+
if (autonomyOn
|
|
110
|
+
&& (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec]), below) === 'auto') {
|
|
111
|
+
approveResolved = { approval: autonomousApproval(below?.map((s) => s.id)), source: 'autonomous' };
|
|
112
|
+
}
|
|
113
|
+
else if (autonomyOn) {
|
|
114
|
+
// @implements A-SPEC-551.1 — hitl-grade spec under autonomy: the in-session elicitation
|
|
115
|
+
// dialog is auto-acceptable by an auto-mode client (the protocol cannot tell a human
|
|
116
|
+
// from an auto-accept), so a governance-critical spec is never offered it. Do nothing
|
|
117
|
+
// here — approveResolved stays undefined and the fail-closed refuse+enqueue path below
|
|
118
|
+
// routes the act to the out-of-band human queue (holmes-kit approve). The A-SPEC-532.1
|
|
119
|
+
// bound ("governance-critical specs never leave the human channel") thus becomes
|
|
120
|
+
// ENFORCED, not aspirational. Autonomy OFF (the else) and the auto-grade branch above
|
|
121
|
+
// stay byte-identical to before.
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
|
|
125
|
+
// The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
|
|
126
|
+
// title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
|
|
127
|
+
// more consequential act) as a first approval. The cap cuts the title, never the marker.
|
|
128
|
+
const out = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
|
|
129
|
+
if (out.kind === 'answered' && out.decision.granted) {
|
|
130
|
+
approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
|
|
131
|
+
}
|
|
132
|
+
else if (out.kind === 'answered') {
|
|
133
|
+
// The human ANSWERED (deny/question/decline): the answer is the message, and no queue
|
|
134
|
+
// entry is filed — a decided request is not a pending one (REQ-246 visibility).
|
|
135
|
+
return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
|
|
136
|
+
}
|
|
137
|
+
else if (out.kind === 'expired') {
|
|
138
|
+
// @implements A-SPEC-497.1 — only the expiry earns a name: the notice LEADS the same
|
|
139
|
+
// fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
|
|
140
|
+
// message learned to say what happened.
|
|
141
|
+
elicitExpiredMs = out.waitedMs;
|
|
142
|
+
}
|
|
143
|
+
// silent: the channel gave no answer — fall through to the byte-identical refusal.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (approveResolved === undefined) {
|
|
148
|
+
// @implements A-SPEC-649 — a grant that exists but is refused by its binding is NAMED, so the
|
|
149
|
+
// operator learns to revoke or re-decide instead of wondering why nothing opened.
|
|
150
|
+
let refusedGrants = '';
|
|
151
|
+
try {
|
|
152
|
+
const projectRoot = context.projectRootOf(a.root ?? store.specsRoot);
|
|
153
|
+
refusedGrants = (0, approval_grants_1.refusedGrantReasons)(projectRoot, { ...action, ...(subject ? { subject } : {}) }, new Date().toISOString()).map((g) => `\n[grant ${g.nonce} refused: ${g.reason}]`).join('');
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
refusedGrants = '';
|
|
157
|
+
}
|
|
158
|
+
return { ok: false, reason: (elicitExpiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(elicitExpiredMs) : '') + refusedGrants
|
|
159
|
+
+ 'spec_approve requires an out-of-band HOLMES_APPROVAL that COVERS this act — a request-payload approval is not a channel, and an expired or elsewhere-scoped token does not open this door (scoped approvals need kind "spec-approve"). (fail-closed)'
|
|
160
|
+
+ context.refusalQueueHint(a.root, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인', ...(subject ? { subject } : {}), ...(risk ? { risk } : {}) }) };
|
|
161
|
+
}
|
|
162
|
+
// @implements A-SPEC-188 — destination BEFORE seal.
|
|
163
|
+
// The old order (seal at :481, resolve the ledger at :483) produced both measured harms: a
|
|
164
|
+
// bad root left a standing seal with no ledger entry anywhere while the CALLER WAS TOLD IT
|
|
165
|
+
// FAILED, and a foreign root filed the only audit record in another project's ledger. This
|
|
166
|
+
// repository lived the first one — REQ-182's chain was approved rootless on 2026-08-13 and
|
|
167
|
+
// the missing entries were found two review rounds later.
|
|
168
|
+
//
|
|
169
|
+
// The destination is DERIVED from the store the server was bound to (same principle as
|
|
170
|
+
// spec_create's A-SPEC-163 guard); a supplied `root` is a confirmation that must match. Any
|
|
171
|
+
// failure here is a refusal with nothing written.
|
|
172
|
+
let ledgerRoot;
|
|
173
|
+
try {
|
|
174
|
+
if (store instanceof spec_store_1.LocalMarkdownRepository) {
|
|
175
|
+
// Compared at PROJECT level, through the same walk-up the old code used: a root pointing
|
|
176
|
+
// anywhere INSIDE this project (the specs dir, a subdirectory) resolves to the same
|
|
177
|
+
// project and is accepted — round-2 review caught the first cut refusing those with a
|
|
178
|
+
// message that printed the identical path on both sides. Only a root resolving to a
|
|
179
|
+
// DIFFERENT project is refused, and the message names both projects.
|
|
180
|
+
//
|
|
181
|
+
// Round-3 caught the fallback: resolveProjectRoot returns its INPUT when no .ax ancestor
|
|
182
|
+
// exists, so a custom HOLMES_SPECS outside any .ax tree "derived" the specs dir itself as
|
|
183
|
+
// the project — a rootless approval then minted .ax/ledger INSIDE the spec store, and the
|
|
184
|
+
// correct explicit root was refused as a "different project". Derivation only counts when
|
|
185
|
+
// the walk actually found a marker; otherwise the old contract stands: root is required
|
|
186
|
+
// and names the project.
|
|
187
|
+
const derived = projectRootOf(store.specsRoot);
|
|
188
|
+
const derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
|
|
189
|
+
if (derivationFoundMarker) {
|
|
190
|
+
ledgerRoot = derived;
|
|
191
|
+
if (typeof a.root === 'string' && a.root !== '') {
|
|
192
|
+
const askedProject = (0, write_target_1.resolveTarget)(projectRootOf(a.root), '.');
|
|
193
|
+
const boundProject = (0, write_target_1.resolveTarget)(ledgerRoot, '.');
|
|
194
|
+
if (askedProject !== boundProject) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
reason: `이 서버는 ${boundProject} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${askedProject} 프로젝트를 가리킵니다.`
|
|
198
|
+
+ ' 다른 프로젝트의 원장에 기록하지 않기 위해 봉인 전에 거부합니다.',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (typeof a.root === 'string' && a.root !== '') {
|
|
204
|
+
ledgerRoot = projectRootOf(a.root);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: '스토어 위치에서 프로젝트를 파생할 수 없습니다(.ax 상위 디렉터리 없음) — 원장을 어디에 둘지 알 수 없어 봉인 전에 거부합니다. root를 지정하십시오.',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
if (typeof a.root !== 'string' || a.root === '') {
|
|
215
|
+
return { ok: false, reason: 'root가 없고 스토어에서 원장 위치를 파생할 수도 없습니다 — 봉인 전에 거부합니다. root를 지정하십시오.' };
|
|
216
|
+
}
|
|
217
|
+
ledgerRoot = projectRootOf(a.root);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
reason: `원장 위치를 확정할 수 없어 봉인 전에 거부합니다: ${String(e.message)}.`
|
|
224
|
+
+ ' 올바른 root를 지정하거나, 파일 스토어에 바인딩된 서버에서는 root를 생략하십시오.',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const specs = await store.list();
|
|
228
|
+
// @implements A-SPEC-188 — with DUPLICATE ids the tool cannot know which copy is canonical:
|
|
229
|
+
// read() resolves the last-walked file while list().find sees the first, so approving would
|
|
230
|
+
// seal a stray's content and delete the canonical file — success reported, edit destroyed
|
|
231
|
+
// (round-2 review reproduced exactly that). Duplicates are a recognized invalid state
|
|
232
|
+
// (doctor detects them); the act refuses rather than picking a side.
|
|
233
|
+
// @implements A-SPEC-188 — the spec this act validates and seals is the one read NOW, with
|
|
234
|
+
// its version captured for the optimistic write below. Basing the candidate on the list()
|
|
235
|
+
// element instead leaves a window in which an external edit is silently destroyed and the
|
|
236
|
+
// STALE content gets sealed (measured: 17 of 40 concurrent edits lost, 35-55ms window).
|
|
237
|
+
const cur = await store.read(a.id);
|
|
238
|
+
// @implements A-SPEC-649 — a grant bound to a subject opens only for THAT subject: the digest of
|
|
239
|
+
// the spec read now (the same read the seal is based on) must equal the one the operator read.
|
|
240
|
+
// Measured 2026-09-14: a grant read against "Original title" sealed "CHANGED after the grant".
|
|
241
|
+
if (cur && approveResolved.source === 'grant') {
|
|
242
|
+
const bound = approveResolved.approval;
|
|
243
|
+
if (typeof bound.subject === 'string' && bound.subject !== '' && bound.subject !== (0, spec_digest_1.specDigest)(cur.spec)) {
|
|
244
|
+
const current = (0, spec_digest_1.specDigest)(cur.spec);
|
|
245
|
+
return { ok: false, reason: `[grant ${bound.nonce ?? '?'} refused: stale-subject] the grant was minted for another content of ${a.id}; the operator must read the current content and decide again (revoke the stale grant with holmes-kit approve --revoke).`
|
|
246
|
+
+ context.refusalQueueHint(a.root, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인', subject: current }) };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// @implements A-SPEC-536.1 — BUG-1: a spec whose YAML is broken is dropped by read()/list(),
|
|
250
|
+
// so a bare "not found" hid that the file EXISTS but cannot be parsed. Surface the skipped
|
|
251
|
+
// files when there are any; byte-identical to the legacy message when there are none. The
|
|
252
|
+
// store's specsRoot is read through the same cast the reachability checks use (A-SPEC-169).
|
|
253
|
+
if (!cur) {
|
|
254
|
+
const dir = store.specsRoot;
|
|
255
|
+
const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
|
|
256
|
+
return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
|
|
257
|
+
}
|
|
258
|
+
const spec = cur.spec;
|
|
259
|
+
const prepared = (0, transition_policy_1.prepareApprovedSpec)(spec, specs, resolver, (rel) => {
|
|
260
|
+
try {
|
|
261
|
+
return a.root ? fs.readFileSync(path.join(a.root, rel), 'utf8') : null;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
if (!prepared.ok)
|
|
268
|
+
return prepared;
|
|
269
|
+
const { candidate, digest } = prepared;
|
|
270
|
+
const priorDigest = (0, spec_digest_1.sealOf)(spec).approvedDigest;
|
|
271
|
+
// @implements A-SPEC-188 — the seal is written only at the version this act READ. On
|
|
272
|
+
// conflict the EDIT wins and the APPROVAL loses: the edit is what a person just wrote; the
|
|
273
|
+
// approval is an act that can simply be retried. The reverse (the old behaviour) sealed
|
|
274
|
+
// stale content over a destroyed edit with no notice.
|
|
275
|
+
try {
|
|
276
|
+
await store.write(candidate, { expectedVersion: cur.version });
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
if (e instanceof spec_store_1.SpecVersionConflictError) {
|
|
280
|
+
// @implements A-SPEC-538.4 — the refusal is unchanged (refuse, write nothing, the edit
|
|
281
|
+
// wins); it now also CARRIES the conflict: the version this act read, the version now on
|
|
282
|
+
// disk, and what to retry. Re-read to learn the current version (null if it vanished).
|
|
283
|
+
const now = await store.read(a.id).catch(() => null);
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
reason: `승인 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 승인하십시오.`
|
|
287
|
+
+ ' 이번 승인은 아무것도 쓰지 않았습니다.',
|
|
288
|
+
conflict: (0, version_conflict_1.conflictDetail)({ id: a.id, expected: cur.version, current: now?.version ?? null }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// @implements A-SPEC-188 — approval can RELOCATE the file (req_type classification moves
|
|
292
|
+
// an H-SPEC into its subfolder). Round-3 probed that landing on an unreadable human note
|
|
293
|
+
// destroyed it with success reported; the store now refuses, and this act relays why.
|
|
294
|
+
if (e instanceof spec_store_1.TargetPathOccupiedError) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
|
|
298
|
+
+ ' 사람이 확인해 옮기거나 고친 뒤 다시 승인하십시오. 이번 승인은 아무것도 쓰지 않았습니다.',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
throw e;
|
|
302
|
+
}
|
|
303
|
+
// @implements A-SPEC-148 — writes go to THIS replica's chain; the legacy file is read-only now.
|
|
304
|
+
const chain = new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger'));
|
|
305
|
+
chain.append({
|
|
306
|
+
ts: new Date().toISOString(),
|
|
307
|
+
actor: approveResolved.approval.actor,
|
|
308
|
+
kind: 'spec-approved',
|
|
309
|
+
summary: `approved ${a.id} sealing ${digest}`,
|
|
310
|
+
inputs: [a.id, digest],
|
|
311
|
+
rationale: approveResolved.approval.rationale,
|
|
312
|
+
authorization: (0, provenance_chain_1.authorizationRef)(approveResolved.approval.actor, approveResolved.approval.token),
|
|
313
|
+
});
|
|
314
|
+
// @implements A-SPEC-135
|
|
315
|
+
// P4 routing signal: a re-approval whose content CHANGED (prior seal existed and differs) mints
|
|
316
|
+
// a `review-needed` entry naming the anchored source files — the exact set a targeted review must
|
|
317
|
+
// re-examine against the new content. A first approval or an idempotent re-seal is not drift and
|
|
318
|
+
// mints nothing. This ROUTES review; it never verdicts the code. Best-effort: a missed route is
|
|
319
|
+
// not a broken seal, so an append failure does not fail the approval.
|
|
320
|
+
if (priorDigest && priorDigest !== digest) {
|
|
321
|
+
try {
|
|
322
|
+
const files = (0, review_targets_1.anchoredForReview)(a.id, cachedScan(ledgerRoot, ledgerRoot), specs);
|
|
323
|
+
chain.append({
|
|
324
|
+
ts: new Date().toISOString(), actor: approveResolved.approval.actor, kind: 'review-needed',
|
|
325
|
+
summary: `targeted review needed: ${a.id} content moved — re-examine ${files.length} anchored file(s) against the new spec content`,
|
|
326
|
+
inputs: [a.id, priorDigest, digest, ...files],
|
|
327
|
+
rationale: approveResolved.approval.rationale, authorization: '',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch { /* a missed routing signal is not a broken seal */ }
|
|
331
|
+
}
|
|
332
|
+
// @implements A-SPEC-245 — single-use: the grant is spent by the seal it authorized.
|
|
333
|
+
if (approveResolved.source === 'grant' && approveResolved.root && approveResolved.approval.nonce) {
|
|
334
|
+
(0, approval_grants_1.consumeGrantFile)(approveResolved.root, approveResolved.approval.nonce);
|
|
335
|
+
}
|
|
336
|
+
// @implements A-SPEC-566.2 — the impact advisory rides the SUCCESS, after the seal is done:
|
|
337
|
+
// the verdict is already committed, so nothing here can change it (advisory, never gate —
|
|
338
|
+
// Judgments must not be budgeted). Reuses the persisted graph READ-ONLY; it never scans,
|
|
339
|
+
// parses or builds (scan:build measured 20~38x — an approval must not pay that), and every
|
|
340
|
+
// failure below degrades to "no advisory field" on an otherwise identical response.
|
|
341
|
+
let impactAdvisory;
|
|
342
|
+
let anchorDensity;
|
|
343
|
+
// @implements A-SPEC-631 — the graph's CONDITION rides beside the advisory: current/stale/unverified,
|
|
344
|
+
// or the reason no advisory could be derived. An empty graph is never reported as no impact.
|
|
345
|
+
let impactGraph;
|
|
346
|
+
let impactAdvisoryUnavailable;
|
|
347
|
+
try {
|
|
348
|
+
if (spec.type === 'A-SPEC' && a.root) {
|
|
349
|
+
const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
|
|
350
|
+
// @implements A-SPEC-631 — non-mutating inspection: never clears, never deletes, and says
|
|
351
|
+
// what the file is. Freshness costs one `git rev-parse`, never a scan.
|
|
352
|
+
const { openPublishedGraph, headCommitOf } = require('../../rtm/graph-store');
|
|
353
|
+
const { cleanSubprocessEnv } = require('../../project/root');
|
|
354
|
+
const view = openPublishedGraph(dbPath, headCommitOf(a.root, cleanSubprocessEnv()));
|
|
355
|
+
if (view.status === 'empty' || view.status === 'unreadable') {
|
|
356
|
+
impactAdvisoryUnavailable = view.status;
|
|
357
|
+
}
|
|
358
|
+
else if (view.graph !== null) {
|
|
359
|
+
const { declaredImpactGap, appendImpactAdvisory } = require('../../rtm/impact-advisory');
|
|
360
|
+
const { filesToTouch } = require('../../spec/compat-impact');
|
|
361
|
+
// Closed in finally (high-effort review F4): this handler lives in a long-running MCP
|
|
362
|
+
// server, and an unclosed native handle per approval accumulates for the process
|
|
363
|
+
// lifetime — and on Windows can hold rtm.sqlite locked against the next rebuild.
|
|
364
|
+
const graph = view.graph;
|
|
365
|
+
impactGraph = { status: view.status, sourceCommit: view.sourceCommit, head: view.head, ...(view.graphAsOf ? { graphAsOf: view.graphAsOf } : {}) };
|
|
366
|
+
try {
|
|
367
|
+
const ftt = filesToTouch(candidate);
|
|
368
|
+
const gap = declaredImpactGap(ftt, graph, (rel) => { try {
|
|
369
|
+
return fs.readFileSync(path.join(a.root, rel), 'utf8');
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return null;
|
|
373
|
+
} });
|
|
374
|
+
if (gap) {
|
|
375
|
+
const graphAsOf = (() => { try {
|
|
376
|
+
return fs.statSync(dbPath).mtime.toISOString();
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return undefined;
|
|
380
|
+
} })();
|
|
381
|
+
impactAdvisory = { ...gap, ...(graphAsOf ? { graphAsOf } : {}) };
|
|
382
|
+
appendImpactAdvisory(a.root, {
|
|
383
|
+
aspec: a.id, files: gap.files.map((f) => f.path), more: gap.more,
|
|
384
|
+
...(graphAsOf ? { graphAsOf } : {}), ts: new Date().toISOString(),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
// @implements A-SPEC-569.5 — anchor-density OBSERVATION, same reopened graph, same
|
|
388
|
+
// no-scan contract, same lifecycle as the advisory above (observe → ledger → measure
|
|
389
|
+
// before anyone proposes promotion). Never a verdict input: the seal is already done,
|
|
390
|
+
// and its own failure degrades to "no field" on an otherwise identical response.
|
|
391
|
+
try {
|
|
392
|
+
const { anchorDensityFindings, appendAnchorDensity } = require('../../rtm/anchor-density');
|
|
393
|
+
const findings = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
|
|
394
|
+
if (findings.length > 0) {
|
|
395
|
+
anchorDensity = findings;
|
|
396
|
+
appendAnchorDensity(a.root, {
|
|
397
|
+
aspec: a.id, files: findings.map((f) => ({ path: f.path, anchors: f.anchors })),
|
|
398
|
+
p90: findings[0].p90, ts: new Date().toISOString(),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
anchorDensity = undefined;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
graph.close();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
impactAdvisory = undefined;
|
|
414
|
+
anchorDensity = undefined;
|
|
415
|
+
impactGraph = undefined;
|
|
416
|
+
impactAdvisoryUnavailable = undefined;
|
|
417
|
+
}
|
|
418
|
+
return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}), ...(anchorDensity ? { anchorDensity } : {}),
|
|
419
|
+
...(impactGraph ? { impactGraph } : {}), ...(impactAdvisoryUnavailable ? { impactAdvisoryUnavailable } : {}) };
|
|
420
|
+
},
|
|
421
|
+
/**
|
|
422
|
+
* Pin a REQ's citations: compute the content digest of every cited source that resolves inside
|
|
423
|
+
* the repo and record it as `rev`, so an author never hashes a file by hand and the digest is
|
|
424
|
+
* always derived from what is actually on disk.
|
|
425
|
+
*
|
|
426
|
+
* Explicit-invocation only, dry-run by DEFAULT, and never overwrites an existing digest — a
|
|
427
|
+
* re-pin would replace evidence of upstream drift with a fresh-looking value, converting the
|
|
428
|
+
* detector into a concealer.
|
|
429
|
+
*/
|
|
430
|
+
async citation_pin(a) {
|
|
431
|
+
// @implements A-SPEC-189 §15 (round 13) — every sibling that reads or writes on behalf of a
|
|
432
|
+
// project refuses a foreign root (spec_create, phase_status, review_record, review_status,
|
|
433
|
+
// spec_approve). `citation_pin` did not, and it SEALS what it read: the sha256 of another
|
|
434
|
+
// project's file was written into this store's REQ frontmatter as evidence, permanently —
|
|
435
|
+
// the design deliberately never overwrites an existing digest.
|
|
436
|
+
const foreignPin = context.foreignRootReason(a.root);
|
|
437
|
+
if (foreignPin !== null)
|
|
438
|
+
return { ok: false, reason: `citation_pin: ${foreignPin}` };
|
|
439
|
+
// @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
|
|
440
|
+
// advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
|
|
441
|
+
const root = projectRootOf(a.root);
|
|
442
|
+
// @implements A-SPEC-188 — the third writer, same duplicate hazard as approve/upgrade:
|
|
443
|
+
// read() resolves the last-walked copy and the write's orphan sweep deletes the other.
|
|
444
|
+
if ((await store.list()).filter((s) => s.id === a.id).length > 1) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 기록 전에 거부합니다.`
|
|
448
|
+
+ ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 시도하십시오.',
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
// @implements A-SPEC-151
|
|
452
|
+
// `read` now yields the spec together with the version it was read at, so a write can say
|
|
453
|
+
// "only if nobody touched this since". citation_pin uses it: the pin is derived from what was
|
|
454
|
+
// read, and writing it back over someone else's edit would silently discard their change.
|
|
455
|
+
const found = await store.read(a.id);
|
|
456
|
+
if (!found)
|
|
457
|
+
return { ok: false, reason: `spec ${a.id} not found` };
|
|
458
|
+
const { spec, version } = found;
|
|
459
|
+
if (spec.type !== 'REQ')
|
|
460
|
+
return { ok: false, reason: `citations live on REQ; ${a.id} is ${spec.type}` };
|
|
461
|
+
if (spec.frontmatter.source == null)
|
|
462
|
+
return { ok: false, reason: `${a.id} has no source to pin` };
|
|
463
|
+
const readLocal = (ref) => {
|
|
464
|
+
const abs = path.resolve(root, ref);
|
|
465
|
+
// A ref that escapes the repository is not ours to read, and following it would let a spec
|
|
466
|
+
// pull arbitrary host files into the provenance record.
|
|
467
|
+
if (!abs.startsWith(path.resolve(root) + path.sep))
|
|
468
|
+
return null;
|
|
469
|
+
try {
|
|
470
|
+
return fs.readFileSync(abs, 'utf8');
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
const { citations, pinned, findings } = (0, validator_1.pinCitations)(spec.frontmatter.source, readLocal);
|
|
477
|
+
const dryRun = a.dryRun !== false; // opt IN to writing
|
|
478
|
+
if (!dryRun && pinned.length > 0) {
|
|
479
|
+
try {
|
|
480
|
+
try {
|
|
481
|
+
await store.write({ ...spec, frontmatter: { ...spec.frontmatter, source: citations } }, { expectedVersion: version });
|
|
482
|
+
}
|
|
483
|
+
catch (e) {
|
|
484
|
+
if (e instanceof spec_store_1.TargetPathOccupiedError) {
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
|
|
488
|
+
+ ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오.',
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
throw e;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
if (err instanceof spec_store_1.SpecVersionConflictError) {
|
|
496
|
+
return { ok: false, reason: `${a.id} changed while its citations were being pinned — re-read and retry (${err.message})` };
|
|
497
|
+
}
|
|
498
|
+
throw err;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { ok: true, id: a.id, dryRun, pinned, findings, citations };
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|