@holmes-lab/holmes-kit 0.16.0 → 0.18.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 +130 -0
- package/README.md +8 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/doctor.js +15 -1
- package/dist/holmes/cli/mcp-version.d.ts +4 -1
- package/dist/holmes/cli/mcp-version.js +5 -2
- package/dist/holmes/governance/autonomy.js +17 -1
- package/dist/holmes/mcp/handlers.d.ts +25 -5
- package/dist/holmes/mcp/handlers.js +146 -24
- package/dist/holmes/mcp/maintenance-analyze.d.ts +3 -0
- package/dist/holmes/mcp/maintenance-analyze.js +20 -0
- package/dist/holmes/mcp/spec-id-guard.d.ts +0 -8
- package/dist/holmes/mcp/spec-id-guard.js +10 -1
- package/dist/holmes/rtm/anchor-density.d.ts +43 -0
- package/dist/holmes/rtm/anchor-density.js +117 -0
- package/dist/holmes/rtm/decision-context.d.ts +23 -0
- package/dist/holmes/rtm/decision-context.js +47 -0
- package/dist/holmes/rtm/impact-advisory.d.ts +4 -0
- package/dist/holmes/rtm/impact-advisory.js +9 -2
- package/dist/holmes/rtm/localize.js +4 -2
- package/dist/holmes/rtm/rtm-builder.js +18 -8
- package/dist/holmes/rtm/rtm-graph.d.ts +12 -0
- package/dist/holmes/rtm/rtm-graph.js +29 -4
- package/dist/holmes/spec/spec-types.d.ts +1 -1
- package/dist/holmes/spec/spec-types.js +14 -1
- package/package.json +1 -1
- package/playbooks/author-slice/PLAYBOOK.md +19 -0
- package/playbooks/publish/PLAYBOOK.md +16 -7
- package/playbooks/tdd-slice/PLAYBOOK.md +5 -0
|
@@ -15,12 +15,4 @@ export type IdVerdict = {
|
|
|
15
15
|
reason: string;
|
|
16
16
|
nextAvailable: number;
|
|
17
17
|
};
|
|
18
|
-
/**
|
|
19
|
-
* 새 id 의 base 가 코퍼스 max base 를 한 칸 넘게 뛰면 거부한다.
|
|
20
|
-
*
|
|
21
|
-
* - 빈 코퍼스, 또는 base 를 못 뽑는 id → 통과(비교 대상이 없거나, 형태 검증은 별도 소관이라 한 결함에
|
|
22
|
-
* 두 이름을 주지 않는다).
|
|
23
|
-
* - base ≤ maxBase(갭 메우기·체인 완성) 또는 base == maxBase+1(새 체인) → 통과.
|
|
24
|
-
* - base > maxBase+1(leap) → 거부, 다음 가용 번호를 문면과 필드에 댄다.
|
|
25
|
-
*/
|
|
26
18
|
export declare function sequentialIdVerdict(newId: string, existingIds: string[]): IdVerdict;
|
|
@@ -30,11 +30,20 @@ function specIdBase(id) {
|
|
|
30
30
|
* - base ≤ maxBase(갭 메우기·체인 완성) 또는 base == maxBase+1(새 체인) → 통과.
|
|
31
31
|
* - base > maxBase+1(leap) → 거부, 다음 가용 번호를 문면과 필드에 댄다.
|
|
32
32
|
*/
|
|
33
|
+
/** The id's number SPACE. @implements A-SPEC-571.1 — ADR keeps its own sequence so a decision
|
|
34
|
+
* (jarvis's ADR-0001) neither blocks nor is blocked by the functional chain's max. Everything
|
|
35
|
+
* else shares one space, exactly as before. */
|
|
36
|
+
function idSpaceOf(id) {
|
|
37
|
+
return /^ADR-/.test(id.trim()) ? 'ADR' : 'functional';
|
|
38
|
+
}
|
|
33
39
|
function sequentialIdVerdict(newId, existingIds) {
|
|
34
40
|
const base = specIdBase(newId);
|
|
35
41
|
if (base === null)
|
|
36
42
|
return { ok: true };
|
|
37
|
-
|
|
43
|
+
// @implements A-SPEC-571.1 — compare only within the same number space.
|
|
44
|
+
const space = idSpaceOf(newId);
|
|
45
|
+
const bases = (existingIds ?? []).filter((id) => idSpaceOf(id) === space)
|
|
46
|
+
.map(specIdBase).filter((n) => n !== null);
|
|
38
47
|
if (bases.length === 0)
|
|
39
48
|
return { ok: true };
|
|
40
49
|
const maxBase = Math.max(...bases);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchor-density advisory — OBSERVATION ONLY, never a gate.
|
|
3
|
+
*
|
|
4
|
+
* The measured ground: anchor density taxes localization precision (authoring ONE spec moved
|
|
5
|
+
* replay recall 0.5476→0.5060; A-SPEC-270's √-dilution exists because a 113-anchor file brushes
|
|
6
|
+
* some spec for almost any request). A count GATE was considered and REFUSED (position-dependent
|
|
7
|
+
* refusals, an incentive to stop anchoring, mechanical file splits) — so this surfaces the fact at
|
|
8
|
+
* sealing time and records it, and the observation ledger decides any future promotion, exactly
|
|
9
|
+
* the impactAdvisory lifecycle. The thresholds below are prose constants: nothing reads them into
|
|
10
|
+
* a verdict (judgments must not be budgeted).
|
|
11
|
+
*/
|
|
12
|
+
export interface AnchorDensityFinding {
|
|
13
|
+
path: string;
|
|
14
|
+
anchors: number;
|
|
15
|
+
p90: number;
|
|
16
|
+
}
|
|
17
|
+
/** Files below this live-anchor count are never flagged, whatever the distribution — a floor. */
|
|
18
|
+
export declare const MIN_ANCHORS = 8;
|
|
19
|
+
/**
|
|
20
|
+
* FtT files whose live anchor count sits at or above max(MIN_ANCHORS, p90 of the distribution).
|
|
21
|
+
* p90 is the value at index ceil(0.9·n)-1 of the ascending counts — deterministic, no interpolation.
|
|
22
|
+
* Pure: same inputs, same findings, in sorted path order.
|
|
23
|
+
*/
|
|
24
|
+
export declare function anchorDensityFindings(fttFiles: string[], counts: Array<{
|
|
25
|
+
sourcePath: string;
|
|
26
|
+
anchors: number;
|
|
27
|
+
}>): AnchorDensityFinding[];
|
|
28
|
+
/**
|
|
29
|
+
* The observation ledger — paths and integers only, no prose, no secrets: what a future
|
|
30
|
+
* promotion/rejection judgment will be measured on.
|
|
31
|
+
*/
|
|
32
|
+
export interface AnchorDensityRecord {
|
|
33
|
+
aspec: string;
|
|
34
|
+
files: Array<{
|
|
35
|
+
path: string;
|
|
36
|
+
anchors: number;
|
|
37
|
+
}>;
|
|
38
|
+
p90: number;
|
|
39
|
+
ts: string;
|
|
40
|
+
replica?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function appendAnchorDensity(root: string, rec: AnchorDensityRecord): boolean;
|
|
43
|
+
export declare function readAnchorDensity(root: string): AnchorDensityRecord[];
|
|
@@ -0,0 +1,117 @@
|
|
|
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.MIN_ANCHORS = void 0;
|
|
37
|
+
exports.anchorDensityFindings = anchorDensityFindings;
|
|
38
|
+
exports.appendAnchorDensity = appendAnchorDensity;
|
|
39
|
+
exports.readAnchorDensity = readAnchorDensity;
|
|
40
|
+
// @implements A-SPEC-569.5
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const path = __importStar(require("node:path"));
|
|
43
|
+
const replica_id_1 = require("../governance/replica-id");
|
|
44
|
+
/** Files below this live-anchor count are never flagged, whatever the distribution — a floor. */
|
|
45
|
+
exports.MIN_ANCHORS = 8;
|
|
46
|
+
/**
|
|
47
|
+
* FtT files whose live anchor count sits at or above max(MIN_ANCHORS, p90 of the distribution).
|
|
48
|
+
* p90 is the value at index ceil(0.9·n)-1 of the ascending counts — deterministic, no interpolation.
|
|
49
|
+
* Pure: same inputs, same findings, in sorted path order.
|
|
50
|
+
*/
|
|
51
|
+
function anchorDensityFindings(fttFiles, counts) {
|
|
52
|
+
if (counts.length === 0 || fttFiles.length === 0)
|
|
53
|
+
return [];
|
|
54
|
+
const sorted = counts.map((c) => c.anchors).sort((a, b) => a - b);
|
|
55
|
+
const p90 = sorted[Math.ceil(0.9 * sorted.length) - 1];
|
|
56
|
+
const threshold = Math.max(exports.MIN_ANCHORS, p90);
|
|
57
|
+
const byPath = new Map(counts.map((c) => [c.sourcePath, c.anchors]));
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const f of [...new Set(fttFiles.map((p) => p.replace(/\\/g, '/')))].sort()) {
|
|
60
|
+
const anchors = byPath.get(f);
|
|
61
|
+
if (anchors !== undefined && anchors >= threshold)
|
|
62
|
+
out.push({ path: f, anchors, p90 });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
const DENSITY_FILE_RE = /^anchor-density\.([^.]+)\.jsonl$/;
|
|
67
|
+
function appendAnchorDensity(root, rec) {
|
|
68
|
+
try {
|
|
69
|
+
if (!fs.existsSync(path.join(root, '.ax')))
|
|
70
|
+
return false;
|
|
71
|
+
let replica = 'local';
|
|
72
|
+
try {
|
|
73
|
+
replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
|
|
74
|
+
}
|
|
75
|
+
catch { /* keep the fallback */ }
|
|
76
|
+
const file = path.join(root, '.ax', 'ledger', `anchor-density.${replica}.jsonl`);
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
fs.appendFileSync(file, `${JSON.stringify({ ...rec, replica })}\n`);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function readAnchorDensity(root) {
|
|
86
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
87
|
+
let names;
|
|
88
|
+
try {
|
|
89
|
+
names = fs.readdirSync(dir).filter((n) => DENSITY_FILE_RE.test(n)).sort();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
let text;
|
|
97
|
+
try {
|
|
98
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const line of text.split('\n')) {
|
|
104
|
+
const s = line.trim();
|
|
105
|
+
if (!s)
|
|
106
|
+
continue;
|
|
107
|
+
try {
|
|
108
|
+
const r = JSON.parse(s);
|
|
109
|
+
if (r && typeof r === 'object' && typeof r.aspec === 'string' && Array.isArray(r.files) && typeof r.ts === 'string') {
|
|
110
|
+
out.push(r);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch { /* a corrupt line never breaks the read */ }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DECISION context of a candidate — "why is this the way it is?".
|
|
3
|
+
*
|
|
4
|
+
* A person diagnosing a defect asks two questions in order: what broke (the candidates), and then
|
|
5
|
+
* why this code was left this way (the decision). The graph already answers the second — REQ-571
|
|
6
|
+
* put store ADRs into the decision population and A-SPEC-293 already builds `constrained_by` from
|
|
7
|
+
* citations — so this is a lookup, not a new mechanism: no new edge kind, no new tool.
|
|
8
|
+
*
|
|
9
|
+
* OBSERVATION ONLY. The context rides beside a candidate; nothing here enters the ranking, the
|
|
10
|
+
* score, or any gate (the c6 rule — prose informs, it never adjudicates).
|
|
11
|
+
*/
|
|
12
|
+
export interface DecisionContextEntry {
|
|
13
|
+
adr: string;
|
|
14
|
+
/** The decision's own sentence, or null when the store carries no summary for it (id stays). */
|
|
15
|
+
decision: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface DecisionGraphLike {
|
|
18
|
+
decisionsConstraining(nodeIds: readonly string[]): string[];
|
|
19
|
+
summaryOf(id: string): string | null;
|
|
20
|
+
}
|
|
21
|
+
/** How many decisions ride beside one candidate. A prose constant — never a verdict input. */
|
|
22
|
+
export declare const MAX_DECISIONS = 3;
|
|
23
|
+
export declare function decisionContextFor(files: readonly string[], specIdsOf: (file: string) => readonly string[], graph: DecisionGraphLike): Map<string, DecisionContextEntry[]>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-572.3
|
|
3
|
+
/**
|
|
4
|
+
* The DECISION context of a candidate — "why is this the way it is?".
|
|
5
|
+
*
|
|
6
|
+
* A person diagnosing a defect asks two questions in order: what broke (the candidates), and then
|
|
7
|
+
* why this code was left this way (the decision). The graph already answers the second — REQ-571
|
|
8
|
+
* put store ADRs into the decision population and A-SPEC-293 already builds `constrained_by` from
|
|
9
|
+
* citations — so this is a lookup, not a new mechanism: no new edge kind, no new tool.
|
|
10
|
+
*
|
|
11
|
+
* OBSERVATION ONLY. The context rides beside a candidate; nothing here enters the ranking, the
|
|
12
|
+
* score, or any gate (the c6 rule — prose informs, it never adjudicates).
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.MAX_DECISIONS = void 0;
|
|
16
|
+
exports.decisionContextFor = decisionContextFor;
|
|
17
|
+
/** How many decisions ride beside one candidate. A prose constant — never a verdict input. */
|
|
18
|
+
exports.MAX_DECISIONS = 3;
|
|
19
|
+
function decisionContextFor(files, specIdsOf, graph) {
|
|
20
|
+
const out = new Map();
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
let adrs = [];
|
|
23
|
+
try {
|
|
24
|
+
// The file itself AND the specs it anchors: a person chasing "why" follows both, and the
|
|
25
|
+
// constraint is often recorded one level up, on the spec rather than on the file.
|
|
26
|
+
adrs = graph.decisionsConstraining([
|
|
27
|
+
`FILE:${file}`,
|
|
28
|
+
...specIdsOf(file).map((id) => `SPEC:${id}`),
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
continue;
|
|
33
|
+
} // an unreadable graph yields no context, never a throw
|
|
34
|
+
const shown = [...new Set(adrs)].sort().slice(0, exports.MAX_DECISIONS);
|
|
35
|
+
if (shown.length === 0)
|
|
36
|
+
continue; // no entry rather than an invented empty list
|
|
37
|
+
out.set(file, shown.map((adr) => {
|
|
38
|
+
let decision = null;
|
|
39
|
+
try {
|
|
40
|
+
decision = graph.summaryOf(`SPEC:${adr}`);
|
|
41
|
+
}
|
|
42
|
+
catch { /* id stays, prose does not */ }
|
|
43
|
+
return { adr, decision };
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
@@ -22,11 +22,15 @@ export interface ImpactAdvisory {
|
|
|
22
22
|
id: string;
|
|
23
23
|
summary: string | null;
|
|
24
24
|
}>;
|
|
25
|
+
/** @implements A-SPEC-569.3 — anchors beyond ANCHOR_CAP, counted rather than silently dropped. */
|
|
26
|
+
anchorsOmitted?: number;
|
|
25
27
|
}>;
|
|
26
28
|
more: number;
|
|
27
29
|
graphAsOf?: string;
|
|
28
30
|
}
|
|
29
31
|
export declare const ADVISORY_CAP = 10;
|
|
32
|
+
/** @implements A-SPEC-569.3 — anchors annotated per advisory file; a prose constant, never a verdict input. */
|
|
33
|
+
export declare const ANCHOR_CAP = 10;
|
|
30
34
|
export declare function declaredImpactGap(fttFiles: string[], graph: GraphLike, readFile: (rel: string) => string | null, opts?: {
|
|
31
35
|
cap?: number;
|
|
32
36
|
}): ImpactAdvisory | null;
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.ADVISORY_CAP = void 0;
|
|
36
|
+
exports.ANCHOR_CAP = exports.ADVISORY_CAP = void 0;
|
|
37
37
|
exports.declaredImpactGap = declaredImpactGap;
|
|
38
38
|
exports.appendImpactAdvisory = appendImpactAdvisory;
|
|
39
39
|
exports.readImpactAdvisories = readImpactAdvisories;
|
|
@@ -43,6 +43,8 @@ const fs = __importStar(require("node:fs"));
|
|
|
43
43
|
const path = __importStar(require("node:path"));
|
|
44
44
|
const replica_id_1 = require("../governance/replica-id");
|
|
45
45
|
exports.ADVISORY_CAP = 10;
|
|
46
|
+
/** @implements A-SPEC-569.3 — anchors annotated per advisory file; a prose constant, never a verdict input. */
|
|
47
|
+
exports.ANCHOR_CAP = 10;
|
|
46
48
|
/** The ONLY shape allowed into the ledger: `seg/seg/…` of word characters, dots and dashes —
|
|
47
49
|
* no leading slash, no drive letter, no backslash, no empty or `..` segment. */
|
|
48
50
|
function isRepoRelative(p) {
|
|
@@ -99,12 +101,16 @@ function declaredImpactGap(fttFiles, graph, readFile, opts) {
|
|
|
99
101
|
ids = anchorsIn(text);
|
|
100
102
|
}
|
|
101
103
|
catch { /* anchors stay [] */ }
|
|
104
|
+
// @implements A-SPEC-569.3 — capped BEFORE the summary lookups, so the omitted tail costs
|
|
105
|
+
// no SELECTs either; the omission is counted, never silent.
|
|
106
|
+
const shownIds = ids.slice(0, exports.ANCHOR_CAP);
|
|
107
|
+
const anchorsOmitted = ids.length - shownIds.length;
|
|
102
108
|
// @implements A-SPEC-568.2 — the anchor's intent sentence rides BESIDE the id, read from the
|
|
103
109
|
// graph S1 built. Information only: nothing below this line feeds files/more/ordering, and a
|
|
104
110
|
// failing lookup downgrades to null rather than killing the finding (an advisory never guesses).
|
|
105
111
|
return {
|
|
106
112
|
path,
|
|
107
|
-
anchors:
|
|
113
|
+
anchors: shownIds.map((id) => {
|
|
108
114
|
let summary = null;
|
|
109
115
|
try {
|
|
110
116
|
summary = graph.summaryOf(`SPEC:${id}`);
|
|
@@ -112,6 +118,7 @@ function declaredImpactGap(fttFiles, graph, readFile, opts) {
|
|
|
112
118
|
catch { /* summary stays null */ }
|
|
113
119
|
return { id, summary };
|
|
114
120
|
}),
|
|
121
|
+
...(anchorsOmitted > 0 ? { anchorsOmitted } : {}),
|
|
115
122
|
};
|
|
116
123
|
}),
|
|
117
124
|
more: sorted.length - shown.length,
|
|
@@ -10,8 +10,10 @@ exports.localizeIssue = localizeIssue;
|
|
|
10
10
|
* repository's `S-<n>` slice shorthand (which names the whole REQ->T-SPEC chain at that number).
|
|
11
11
|
* The prefix is REQUIRED — a bare `262` in "262 files were rewritten" is a number, not a citation.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// @implements A-SPEC-571.2 — ADR is citable like any governed id (recognition only; the match
|
|
14
|
+
// SCORING population stays A-SPEC-limited below, so an ADR never admits or reorders a file).
|
|
15
|
+
const CITATION = /\b(?:(A-SPEC|H-SPEC|T-SPEC|C-SPEC|ADR|REQ)-(\d+(?:\.\d+)?)|S-(\d+(?:\.\d+)?))\b/gi;
|
|
16
|
+
const CITABLE_KINDS = ['REQ', 'H-SPEC', 'A-SPEC', 'T-SPEC', 'C-SPEC', 'ADR'];
|
|
15
17
|
/**
|
|
16
18
|
* @implements A-SPEC-273
|
|
17
19
|
* How much a citation is worth, normalized by the file's anchor count exactly as the lexical spec
|
|
@@ -73,6 +73,8 @@ const INTENT_SECTION = {
|
|
|
73
73
|
'REQ': 'Problem / Need',
|
|
74
74
|
'H-SPEC': 'Intent',
|
|
75
75
|
'A-SPEC': 'Objective',
|
|
76
|
+
// @implements A-SPEC-571.2 — a decision's intent IS its Decision line.
|
|
77
|
+
'ADR': 'Decision',
|
|
76
78
|
};
|
|
77
79
|
/** Longest intent sentence carried into the graph — a bound, applied deterministically. */
|
|
78
80
|
const SUMMARY_SENTENCE_CAP = 200;
|
|
@@ -91,15 +93,23 @@ function specSummary(s) {
|
|
|
91
93
|
const text = (section ? (s.sections?.[section] ?? '') : '').replace(/\s+/g, ' ').trim();
|
|
92
94
|
// Measured 2026-09-07 on this store: 20 legacy specs carry title '' — the summary falls back to
|
|
93
95
|
// the sentence alone, then to the id, because SC1 admits no empty summary on any path.
|
|
96
|
+
let result;
|
|
94
97
|
if (text === '' || text === 'TODO')
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
result = title !== '' ? title : s.id;
|
|
99
|
+
else {
|
|
100
|
+
// First sentence: up to the first . ! or ? that ends a word — the lookahead keeps `A-SPEC-129.2`
|
|
101
|
+
// whole, because its dot is followed by a digit, not by whitespace or the end.
|
|
102
|
+
const m = /^(.*?[.!?])(?=\s|$)/.exec(text);
|
|
103
|
+
let sentence = m ? m[1] : text;
|
|
104
|
+
if (sentence.length > SUMMARY_SENTENCE_CAP)
|
|
105
|
+
sentence = `${sentence.slice(0, SUMMARY_SENTENCE_CAP)}…`;
|
|
106
|
+
result = title !== '' ? `${title} — ${sentence}` : sentence;
|
|
107
|
+
}
|
|
108
|
+
// @implements A-SPEC-569.1 — the WHOLE summary folds, title included. The first cut normalized
|
|
109
|
+
// only the sentence, and a YAML double-quoted title carried \n/\t straight into dumpCanonical,
|
|
110
|
+
// where graphViewOf parsed the forged row as a real call edge (0.16.0 adversarial reproduction).
|
|
111
|
+
// Prose must never carry the dump's structural characters.
|
|
112
|
+
return result.replace(/\s+/g, ' ').trim();
|
|
103
113
|
}
|
|
104
114
|
/**
|
|
105
115
|
* Adds one scanned file's CODE nodes and `implements` edges to the graph,
|
|
@@ -61,6 +61,9 @@ export declare class RtmGraph {
|
|
|
61
61
|
*/
|
|
62
62
|
summaryOf(id: string): string | null;
|
|
63
63
|
addEdge(src: string, dst: string, rel: string, sourcePath?: string, provenance?: Provenance): void;
|
|
64
|
+
/** @implements A-SPEC-569.1 — does the graph hold this node? SELECT 1, no row materialization:
|
|
65
|
+
* the approved-only channel filter (A-SPEC-569.2) asks exactly this and nothing more. */
|
|
66
|
+
hasNode(id: string): boolean;
|
|
64
67
|
/** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
|
|
65
68
|
provenanceOfNode(id: string): Provenance | null;
|
|
66
69
|
/** @implements A-SPEC-281 — how this edge got here, or null if the edge is unknown. */
|
|
@@ -212,6 +215,15 @@ export declare class RtmGraph {
|
|
|
212
215
|
clear(): void;
|
|
213
216
|
/** @implements A-SPEC-283 — the journal mode actually in force, so the pragma can be asserted. */
|
|
214
217
|
journalMode(): string;
|
|
218
|
+
/**
|
|
219
|
+
* @implements A-SPEC-569.5
|
|
220
|
+
* Live anchors per source file — distinct SPEC targets of `implements` edges, one GROUP BY.
|
|
221
|
+
* Feeds the anchor-density OBSERVATION at sealing time; nothing reads it into a verdict.
|
|
222
|
+
*/
|
|
223
|
+
implementsAnchorCounts(): Array<{
|
|
224
|
+
sourcePath: string;
|
|
225
|
+
anchors: number;
|
|
226
|
+
}>;
|
|
215
227
|
nodeCount(): number;
|
|
216
228
|
edgeCount(): number;
|
|
217
229
|
close(): void;
|
|
@@ -120,7 +120,11 @@ class RtmGraph {
|
|
|
120
120
|
edgeStmt;
|
|
121
121
|
addNode(id, kind, sourcePath, provenance, summary) {
|
|
122
122
|
this.nodeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO nodes (id,kind,source_path,summary,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
|
|
123
|
-
|
|
123
|
+
// @implements A-SPEC-569.1 — the STORAGE boundary folds structural characters too, so a future
|
|
124
|
+
// caller that never went through specSummary still cannot forge dumpCanonical rows. Second,
|
|
125
|
+
// independent face of the seal (each face is verified alone).
|
|
126
|
+
const foldedSummary = summary == null ? null : summary.replace(/\s+/g, ' ').trim();
|
|
127
|
+
this.nodeStmt.run(id, kind, sourcePath ?? null, foldedSummary, ...provenanceRow(provenance));
|
|
124
128
|
}
|
|
125
129
|
/**
|
|
126
130
|
* @implements A-SPEC-568.1
|
|
@@ -137,6 +141,11 @@ class RtmGraph {
|
|
|
137
141
|
this.edgeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO edges (src,dst,rel,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
|
|
138
142
|
this.edgeStmt.run(src, dst, rel, sourcePath ?? null, ...provenanceRow(provenance));
|
|
139
143
|
}
|
|
144
|
+
/** @implements A-SPEC-569.1 — does the graph hold this node? SELECT 1, no row materialization:
|
|
145
|
+
* the approved-only channel filter (A-SPEC-569.2) asks exactly this and nothing more. */
|
|
146
|
+
hasNode(id) {
|
|
147
|
+
return this.db.prepare('SELECT 1 FROM nodes WHERE id = ?').get(id) !== undefined;
|
|
148
|
+
}
|
|
140
149
|
/** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
|
|
141
150
|
provenanceOfNode(id) {
|
|
142
151
|
return provenanceOf(this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(id));
|
|
@@ -364,15 +373,21 @@ class RtmGraph {
|
|
|
364
373
|
// @implements A-SPEC-281 — provenance is part of the value, so a provenance-only divergence
|
|
365
374
|
// between a full rebuild and an incremental update is caught rather than passing as equal.
|
|
366
375
|
const cols = PROVENANCE_COLUMNS.join(', ');
|
|
367
|
-
|
|
376
|
+
// @implements A-SPEC-569.1 (revision) — EVERY text column folds at emission. Sealing columns
|
|
377
|
+
// one at a time (title → summary) was whack-a-mole: high-effort review found id/depends_on
|
|
378
|
+
// carrying tabs into these rows and shifting columns under graphViewOf. The dump's structural
|
|
379
|
+
// characters are the dump's own concern, so this is the single choke point; the row-shape
|
|
380
|
+
// invariant (exactly 13 cells, structural-char-free) is pinned by test.
|
|
381
|
+
const fold = (v) => (v === null || v === undefined ? '' : String(v).replace(/[\t\n\r]+/g, ' '));
|
|
382
|
+
const prov = (r) => PROVENANCE_COLUMNS.map((c) => fold(r[c])).join('\t');
|
|
368
383
|
const nodes = this.db.prepare(`SELECT id, kind, source_path, summary, ${cols} FROM nodes ORDER BY id`).all();
|
|
369
384
|
const edges = this.db.prepare(`SELECT src, dst, rel, source_path, ${cols} FROM edges ORDER BY src, dst, rel`).all();
|
|
370
385
|
return [
|
|
371
386
|
// @implements A-SPEC-568.1 — summary is part of the VALUE (appended last so every positional
|
|
372
387
|
// consumer of the earlier columns is untouched); a summary-only divergence between two builds
|
|
373
388
|
// must fail the convergence comparison rather than pass as equal.
|
|
374
|
-
...nodes.map((n) => `N\t${n.id}\t${n.kind}\t${n.source_path
|
|
375
|
-
...edges.map((e) => `E\t${e.src}\t${e.dst}\t${e.rel}\t${e.source_path
|
|
389
|
+
...nodes.map((n) => `N\t${fold(n.id)}\t${fold(n.kind)}\t${fold(n.source_path)}\t${prov(n)}\t${fold(n.summary)}`),
|
|
390
|
+
...edges.map((e) => `E\t${fold(e.src)}\t${fold(e.dst)}\t${fold(e.rel)}\t${fold(e.source_path)}\t${prov(e)}`),
|
|
376
391
|
].join('\n');
|
|
377
392
|
}
|
|
378
393
|
/**
|
|
@@ -416,6 +431,16 @@ class RtmGraph {
|
|
|
416
431
|
journalMode() {
|
|
417
432
|
return String(this.db.pragma('journal_mode', { simple: true }) ?? '');
|
|
418
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* @implements A-SPEC-569.5
|
|
436
|
+
* Live anchors per source file — distinct SPEC targets of `implements` edges, one GROUP BY.
|
|
437
|
+
* Feeds the anchor-density OBSERVATION at sealing time; nothing reads it into a verdict.
|
|
438
|
+
*/
|
|
439
|
+
implementsAnchorCounts() {
|
|
440
|
+
return this.db.prepare(`SELECT source_path AS sourcePath, COUNT(DISTINCT dst) AS anchors FROM edges
|
|
441
|
+
WHERE rel='implements' AND source_path IS NOT NULL GROUP BY source_path ORDER BY source_path ASC`)
|
|
442
|
+
.all();
|
|
443
|
+
}
|
|
419
444
|
nodeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM nodes').get().c; }
|
|
420
445
|
edgeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM edges').get().c; }
|
|
421
446
|
close() { this.db.close(); }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SpecType = 'REQ' | 'H-SPEC' | 'A-SPEC' | 'C-SPEC' | 'T-SPEC';
|
|
1
|
+
export type SpecType = 'REQ' | 'H-SPEC' | 'A-SPEC' | 'C-SPEC' | 'T-SPEC' | 'ADR';
|
|
2
2
|
export declare const SPEC_STATUSES: readonly ["draft", "review", "approved", "outdated"];
|
|
3
3
|
export type SpecStatus = (typeof SPEC_STATUSES)[number];
|
|
4
4
|
export interface SpecTypeDef {
|
|
@@ -12,7 +12,9 @@ exports.parentRuleText = parentRuleText;
|
|
|
12
12
|
// its test's CANON, and the type itself — so adding a status compiled clean while spec_list kept
|
|
13
13
|
// flagging it legacy.
|
|
14
14
|
exports.SPEC_STATUSES = ['draft', 'review', 'approved', 'outdated'];
|
|
15
|
-
|
|
15
|
+
// @implements A-SPEC-571.1 — ADR trails the functional chain: spec_next serves the REQ→…→T-SPEC
|
|
16
|
+
// order first, and a decision is off that critical path.
|
|
17
|
+
exports.SPEC_ORDER = ['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC', 'ADR'];
|
|
16
18
|
/**
|
|
17
19
|
* Where a requirement came from.
|
|
18
20
|
*
|
|
@@ -102,6 +104,17 @@ exports.SPEC_TYPES = {
|
|
|
102
104
|
requiredFields: ['coverage'],
|
|
103
105
|
requiredSections: ['Normal Cases', 'Corner Cases', 'Negative Cases', 'Boundary Cases'],
|
|
104
106
|
},
|
|
107
|
+
// @implements A-SPEC-571.1
|
|
108
|
+
// A DECISION, not a functional contract: a root type (no parent, like REQ) that records why a
|
|
109
|
+
// choice was made and inherits the store's authoring governance (stub → validate → seal → ledger
|
|
110
|
+
// → tamper-block). It carries NO T-SPEC/anchor/FtT duty — that is the No-Spec-No-Code chain, a
|
|
111
|
+
// separate axis. Its number space is its own (see spec-id-guard), so jarvis's ADR-0001 does not
|
|
112
|
+
// collide with the functional chain's max. 4-digit zero-padded ids are accepted (ADR-0001).
|
|
113
|
+
'ADR': {
|
|
114
|
+
type: 'ADR', idRegex: /^ADR-\d{3,}$/, example: 'ADR-0001', folder: '06_adr', parents: [],
|
|
115
|
+
requiredFields: ['decided', 'decider'],
|
|
116
|
+
requiredSections: ['Context', 'Decision', 'Consequences', 'Alternatives'],
|
|
117
|
+
},
|
|
105
118
|
};
|
|
106
119
|
// @implements A-SPEC-100.1
|
|
107
120
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@implements A-SPEC-209",
|
|
3
3
|
"name": "@holmes-lab/holmes-kit",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.18.0",
|
|
5
5
|
"description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
|
|
6
6
|
"main": "dist/holmes/mcp/server.js",
|
|
7
7
|
"types": "dist/holmes/mcp/server.d.ts",
|
|
@@ -82,6 +82,25 @@ Criteria · Non-Functional · Assumptions · Open Questions.** 필수 필드: `r
|
|
|
82
82
|
부모 REQ의 Success Criteria가 이 설계로 어떻게 달성되는지가 본문이다. Open Questions는 비워두는
|
|
83
83
|
칸이 아니다 — 아직 결정하지 않은 것을 결정하지 않았다고 적는 곳이고, 닫을 때는 근거와 함께 닫는다.
|
|
84
84
|
|
|
85
|
+
## Files to Touch를 확정하기 전에 — 설계-시점 영향 범위 읽기
|
|
86
|
+
|
|
87
|
+
A-SPEC의 Files to Touch는 **스코프 선언**이고, 그래프는 그 선언이 무엇을 빠뜨리는지 이미 안다.
|
|
88
|
+
`approval_status(id)`를 부르면 `graphPreview`가 온다:
|
|
89
|
+
|
|
90
|
+
- `impact` — 선언한 FtT **밖**에서 그 안으로 호출해 들어오는 파일들(1-hop). 각 파일의 앵커 옆에
|
|
91
|
+
그 스펙의 **의도 문장**이 붙으므로, "무슨 의도가 걸려 있는지"를 스토어를 열지 않고 읽는다.
|
|
92
|
+
- `density` — FtT 안의 **앵커-과밀** 파일(live 앵커가 분포 상위이며 절대치도 큰 것). 새 로직을
|
|
93
|
+
거기 더할지, 새 파일로 뺄지 판단하는 근거다.
|
|
94
|
+
|
|
95
|
+
읽고 나서 셋 중 하나를 **선택**한다: ①지목된 밖-파일을 FtT에 넣는다 ②스코프를 좁혀 그 파급이
|
|
96
|
+
생기지 않게 설계를 바꾼다 ③근거를 갖고 그대로 둔다. 어느 쪽이든 선언은 이제 **알고 한 선언**이다.
|
|
97
|
+
|
|
98
|
+
> [!NOTE]
|
|
99
|
+
> **게이트는 이것을 막지 않는다** — 규율이지 차단이 아니다(하드 게이트 승격은 관측 원장이
|
|
100
|
+
> 오탐률을 답한 뒤의 별도 결정). 다만 읽지 않고 확정하면, 같은 소견이 봉인 뒤에 같은 말을
|
|
101
|
+
> 반복한다 — 실사고 기록: 봉인 소견이 지목한 파일 클러스터에서 회귀 3건이 났고, 그 소견은
|
|
102
|
+
> 설계가 끝난 뒤에야 읽혔다.
|
|
103
|
+
|
|
85
104
|
## A-SPEC / T-SPEC에 쓰는 것
|
|
86
105
|
|
|
87
106
|
A-SPEC 필수 섹션: **Objective · Inputs / Outputs · Behavior · Test Points · Files to Touch · Done
|
|
@@ -37,15 +37,24 @@ npm publish 는 **비가역·외부노출**이라 기본은 HITL(사람 승인)
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
39
|
### 2.5단계: 문서 정합성 게이트 (Docs Currency Gate) — 배포는 정직한 고지다
|
|
40
|
-
타르볼에 문서가 **포함**됐는지가 아니라 **최신인지**를
|
|
41
|
-
|
|
40
|
+
타르볼에 문서가 **포함**됐는지가 아니라 **최신인지**를 검사한다. 부정직은 두 방향이다: 거짓을
|
|
41
|
+
남기는 것(drift)과 참을 숨기는 것(absence) — 둘 다 차단한다. (사고 이력 둘: 폐기된 동작이
|
|
42
|
+
README 에 현재형으로 남은 사고, 그리고 **0.16.0 — 신기능 3건이 README 기능 목록에 아예 없는데
|
|
43
|
+
"옛 문구 grep 0건"이 통과로 읽혀 그대로 배포된 사고.**)
|
|
42
44
|
1. 직전 릴리스 태그 이후 승인된 스펙 열거: `git log <last-tag>..HEAD --name-only -- .ax/specs/03_a-spec/`.
|
|
43
|
-
2. 각 A-SPEC 중 **사용자-대면**(CLI 명령/플래그, 동작 변경, env 스위치, 게이트
|
|
44
|
-
|
|
45
|
-
- `
|
|
46
|
-
|
|
45
|
+
2. 각 A-SPEC 중 **사용자-대면**(CLI 명령/플래그, MCP 응답 필드, 동작 변경, env 스위치, 게이트
|
|
46
|
+
행동, 신규 원장/아티팩트)인 것마다 — **세 검사를 모두**:
|
|
47
|
+
- **(2a) 추가-검사(absence)**: `CHANGELOG.md` 이번 버전 항목과 `README.md` 기능 목록에 그
|
|
48
|
+
변화의 항목이 **존재**하는가. 기능 이름으로 `grep` 해서 **0건이면 그것은 '통과'가 아니라
|
|
49
|
+
누락 신호다** — 쓴 적 없는 기능은 옛 문구도 없다.
|
|
50
|
+
- **(2b) drift-검사**: 바뀐 동작의 **옛 문구**를 `grep` 으로 점검 — 폐기된 동작을 현재형으로
|
|
51
|
+
서술하지 않는가.
|
|
52
|
+
- **(2c) 스테일 마커**: 기능 섹션 제목 등의 **버전-고정 라벨**("vX.Y.x Features" 류)은 매
|
|
53
|
+
릴리스 수동 갱신을 요구하는 drift 발생기다 — 발견 즉시 버전-무관 표현으로 제거한다(항목별
|
|
54
|
+
*(new in X.Y.Z)* 가 시점을 말한다).
|
|
47
55
|
> [!CAUTION]
|
|
48
|
-
> 사용자-대면 변화가 CHANGELOG/README 에 반영되지 않았으면 배포 중단 — 문서 drift
|
|
56
|
+
> 사용자-대면 변화가 CHANGELOG/README 에 반영되지 않았으면 배포 중단 — 문서 drift 도, 문서
|
|
57
|
+
> 누락도 거짓 주장이다.
|
|
49
58
|
|
|
50
59
|
---
|
|
51
60
|
|
|
@@ -69,6 +69,11 @@ holmes는 기계적으로 판별한다: `red-error`로는 red→green 시퀀스
|
|
|
69
69
|
|
|
70
70
|
## 절차
|
|
71
71
|
|
|
72
|
+
0. **FtT 확정 전** `approval_status(<A-SPEC id>)`의 `graphPreview`로 영향 범위를 읽는다 —
|
|
73
|
+
`impact`(선언 밖에서 들어오는 1-hop 호출자, 의도 문장 병기)와 `density`(앵커-과밀 파일). 지목된
|
|
74
|
+
것을 FtT에 넣을지, 스코프를 좁힐지, 근거를 갖고 둘지 **정하고 나서** 아래로 간다. 게이트가
|
|
75
|
+
**게이트는 이것을 막지 않는다** — 규율이지 차단이 아니다. 다만 읽지 않으면
|
|
76
|
+
같은 소견을 봉인 뒤에 다시 듣는다(실사고 기록).
|
|
72
77
|
1. `promote-slice`로 대상 A-SPEC과 그 T-SPEC을 승인한다(`[ART-1]` 게이트를 연다).
|
|
73
78
|
2. 커버 테스트를 **먼저** 쓴다. 심볼이 없어 컴파일이 깨지면 틀린-값 스텁을 넣는다.
|
|
74
79
|
3. `test_run` — **red-assertion**을 본다. `red-error`면 그건 아직 RED가 아니다; 스텁으로 고쳐라.
|