@kodevibe/harness 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +101 -4
- package/README.md +108 -5
- package/harness/core-rules.md +2 -0
- package/harness/project-brief.md +18 -0
- package/harness/skills/docs-bridge.md +161 -0
- package/harness/skills/setup.md +10 -0
- package/harness/skills/state-check.md +19 -0
- package/harness/skills/wrap-up.md +9 -0
- package/package.json +11 -2
- package/src/dependency-scan.js +194 -0
- package/src/guard.js +717 -0
- package/src/init.js +754 -8
- package/src/llm-bench.js +323 -0
- package/src/pack-check.js +47 -0
package/src/guard.js
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// kode:harness — Deterministic Guardrail (Harness Guard)
|
|
4
|
+
//
|
|
5
|
+
// Purpose: mechanically eliminate the top recurring root causes that LLMs
|
|
6
|
+
// fail to honor via markdown instructions alone. Pure functions (no I/O) so
|
|
7
|
+
// they are trivially testable; runGuard() is the only file-reading wrapper.
|
|
8
|
+
//
|
|
9
|
+
// Checks:
|
|
10
|
+
// R5 scanSecrets — Iron Law #4 (no hardcoded credentials)
|
|
11
|
+
// R1 checkStateFile — Iron Law #8/#11 (session handoff + proof-first)
|
|
12
|
+
// R3 checkReviewerHandoff — reviewed/done work must be handoff-ready
|
|
13
|
+
// R6 lintMarkdownTables — L3-8 (markdown state file integrity)
|
|
14
|
+
// R6 lintLineLimit — project-state.md "keep under 200 lines"
|
|
15
|
+
// R7 checkStateSync — Story/feature/dependency state stays in sync
|
|
16
|
+
// R8 checkPublicBoundary — public package surface does not leak internal refs
|
|
17
|
+
// R9 checkEnvSeal — proof records include reproducible environment seal
|
|
18
|
+
// R10 checkInstructionBudget — instruction files fit model-tier budgets
|
|
19
|
+
//
|
|
20
|
+
// Severity: 'error' blocks the commit (exit 1). 'warn' is informational.
|
|
21
|
+
|
|
22
|
+
// ─── Secret patterns (R5 / Iron Law #4) ──────────────────────────────
|
|
23
|
+
// Conservative set to minimize false positives. Each entry: { id, re, label }.
|
|
24
|
+
const SECRET_PATTERNS = [
|
|
25
|
+
{ id: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/, label: 'AWS access key id' },
|
|
26
|
+
{ id: 'github-token', re: /\bgh[pousr]_[0-9A-Za-z]{36,}\b/, label: 'GitHub token' },
|
|
27
|
+
{ id: 'slack-token', re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/, label: 'Slack token' },
|
|
28
|
+
{ id: 'private-key', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/, label: 'private key block' },
|
|
29
|
+
{ id: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/, label: 'Google API key' },
|
|
30
|
+
// Generic assignment: key/secret/password/token = "literal value"
|
|
31
|
+
{
|
|
32
|
+
id: 'generic-secret-assignment',
|
|
33
|
+
re: /\b(?:api[_-]?key|secret|password|passwd|pwd|access[_-]?token|auth[_-]?token|private[_-]?key)\b\s*[:=]\s*['"]([^'"\n]{8,})['"]/i,
|
|
34
|
+
label: 'hardcoded secret assignment',
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Values that look like secrets but are safe placeholders / env references.
|
|
39
|
+
const SECRET_ALLOWLIST = [
|
|
40
|
+
/process\.env/i,
|
|
41
|
+
/os\.environ/i,
|
|
42
|
+
/import\.meta\.env/i,
|
|
43
|
+
/\bgetenv\b/i,
|
|
44
|
+
/\byour[_-]?(?:api[_-]?key|token|secret|password)\b/i,
|
|
45
|
+
/\bexample\b/i,
|
|
46
|
+
/\bplaceholder\b/i,
|
|
47
|
+
/\bchangeme\b/i,
|
|
48
|
+
/\bdummy\b/i,
|
|
49
|
+
/\bredacted\b/i,
|
|
50
|
+
/\bxxx+\b/i,
|
|
51
|
+
/\bsample\b/i,
|
|
52
|
+
/<[^>]+>/, // <your-token-here>
|
|
53
|
+
/\$\{[^}]+\}/, // ${TOKEN}
|
|
54
|
+
/\*{3,}/, // ****
|
|
55
|
+
// Config/reference patterns: the value is a name/path/enum, not a literal secret.
|
|
56
|
+
/(?:_env(?:_var)?|_name|_key_id|_path|_file|_field|_header|_param|_var)\b/i,
|
|
57
|
+
/\b(?:enum|type|interface|column|field|label)\b/i,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function isAllowlisted(line) {
|
|
61
|
+
return SECRET_ALLOWLIST.some((re) => re.test(line));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodedBase64Secret(line) {
|
|
65
|
+
const candidates = line.matchAll(/['"]([A-Za-z0-9+/]{24,}={0,2})['"]/g);
|
|
66
|
+
for (const m of candidates) {
|
|
67
|
+
const raw = m[1];
|
|
68
|
+
if (raw.length % 4 !== 0) continue;
|
|
69
|
+
let decoded = '';
|
|
70
|
+
try {
|
|
71
|
+
decoded = Buffer.from(raw, 'base64').toString('utf8');
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!decoded || decoded.includes('\uFFFD')) continue;
|
|
76
|
+
const printable = decoded.replace(/[\t\r\n\x20-\x7E]/g, '').length === 0;
|
|
77
|
+
if (!printable) continue;
|
|
78
|
+
const hit = SECRET_PATTERNS.find((pat) => pat.re.test(decoded));
|
|
79
|
+
if (hit) return hit;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Scan text for hardcoded credentials.
|
|
86
|
+
* @param {string} content
|
|
87
|
+
* @param {string} [filename]
|
|
88
|
+
* @returns {Array<{check:string,severity:string,line:number,message:string}>}
|
|
89
|
+
*/
|
|
90
|
+
function scanSecrets(content, filename = '') {
|
|
91
|
+
const violations = [];
|
|
92
|
+
const lines = content.split('\n');
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
if (isAllowlisted(line)) continue;
|
|
96
|
+
const decodedHit = decodedBase64Secret(line);
|
|
97
|
+
if (decodedHit) {
|
|
98
|
+
violations.push({
|
|
99
|
+
check: 'secret',
|
|
100
|
+
severity: 'error',
|
|
101
|
+
line: i + 1,
|
|
102
|
+
message: `${filename ? filename + ': ' : ''}possible base64-encoded ${decodedHit.label} (${decodedHit.id}) on line ${i + 1}`,
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
for (const pat of SECRET_PATTERNS) {
|
|
107
|
+
if (pat.re.test(line)) {
|
|
108
|
+
violations.push({
|
|
109
|
+
check: 'secret',
|
|
110
|
+
severity: 'error',
|
|
111
|
+
line: i + 1,
|
|
112
|
+
message: `${filename ? filename + ': ' : ''}possible ${pat.label} (${pat.id}) on line ${i + 1}`,
|
|
113
|
+
});
|
|
114
|
+
break; // one finding per line is enough
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return violations;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── State file checker (R1 / Iron Law #8 + #11) ─────────────────────
|
|
122
|
+
|
|
123
|
+
// Template sentinel: identical to runValidate() in init.js (unfilled template).
|
|
124
|
+
const STATE_TEMPLATE_SENTINEL = 'S1-1 | Project scaffolding';
|
|
125
|
+
|
|
126
|
+
function stripHtmlComments(content) {
|
|
127
|
+
return content.replace(/<!--[\s\S]*?-->/g, '');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getSection(content, header) {
|
|
131
|
+
// Returns the body text between `## header` and the next `## ` (or EOF).
|
|
132
|
+
const re = new RegExp(`^##\\s+${header}\\s*$`, 'm');
|
|
133
|
+
const m = re.exec(content);
|
|
134
|
+
if (!m) return null;
|
|
135
|
+
const start = m.index + m[0].length;
|
|
136
|
+
const rest = content.slice(start);
|
|
137
|
+
const next = /^##\s+/m.exec(rest);
|
|
138
|
+
return next ? rest.slice(0, next.index) : rest;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseMarkdownTable(section) {
|
|
142
|
+
const lines = section.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('|'));
|
|
143
|
+
if (lines.length < 2) return [];
|
|
144
|
+
const header = lines[0].replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
145
|
+
return lines.slice(2)
|
|
146
|
+
.filter((line) => !/^\|\s*[-:|\s]+\|?$/.test(line))
|
|
147
|
+
.map((line) => {
|
|
148
|
+
const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
149
|
+
const row = {};
|
|
150
|
+
header.forEach((name, i) => { row[name] = cells[i] || ''; });
|
|
151
|
+
row.__raw = line;
|
|
152
|
+
return row;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function storyIdFromRow(row) {
|
|
157
|
+
return row.ID || row.Id || row.Story || row['Story ID'] || row['Story'] || '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function rowStatus(row) {
|
|
161
|
+
return row.Status || row.status || '';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Validate docs/project-state.md content for handoff + proof-first integrity.
|
|
166
|
+
* @param {string} content
|
|
167
|
+
* @returns {Array<{check:string,severity:string,line:number,message:string}>}
|
|
168
|
+
*/
|
|
169
|
+
function checkStateFile(content) {
|
|
170
|
+
const violations = [];
|
|
171
|
+
const visible = stripHtmlComments(content);
|
|
172
|
+
|
|
173
|
+
// 1) Unfilled template sentinel still present → setup not run.
|
|
174
|
+
if (content.includes(STATE_TEMPLATE_SENTINEL)) {
|
|
175
|
+
violations.push({
|
|
176
|
+
check: 'state',
|
|
177
|
+
severity: 'error',
|
|
178
|
+
line: 0,
|
|
179
|
+
message: 'project-state.md still contains the unfilled template (run setup before committing work).',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2) Quick Summary must have the 3 handoff lines filled (outside comments).
|
|
184
|
+
const quick = getSection(visible, 'Quick Summary');
|
|
185
|
+
if (quick !== null) {
|
|
186
|
+
const hasDone = /✅/.test(quick);
|
|
187
|
+
const hasProgress = /🔄/.test(quick);
|
|
188
|
+
const hasNext = /➡️/.test(quick);
|
|
189
|
+
if (!(hasDone && hasProgress && hasNext)) {
|
|
190
|
+
violations.push({
|
|
191
|
+
check: 'state',
|
|
192
|
+
severity: 'error',
|
|
193
|
+
line: 0,
|
|
194
|
+
message: 'Quick Summary is missing the 3 handoff lines (✅ last / 🔄 in-progress / ➡️ next). Update before commit (Iron Law #8).',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 3) Proof-First: any ✅ done Story requires at least one Proof Ledger row.
|
|
200
|
+
// Strict: only a Story Status *table row* whose status cell is "✅ done"
|
|
201
|
+
// counts (avoids false positives from prose mentioning "✅ done").
|
|
202
|
+
const storySection = getSection(visible, 'Story Status') || '';
|
|
203
|
+
const hasDoneStory = /^\s*\|.*\|\s*✅\s*done\s*(?:\|.*)?$/m.test(storySection);
|
|
204
|
+
if (hasDoneStory) {
|
|
205
|
+
const ledger = getSection(visible, 'Proof Ledger') || '';
|
|
206
|
+
// A real ledger row is a table data row referencing a result (✅/❌/pass/fail).
|
|
207
|
+
const hasLedgerRow = /\|.*\|.*\|.*(✅|❌|pass|fail).*\|/i.test(ledger);
|
|
208
|
+
if (!hasLedgerRow) {
|
|
209
|
+
violations.push({
|
|
210
|
+
check: 'state',
|
|
211
|
+
severity: 'error',
|
|
212
|
+
line: 0,
|
|
213
|
+
message: 'A Story is marked "✅ done" but the Proof Ledger has no proof row (Iron Law #11 Proof-First).',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return violations;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Reviewer Handoff Gate (R3) ──────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Reviewer Handoff DoD: reviewed/done Stories must carry a compact handoff row
|
|
225
|
+
* so the next session can continue from evidence, risks, and next action.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} content project-state.md
|
|
228
|
+
* @returns {Array}
|
|
229
|
+
*/
|
|
230
|
+
function checkReviewerHandoff(content) {
|
|
231
|
+
const violations = [];
|
|
232
|
+
const visible = stripHtmlComments(content);
|
|
233
|
+
const storyRows = parseMarkdownTable(getSection(visible, 'Story Status') || '');
|
|
234
|
+
const completed = storyRows.filter((row) => /✅\s*done|reviewed|리뷰\s*완료/i.test(rowStatus(row)));
|
|
235
|
+
if (completed.length === 0) return violations;
|
|
236
|
+
|
|
237
|
+
const handoff = getSection(visible, 'Reviewer Handoff') || '';
|
|
238
|
+
const handoffRows = parseMarkdownTable(handoff);
|
|
239
|
+
|
|
240
|
+
for (const story of completed) {
|
|
241
|
+
const id = storyIdFromRow(story);
|
|
242
|
+
const match = handoffRows.find((row) => Object.values(row).some((v) => typeof v === 'string' && v.includes(id)));
|
|
243
|
+
if (!match) {
|
|
244
|
+
violations.push({
|
|
245
|
+
check: 'handoff',
|
|
246
|
+
severity: 'error',
|
|
247
|
+
line: 0,
|
|
248
|
+
message: `Story ${id || '(unknown)'} is reviewed/done but has no Reviewer Handoff row (R3). Add evidence, risk/blocker, and next-action handoff before closing.`,
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const raw = match.__raw || '';
|
|
253
|
+
if (!/(proof|evidence|증거|risk|blocker|next|다음|handoff)/i.test(raw)) {
|
|
254
|
+
violations.push({
|
|
255
|
+
check: 'handoff',
|
|
256
|
+
severity: 'error',
|
|
257
|
+
line: 0,
|
|
258
|
+
message: `Story ${id || '(unknown)'} Reviewer Handoff row is too thin. Include proof/evidence, risk or blocker, and next action (R3).`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return violations;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Learn Completion Gate (R2 / wrap-up + Iron Law #8) ──────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Verify that a session-end "Learn / wrap-up" actually completed its required
|
|
270
|
+
* outputs. The recurring failure (실증 #2) was that Step 6 (Learn) ran
|
|
271
|
+
* selectively, leaving state files stale. This gate is invoked only when the
|
|
272
|
+
* caller asserts a wrap-up just happened (e.g. CLI --wrap-up), so it does not
|
|
273
|
+
* fire on ordinary commits.
|
|
274
|
+
*
|
|
275
|
+
* Required outputs of a non-quiet session:
|
|
276
|
+
* 1. project-state.md Quick Summary 3 handoff lines (checked here too)
|
|
277
|
+
* 2. project-state.md has a Recent Changes entry for the session
|
|
278
|
+
* 3. features.md is not the untouched template (if features were added)
|
|
279
|
+
*
|
|
280
|
+
* @param {{projectState?:string, features?:string, quiet?:boolean}} files
|
|
281
|
+
* @returns {Array}
|
|
282
|
+
*/
|
|
283
|
+
function checkLearnCompletion({ projectState = '', features = '', quiet = false } = {}) {
|
|
284
|
+
const violations = [];
|
|
285
|
+
if (quiet) return violations; // zero-change session: wrap-up legitimately skips most steps
|
|
286
|
+
|
|
287
|
+
const ps = stripHtmlComments(projectState);
|
|
288
|
+
|
|
289
|
+
// 1) Quick Summary handoff lines (reuse the strict rule).
|
|
290
|
+
const quick = getSection(ps, 'Quick Summary');
|
|
291
|
+
if (quick !== null) {
|
|
292
|
+
if (!(/✅/.test(quick) && /🔄/.test(quick) && /➡️/.test(quick))) {
|
|
293
|
+
violations.push({
|
|
294
|
+
check: 'learn',
|
|
295
|
+
severity: 'error',
|
|
296
|
+
line: 0,
|
|
297
|
+
message: 'Learn incomplete: Quick Summary handoff lines (✅/🔄/➡️) not updated at session end (wrap-up Step 4).',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 2) Recent Changes must have at least one real entry (not just the template comment).
|
|
303
|
+
const recent = getSection(ps, 'Recent Changes');
|
|
304
|
+
if (recent !== null) {
|
|
305
|
+
const hasEntry = recent.split('\n').some((l) => {
|
|
306
|
+
const t = l.trim();
|
|
307
|
+
return t.length > 0 && !t.startsWith('<!--') && !t.startsWith('-->') && (t.startsWith('-') || t.startsWith('|') || t.startsWith('*'));
|
|
308
|
+
});
|
|
309
|
+
if (!hasEntry) {
|
|
310
|
+
violations.push({
|
|
311
|
+
check: 'learn',
|
|
312
|
+
severity: 'error',
|
|
313
|
+
line: 0,
|
|
314
|
+
message: 'Learn incomplete: Recent Changes has no session entry (wrap-up Step 4 not finished).',
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return violations;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── State Auto-Sync Gate (R7) ───────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
function splitPathList(value) {
|
|
325
|
+
return String(value || '')
|
|
326
|
+
.split(/[,;<br>`]+|\s{2,}/)
|
|
327
|
+
.map((v) => v.trim())
|
|
328
|
+
.filter((v) => v && !/^n\/a$/i.test(v) && !/^\(?none\)?$/i.test(v));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Cross-check project-state.md, features.md, and dependency-map.md so LLM state
|
|
333
|
+
* updates cannot drift silently after code/docs changes.
|
|
334
|
+
*
|
|
335
|
+
* @param {{projectState?:string, features?:string, dependencyMap?:string}} files
|
|
336
|
+
* @returns {Array}
|
|
337
|
+
*/
|
|
338
|
+
function checkStateSync({ projectState = '', features = '', dependencyMap = '' } = {}) {
|
|
339
|
+
const violations = [];
|
|
340
|
+
const stateVisible = stripHtmlComments(projectState);
|
|
341
|
+
const featureVisible = stripHtmlComments(features);
|
|
342
|
+
const depVisible = stripHtmlComments(dependencyMap);
|
|
343
|
+
|
|
344
|
+
const storyRows = parseMarkdownTable(getSection(stateVisible, 'Story Status') || '');
|
|
345
|
+
const doneStories = storyRows.filter((row) => /✅\s*done/i.test(rowStatus(row)));
|
|
346
|
+
const featureSection = getSection(featureVisible, 'Feature Registry')
|
|
347
|
+
|| getSection(featureVisible, 'Feature List')
|
|
348
|
+
|| featureVisible;
|
|
349
|
+
const featureRows = parseMarkdownTable(featureSection);
|
|
350
|
+
const depSection = getSection(depVisible, 'Module Dependency Map')
|
|
351
|
+
|| getSection(depVisible, 'Module Map')
|
|
352
|
+
|| depVisible;
|
|
353
|
+
const depRows = parseMarkdownTable(depSection);
|
|
354
|
+
const depText = depRows.map((row) => Object.values(row).join(' ')).join('\n');
|
|
355
|
+
|
|
356
|
+
for (const story of doneStories) {
|
|
357
|
+
const id = storyIdFromRow(story);
|
|
358
|
+
const matchedFeature = featureRows.find((row) => Object.values(row).some((v) => typeof v === 'string' && v.includes(id)));
|
|
359
|
+
if (!matchedFeature) {
|
|
360
|
+
violations.push({
|
|
361
|
+
check: 'sync',
|
|
362
|
+
severity: 'error',
|
|
363
|
+
line: 0,
|
|
364
|
+
message: `Story ${id || '(unknown)'} is done but no feature registry row references it (R7 state auto-sync).`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const feature of featureRows) {
|
|
370
|
+
const keyFiles = feature['Key Files'] || feature['Key files'] || feature.Files || feature.Scope || '';
|
|
371
|
+
for (const file of splitPathList(keyFiles)) {
|
|
372
|
+
if (/^docs\//.test(file)) continue;
|
|
373
|
+
const firstSegment = file.split('/').filter(Boolean)[0];
|
|
374
|
+
if (firstSegment && depText && !depText.includes(firstSegment) && !depText.includes(file)) {
|
|
375
|
+
violations.push({
|
|
376
|
+
check: 'sync',
|
|
377
|
+
severity: 'error',
|
|
378
|
+
line: 0,
|
|
379
|
+
message: `Feature registry references ${file}, but dependency-map.md does not mention it (R7 state auto-sync).`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return violations;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── Integration / Persistence DoD (R4) ──────────────────────────────
|
|
389
|
+
|
|
390
|
+
// Evidence terms that prove only in-memory/unit behaviour (insufficient alone).
|
|
391
|
+
const UNIT_ONLY_TERMS = /\bunit\b|단위\s*테스트/i;
|
|
392
|
+
// Evidence terms that prove integration / persistence reached real boundaries.
|
|
393
|
+
const INTEGRATION_TERMS = /\bintegration\b|통합|\bpersist|영속|\brow count|적재|\bcontext test|\be2e\b|database|\bdb\b|repository|commit boundary/i;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Integration/Persistence DoD: a Story marked "✅ done" must have at least one
|
|
397
|
+
* Proof Ledger row whose evidence indicates integration/persistence — not only
|
|
398
|
+
* unit tests. Root cause (실증 #3 FP-KR-005): green unit tests hid a commit-
|
|
399
|
+
* boundary defect (LAMT=0). This makes "green unit tests alone = done" illegal.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} content project-state.md
|
|
402
|
+
* @returns {Array}
|
|
403
|
+
*/
|
|
404
|
+
function checkIntegrationDoD(content) {
|
|
405
|
+
const violations = [];
|
|
406
|
+
const visible = stripHtmlComments(content);
|
|
407
|
+
const storySection = getSection(visible, 'Story Status') || '';
|
|
408
|
+
const hasDoneStory = /^\s*\|.*\|\s*✅\s*done\s*(?:\|.*)?$/m.test(storySection);
|
|
409
|
+
if (!hasDoneStory) return violations;
|
|
410
|
+
|
|
411
|
+
const ledger = getSection(visible, 'Proof Ledger') || '';
|
|
412
|
+
const rows = ledger.split('\n').filter((l) => {
|
|
413
|
+
const t = l.trim();
|
|
414
|
+
return t.startsWith('|') && /(✅|❌|pass|fail)/i.test(t) && !/^\|\s*[-:|\s]+\|?$/.test(t);
|
|
415
|
+
});
|
|
416
|
+
if (rows.length === 0) return violations; // Proof-First rule (checkStateFile) handles the empty case.
|
|
417
|
+
|
|
418
|
+
const hasIntegrationEvidence = rows.some((r) => INTEGRATION_TERMS.test(r));
|
|
419
|
+
const onlyUnitEvidence = rows.every((r) => UNIT_ONLY_TERMS.test(r) && !INTEGRATION_TERMS.test(r));
|
|
420
|
+
|
|
421
|
+
if (!hasIntegrationEvidence && onlyUnitEvidence) {
|
|
422
|
+
violations.push({
|
|
423
|
+
check: 'integration',
|
|
424
|
+
severity: 'error',
|
|
425
|
+
line: 0,
|
|
426
|
+
message: 'A "✅ done" Story has only unit-test proof. Add integration/persistence evidence (row count, context/DB test) before marking done (R4 DoD — green unit tests can hide persistence defects).',
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return violations;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ─── Environment Seal Gate (R9) ──────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Proof rows for done Stories need enough environment detail to reproduce the
|
|
436
|
+
* result. This catches "works on my machine" proof drift.
|
|
437
|
+
*
|
|
438
|
+
* @param {string} content project-state.md
|
|
439
|
+
* @returns {Array}
|
|
440
|
+
*/
|
|
441
|
+
function checkEnvSeal(content) {
|
|
442
|
+
const violations = [];
|
|
443
|
+
const visible = stripHtmlComments(content);
|
|
444
|
+
const storyRows = parseMarkdownTable(getSection(visible, 'Story Status') || '');
|
|
445
|
+
const hasDoneStory = storyRows.some((row) => /✅\s*done/i.test(rowStatus(row)));
|
|
446
|
+
if (!hasDoneStory) return violations;
|
|
447
|
+
|
|
448
|
+
const ledger = getSection(visible, 'Proof Ledger') || '';
|
|
449
|
+
const passingProof = parseMarkdownTable(ledger).some((row) => /(✅|pass)/i.test(Object.values(row).join(' ')));
|
|
450
|
+
if (!passingProof) return violations;
|
|
451
|
+
|
|
452
|
+
const env = getSection(visible, 'Environment Seal') || '';
|
|
453
|
+
const hasCommit = /\b(commit|sha|head|revision)\b\s*[:=]/i.test(env);
|
|
454
|
+
const hasRuntime = /\b(node|npm|python|java|jdk|go|rust|os)\b\s*[:=]/i.test(env);
|
|
455
|
+
const hasCommand = /\b(command|proof|test|검증)\b\s*[:=]/i.test(env);
|
|
456
|
+
if (!(hasCommit && hasRuntime && hasCommand)) {
|
|
457
|
+
violations.push({
|
|
458
|
+
check: 'env-seal',
|
|
459
|
+
severity: 'error',
|
|
460
|
+
line: 0,
|
|
461
|
+
message: 'Done Story proof is missing Environment Seal details (R9). Add commit/head, runtime/OS, and proof command before closing.',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return violations;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ─── Public Boundary Gate (R8) ───────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
const PUBLIC_LEAK_PATTERNS = [
|
|
471
|
+
{ id: 'local-path', re: /(?:^|[\s"'(])(?:\/Users\/|file:\/\/|[A-Za-z]:\\)/, label: 'local machine path' },
|
|
472
|
+
{ id: 'private-page-id', re: /\bpageId\b\s*[:=]?\s*`?["']?\d{6,}\b/i, label: 'private pageId' },
|
|
473
|
+
{ id: 'private-wiki-url', re: /https?:\/\/[^/\s]*(?:atlassian|confluence|jira|wiki)[^/\s]*/i, label: 'private docs URL' },
|
|
474
|
+
{ id: 'container-registry', re: /\b[A-Za-z0-9.-]*(?:acr|registry)[A-Za-z0-9.-]*\.(?:azurecr\.io|local|internal)\b/i, label: 'internal registry reference' },
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Detect internal references that must not ship in the public npm package.
|
|
479
|
+
*
|
|
480
|
+
* @param {string} content
|
|
481
|
+
* @param {string} [filename]
|
|
482
|
+
* @returns {Array}
|
|
483
|
+
*/
|
|
484
|
+
function checkPublicBoundary(content, filename = '') {
|
|
485
|
+
const violations = [];
|
|
486
|
+
const lines = content.split('\n');
|
|
487
|
+
for (let i = 0; i < lines.length; i++) {
|
|
488
|
+
const line = lines[i];
|
|
489
|
+
for (const pat of PUBLIC_LEAK_PATTERNS) {
|
|
490
|
+
if (pat.re.test(line)) {
|
|
491
|
+
violations.push({
|
|
492
|
+
check: 'public-boundary',
|
|
493
|
+
severity: 'error',
|
|
494
|
+
line: i + 1,
|
|
495
|
+
message: `${filename ? filename + ': ' : ''}public package surface contains ${pat.label} (${pat.id}) on line ${i + 1} (R8 two-track boundary).`,
|
|
496
|
+
});
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return violations;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ─── Markdown lint (R6 / L3-8) ───────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Detect malformed markdown tables (column count mismatch vs header).
|
|
508
|
+
* Only inspects contiguous table blocks (lines starting with `|`).
|
|
509
|
+
* @param {string} content
|
|
510
|
+
* @param {string} [filename]
|
|
511
|
+
* @returns {Array<{check:string,severity:string,line:number,message:string}>}
|
|
512
|
+
*/
|
|
513
|
+
function lintMarkdownTables(content, filename = '') {
|
|
514
|
+
const violations = [];
|
|
515
|
+
const lines = content.split('\n');
|
|
516
|
+
let headerCols = null;
|
|
517
|
+
let inComment = false;
|
|
518
|
+
|
|
519
|
+
const countCols = (row) => {
|
|
520
|
+
// Trim leading/trailing pipe, then split. Escaped \| is rare in state files.
|
|
521
|
+
const trimmed = row.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
522
|
+
return trimmed.split('|').length;
|
|
523
|
+
};
|
|
524
|
+
const isSeparator = (row) => /^\s*\|?[\s:|-]+\|?\s*$/.test(row) && row.includes('-');
|
|
525
|
+
|
|
526
|
+
for (let i = 0; i < lines.length; i++) {
|
|
527
|
+
const raw = lines[i];
|
|
528
|
+
// Skip HTML comment blocks (templates embed example tables in comments).
|
|
529
|
+
if (raw.includes('<!--')) inComment = true;
|
|
530
|
+
if (inComment) {
|
|
531
|
+
if (raw.includes('-->')) inComment = false;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const line = raw.trim();
|
|
535
|
+
if (line.startsWith('|')) {
|
|
536
|
+
if (headerCols === null) {
|
|
537
|
+
headerCols = countCols(line);
|
|
538
|
+
} else if (isSeparator(line)) {
|
|
539
|
+
// separator row — ignore column count
|
|
540
|
+
} else {
|
|
541
|
+
const cols = countCols(line);
|
|
542
|
+
if (cols !== headerCols) {
|
|
543
|
+
violations.push({
|
|
544
|
+
check: 'markdown',
|
|
545
|
+
severity: 'error',
|
|
546
|
+
line: i + 1,
|
|
547
|
+
message: `${filename ? filename + ': ' : ''}table row on line ${i + 1} has ${cols} columns, expected ${headerCols}.`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} else if (line === '') {
|
|
552
|
+
headerCols = null; // table block ended
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return violations;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Enforce a maximum line count (state files must stay compact).
|
|
560
|
+
* @param {string} content
|
|
561
|
+
* @param {number} limit
|
|
562
|
+
* @param {string} [filename]
|
|
563
|
+
*/
|
|
564
|
+
function lintLineLimit(content, limit, filename = '') {
|
|
565
|
+
const count = content.split('\n').length;
|
|
566
|
+
if (count > limit) {
|
|
567
|
+
return [{
|
|
568
|
+
check: 'markdown',
|
|
569
|
+
severity: 'warn',
|
|
570
|
+
line: 0,
|
|
571
|
+
message: `${filename ? filename + ': ' : ''}${count} lines exceeds the ${limit}-line limit — archive completed entries.`,
|
|
572
|
+
}];
|
|
573
|
+
}
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ─── Model-Tier Instruction Budget (R10) ─────────────────────────────
|
|
578
|
+
|
|
579
|
+
const INSTRUCTION_BUDGETS = {
|
|
580
|
+
dispatcher: { words: 1500, tokens: 1950 },
|
|
581
|
+
skill: { words: 2500, tokens: 3250 },
|
|
582
|
+
agent: { words: 2500, tokens: 3250 },
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
function estimateTokens(content) {
|
|
586
|
+
const words = content.trim() ? content.trim().split(/\s+/).length : 0;
|
|
587
|
+
return { words, tokens: Math.ceil(words * 1.3) };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function instructionRoleForFile(file) {
|
|
591
|
+
if (/core-rules\.md$|copilot-instructions\.md$|AGENTS\.md$|CLAUDE\.md$/.test(file)) return 'dispatcher';
|
|
592
|
+
if (/\/skills\/|\.skill\.md$/.test(file)) return 'skill';
|
|
593
|
+
if (/\/agents\/|\.agent\.md$|\.toml$/.test(file)) return 'agent';
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Keep generated instructions inside model-tier budgets so smaller/private LLMs
|
|
599
|
+
* can follow the harness without context collapse.
|
|
600
|
+
*
|
|
601
|
+
* @param {string} content
|
|
602
|
+
* @param {string} filename
|
|
603
|
+
* @param {string} [role]
|
|
604
|
+
* @returns {Array}
|
|
605
|
+
*/
|
|
606
|
+
function checkInstructionBudget(content, filename = '', role = instructionRoleForFile(filename)) {
|
|
607
|
+
if (!role || !INSTRUCTION_BUDGETS[role]) return [];
|
|
608
|
+
const budget = INSTRUCTION_BUDGETS[role];
|
|
609
|
+
const usage = estimateTokens(content);
|
|
610
|
+
if (usage.words <= budget.words && usage.tokens <= budget.tokens) return [];
|
|
611
|
+
return [{
|
|
612
|
+
check: 'model-budget',
|
|
613
|
+
severity: 'error',
|
|
614
|
+
line: 0,
|
|
615
|
+
message: `${filename ? filename + ': ' : ''}${role} instruction budget exceeded (${usage.words} words / ~${usage.tokens} tokens; limit ${budget.words} words / ~${budget.tokens} tokens) (R10).`,
|
|
616
|
+
}];
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ─── Orchestrator ────────────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
const fs = require('fs');
|
|
622
|
+
const path = require('path');
|
|
623
|
+
|
|
624
|
+
const STATE_LINE_LIMITS = { 'project-state.md': 200 };
|
|
625
|
+
const PUBLIC_PACKAGE_PATHS = [
|
|
626
|
+
/^bin\//,
|
|
627
|
+
/^src\//,
|
|
628
|
+
/^harness\//,
|
|
629
|
+
/^templates\//,
|
|
630
|
+
/^README(?:\.ko)?\.md$/,
|
|
631
|
+
/^LICENSE$/,
|
|
632
|
+
/^package\.json$/,
|
|
633
|
+
];
|
|
634
|
+
|
|
635
|
+
function isStateFile(file) {
|
|
636
|
+
return /(?:^|\/)(?:docs|\.harness)\/project-state\.md$/.test(file);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function isStateMarkdownFile(file) {
|
|
640
|
+
return /(?:^|\/)(?:docs|\.harness)\/(?:project-state|features|dependency-map|project-brief|failure-patterns)\.md$/.test(file);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function isScannableForSecrets(file) {
|
|
644
|
+
return /\.(js|ts|jsx|tsx|json|jsonc|ya?ml|env|sh|py|java|md|properties|toml)$/i.test(file)
|
|
645
|
+
&& !/\.lock$/.test(file);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function isPublicPackageFile(file) {
|
|
649
|
+
const normalized = file.replace(/\\/g, '/');
|
|
650
|
+
return PUBLIC_PACKAGE_PATHS.some((re) => re.test(normalized));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Run all guard checks over a set of files.
|
|
655
|
+
* @param {{files: string[], cwd?: string}} opts
|
|
656
|
+
* @returns {{ok: boolean, violations: Array, errorCount: number, warnCount: number, scanned: number}}
|
|
657
|
+
*/
|
|
658
|
+
function runGuard({ files, cwd = process.cwd() }) {
|
|
659
|
+
const all = [];
|
|
660
|
+
let scanned = 0;
|
|
661
|
+
|
|
662
|
+
for (const file of files) {
|
|
663
|
+
const abs = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
664
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
|
665
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
666
|
+
const rel = path.relative(cwd, abs);
|
|
667
|
+
scanned++;
|
|
668
|
+
|
|
669
|
+
if (isScannableForSecrets(file)) {
|
|
670
|
+
all.push(...scanSecrets(content, rel));
|
|
671
|
+
}
|
|
672
|
+
if (isPublicPackageFile(rel)) {
|
|
673
|
+
all.push(...checkPublicBoundary(content, rel));
|
|
674
|
+
}
|
|
675
|
+
if (file.endsWith('.md') && isStateMarkdownFile(file)) {
|
|
676
|
+
all.push(...lintMarkdownTables(content, rel));
|
|
677
|
+
}
|
|
678
|
+
if (file.endsWith('.md')) {
|
|
679
|
+
all.push(...checkInstructionBudget(content, rel));
|
|
680
|
+
}
|
|
681
|
+
if (isStateFile(file)) {
|
|
682
|
+
const base = path.basename(file);
|
|
683
|
+
all.push(...checkStateFile(content));
|
|
684
|
+
all.push(...checkReviewerHandoff(content));
|
|
685
|
+
all.push(...checkIntegrationDoD(content));
|
|
686
|
+
all.push(...checkEnvSeal(content));
|
|
687
|
+
if (STATE_LINE_LIMITS[base]) {
|
|
688
|
+
all.push(...lintLineLimit(content, STATE_LINE_LIMITS[base], rel));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const errorCount = all.filter((v) => v.severity === 'error').length;
|
|
694
|
+
const warnCount = all.filter((v) => v.severity === 'warn').length;
|
|
695
|
+
return { ok: errorCount === 0, violations: all, errorCount, warnCount, scanned };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
module.exports = {
|
|
699
|
+
scanSecrets,
|
|
700
|
+
checkStateFile,
|
|
701
|
+
checkReviewerHandoff,
|
|
702
|
+
checkLearnCompletion,
|
|
703
|
+
checkStateSync,
|
|
704
|
+
checkIntegrationDoD,
|
|
705
|
+
checkEnvSeal,
|
|
706
|
+
checkPublicBoundary,
|
|
707
|
+
lintMarkdownTables,
|
|
708
|
+
lintLineLimit,
|
|
709
|
+
checkInstructionBudget,
|
|
710
|
+
estimateTokens,
|
|
711
|
+
runGuard,
|
|
712
|
+
isScannableForSecrets,
|
|
713
|
+
isStateFile,
|
|
714
|
+
isStateMarkdownFile,
|
|
715
|
+
isPublicPackageFile,
|
|
716
|
+
SECRET_PATTERNS,
|
|
717
|
+
};
|