hive-lite 0.1.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/README.md +443 -0
- package/bin/hive.js +6 -0
- package/docs/cli-semantics.md +386 -0
- package/docs/skills/hive-lite-finish/SKILL.md +282 -0
- package/docs/skills/hive-lite-finish/agents/openai.yaml +4 -0
- package/docs/skills/hive-lite-finish/references/safety.md +95 -0
- package/docs/skills/hive-lite-finish/references/verdicts.md +123 -0
- package/docs/skills/hive-lite-map-maintainer/SKILL.md +203 -0
- package/docs/skills/hive-lite-map-maintainer/agents/openai.yaml +7 -0
- package/docs/skills/hive-lite-map-maintainer/references/lifecycle.md +114 -0
- package/docs/skills/hive-lite-map-maintainer/references/repair-rules.md +201 -0
- package/docs/skills/hive-lite-start-prompt/SKILL.md +283 -0
- package/docs/skills/hive-lite-start-prompt/agents/openai.yaml +4 -0
- package/docs/skills/hive-lite-start-prompt/references/input-calibration.md +82 -0
- package/docs/skills/hive-lite-start-prompt/references/preflight.md +116 -0
- package/package.json +40 -0
- package/src/cli.js +910 -0
- package/src/lib/change.js +642 -0
- package/src/lib/context.js +1104 -0
- package/src/lib/evidence.js +230 -0
- package/src/lib/fsx.js +54 -0
- package/src/lib/git.js +128 -0
- package/src/lib/glob.js +47 -0
- package/src/lib/health.js +1012 -0
- package/src/lib/id.js +13 -0
- package/src/lib/map.js +713 -0
- package/src/lib/next.js +341 -0
- package/src/lib/risk.js +122 -0
- package/src/lib/roles.js +109 -0
- package/src/lib/scope.js +168 -0
- package/src/lib/skills.js +349 -0
- package/src/lib/status.js +344 -0
- package/src/lib/yaml.js +223 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
const cp = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { createHash } = require('crypto');
|
|
5
|
+
const { createId } = require('./id');
|
|
6
|
+
const { ensureDir, exists, readJson, writeJson, writeText } = require('./fsx');
|
|
7
|
+
const { changedFiles, commitAll, currentBranch, currentHead, diffFromHead, repoRoot } = require('./git');
|
|
8
|
+
const { firstPatternMatch, matchesPattern } = require('./glob');
|
|
9
|
+
const { hiveDir, loadProjectMap, saveAreas } = require('./map');
|
|
10
|
+
const { evaluateChangeRisk, validationStatus } = require('./risk');
|
|
11
|
+
const { evaluateEvidencePolicy, inferRole } = require('./evidence');
|
|
12
|
+
const { itemPattern, normalizeAreaScope, normalizePatternList, patternDisplay } = require('./scope');
|
|
13
|
+
const { parseYaml, stringifyYaml } = require('./yaml');
|
|
14
|
+
|
|
15
|
+
function sha256(value) {
|
|
16
|
+
return createHash('sha256').update(value || '').digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function changeDir(root, id) {
|
|
20
|
+
return path.join(hiveDir(root), 'changes', id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function changeFile(root, id) {
|
|
24
|
+
return path.join(changeDir(root, id), 'change.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function latestFile(root) {
|
|
28
|
+
return path.join(hiveDir(root), 'changes', 'latest');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function contextPath(root, id) {
|
|
32
|
+
return path.join(hiveDir(root), 'context', `${id}.json`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadContext(root, id) {
|
|
36
|
+
if (!id) return null;
|
|
37
|
+
const file = contextPath(root, id);
|
|
38
|
+
if (!exists(file)) throw new Error(`context packet not found: ${id}`);
|
|
39
|
+
return readJson(file);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadChange(root, id) {
|
|
43
|
+
const file = changeFile(root, id);
|
|
44
|
+
if (!exists(file)) throw new Error(`change not found: ${id}`);
|
|
45
|
+
return readJson(file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function latestChangeId(root) {
|
|
49
|
+
if (!exists(latestFile(root))) return null;
|
|
50
|
+
return fs.readFileSync(latestFile(root), 'utf8').trim() || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveChange(root, change) {
|
|
54
|
+
const dir = changeDir(root, change.id);
|
|
55
|
+
ensureDir(dir);
|
|
56
|
+
writeJson(path.join(dir, 'change.json'), change);
|
|
57
|
+
if (change.diff && change.diff.text !== undefined) {
|
|
58
|
+
writeText(path.join(dir, 'diff.patch'), change.diff.text || '');
|
|
59
|
+
change.diff.patchPath = path.relative(root, path.join(dir, 'diff.patch')).replace(/\\/g, '/');
|
|
60
|
+
writeJson(path.join(dir, 'change.json'), change);
|
|
61
|
+
}
|
|
62
|
+
writeText(latestFile(root), `${change.id}\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadSplitNote(root, splitId) {
|
|
66
|
+
if (!splitId) return null;
|
|
67
|
+
const file = path.join(hiveDir(root), 'context', 'splits', `${splitId}.json`);
|
|
68
|
+
if (!exists(file)) return null;
|
|
69
|
+
return readJson(file);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function acceptedPhasesForSplit(root, splitId) {
|
|
73
|
+
const dir = path.join(hiveDir(root), 'changes');
|
|
74
|
+
if (!exists(dir)) return new Map();
|
|
75
|
+
const accepted = new Map();
|
|
76
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
77
|
+
if (!entry.isDirectory() || !entry.name.startsWith('chg_')) continue;
|
|
78
|
+
const file = path.join(dir, entry.name, 'change.json');
|
|
79
|
+
if (!exists(file)) continue;
|
|
80
|
+
try {
|
|
81
|
+
const item = readJson(file);
|
|
82
|
+
const origin = item.originSplit || {};
|
|
83
|
+
if (origin.splitId !== splitId || !origin.phaseId) continue;
|
|
84
|
+
if (!item.humanDecision || item.humanDecision.status !== 'accepted') continue;
|
|
85
|
+
const current = accepted.get(origin.phaseId) || [];
|
|
86
|
+
current.push({
|
|
87
|
+
changeId: item.id,
|
|
88
|
+
commit: item.humanDecision.commit || null,
|
|
89
|
+
});
|
|
90
|
+
accepted.set(origin.phaseId, current);
|
|
91
|
+
} catch {
|
|
92
|
+
// Ignore malformed historical change records.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return accepted;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function phaseReadiness(phase, accepted) {
|
|
99
|
+
const required = ((phase.preconditions || {}).requiredAcceptedPhases || []);
|
|
100
|
+
const missing = required.filter((phaseId) => !accepted.has(phaseId));
|
|
101
|
+
return {
|
|
102
|
+
phaseId: phase.id,
|
|
103
|
+
title: phase.title,
|
|
104
|
+
areaId: phase.primaryAreaId,
|
|
105
|
+
findIntent: phase.findIntent,
|
|
106
|
+
dependencyStatus: missing.length ? `waiting_on:${missing.join(',')}` : 'ready',
|
|
107
|
+
missingRequiredAcceptedPhases: missing,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function splitPostAcceptHint(root, change) {
|
|
112
|
+
const origin = change.originSplit;
|
|
113
|
+
if (!origin || !origin.splitId) return null;
|
|
114
|
+
const note = loadSplitNote(root, origin.splitId);
|
|
115
|
+
if (!note || !Array.isArray(note.phases)) return null;
|
|
116
|
+
const accepted = acceptedPhasesForSplit(root, origin.splitId);
|
|
117
|
+
const remaining = note.phases
|
|
118
|
+
.filter((phase) => phase.id !== origin.phaseId)
|
|
119
|
+
.filter((phase) => !accepted.has(phase.id))
|
|
120
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
121
|
+
.map((phase) => phaseReadiness(phase, accepted));
|
|
122
|
+
const ready = remaining.filter((phase) => phase.dependencyStatus === 'ready');
|
|
123
|
+
const waiting = remaining.filter((phase) => phase.dependencyStatus !== 'ready');
|
|
124
|
+
return {
|
|
125
|
+
kind: 'split_remaining_phases',
|
|
126
|
+
splitId: origin.splitId,
|
|
127
|
+
completedPhaseId: origin.phaseId || null,
|
|
128
|
+
remainingPhases: remaining,
|
|
129
|
+
readyPhases: ready,
|
|
130
|
+
waitingPhases: waiting,
|
|
131
|
+
message: remaining.length
|
|
132
|
+
? `This accepted change belongs to ${origin.splitId}. Remaining phase seeds are available.`
|
|
133
|
+
: `This accepted change belongs to ${origin.splitId}. No other phase seeds were recorded.`,
|
|
134
|
+
suggestedNextPhase: ready[0] || null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function inferAreaFromFiles(root, map, files) {
|
|
139
|
+
const matches = [];
|
|
140
|
+
for (const area of map.areas) {
|
|
141
|
+
let count = 0;
|
|
142
|
+
const scope = normalizeAreaScope(root, area);
|
|
143
|
+
const patterns = [
|
|
144
|
+
...scope.writableDirect,
|
|
145
|
+
...scope.writableConditional.map(itemPattern),
|
|
146
|
+
...scope.writableBroadFallback.map(itemPattern),
|
|
147
|
+
...scope.readable,
|
|
148
|
+
...((area.entrypoints || []).map((entry) => entry.path).filter(Boolean)),
|
|
149
|
+
].filter(Boolean);
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
if (patterns.some((pattern) => matchesPattern(file, pattern))) count += 1;
|
|
152
|
+
}
|
|
153
|
+
if (count > 0) matches.push({ area, count });
|
|
154
|
+
}
|
|
155
|
+
matches.sort((a, b) => b.count - a.count);
|
|
156
|
+
return matches;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function qualityFor(scope) {
|
|
160
|
+
if (scope.quality) return scope.quality;
|
|
161
|
+
if (scope.writableBroadFallback.length > 0 && scope.writableDirect.length === 0) return 'broad';
|
|
162
|
+
if (scope.writableConditional.length > 0 && scope.writableDirect.length === 0) return 'conditional';
|
|
163
|
+
if (scope.writableDirect.length > 0 && (scope.writableConditional.length > 0 || scope.writableBroadFallback.length > 0)) return 'mixed';
|
|
164
|
+
if (scope.writableDirect.length > 0) return 'narrow';
|
|
165
|
+
return 'unknown';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function scopeFromContext(root, context) {
|
|
169
|
+
if (!context || !context.scope) {
|
|
170
|
+
return normalizeAreaScope(root, {
|
|
171
|
+
readable_scope: context ? context.readableScope || [] : [],
|
|
172
|
+
writable_scope: context ? context.writableScope || [] : [],
|
|
173
|
+
do_not_touch: context ? context.doNotTouch || [] : [],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const scope = context.scope;
|
|
177
|
+
return {
|
|
178
|
+
readable: (scope.readable || []).slice(),
|
|
179
|
+
writableDirect: (scope.writableDirect || []).map(itemPattern).filter(Boolean),
|
|
180
|
+
writableConditional: normalizePatternList(scope.writableConditional || [], { requiresReview: true }),
|
|
181
|
+
writableBroadFallback: normalizePatternList(scope.writableBroadFallback || [], { requiresReview: true }),
|
|
182
|
+
forbidden: (scope.forbidden || []).slice(),
|
|
183
|
+
quality: scope.quality || 'unknown',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function firstItemMatch(file, items) {
|
|
188
|
+
return (items || []).find((item) => matchesPattern(file, itemPattern(item)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function scopeCheck(root, files, context, map) {
|
|
192
|
+
const filePaths = files.map((item) => item.path || item);
|
|
193
|
+
const matchedAreas = [];
|
|
194
|
+
let scope = {
|
|
195
|
+
readable: [],
|
|
196
|
+
writableDirect: [],
|
|
197
|
+
writableConditional: [],
|
|
198
|
+
writableBroadFallback: [],
|
|
199
|
+
forbidden: [],
|
|
200
|
+
quality: 'unknown',
|
|
201
|
+
};
|
|
202
|
+
let source = 'none';
|
|
203
|
+
|
|
204
|
+
if (context) {
|
|
205
|
+
scope = scopeFromContext(root, context);
|
|
206
|
+
if (context.area && context.area.id) matchedAreas.push(context.area.id);
|
|
207
|
+
source = 'context_packet';
|
|
208
|
+
} else {
|
|
209
|
+
const inferred = inferAreaFromFiles(root, map, filePaths);
|
|
210
|
+
if (inferred.length > 0 && inferred[0].count > 0) {
|
|
211
|
+
const top = inferred[0].area;
|
|
212
|
+
scope = normalizeAreaScope(root, top);
|
|
213
|
+
matchedAreas.push(...inferred.filter((item) => item.count > 0).map((item) => item.area.id));
|
|
214
|
+
source = inferred.length === 1 || inferred[0].count > (inferred[1] ? inferred[1].count : 0) ? 'project_map_inferred' : 'project_map_ambiguous';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const violations = [];
|
|
219
|
+
const review = [];
|
|
220
|
+
const reviewDetails = [];
|
|
221
|
+
const matchedTiers = {
|
|
222
|
+
direct: [],
|
|
223
|
+
conditional: [],
|
|
224
|
+
broad: [],
|
|
225
|
+
unmatched: [],
|
|
226
|
+
};
|
|
227
|
+
const allWritable = [
|
|
228
|
+
...scope.writableDirect,
|
|
229
|
+
...scope.writableConditional.map(itemPattern),
|
|
230
|
+
...scope.writableBroadFallback.map(itemPattern),
|
|
231
|
+
].filter(Boolean);
|
|
232
|
+
|
|
233
|
+
for (const file of filePaths) {
|
|
234
|
+
const forbidden = firstPatternMatch(file, scope.forbidden);
|
|
235
|
+
if (forbidden) {
|
|
236
|
+
violations.push(`${file} matched doNotTouch ${forbidden}`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const direct = firstItemMatch(file, scope.writableDirect);
|
|
240
|
+
if (direct) {
|
|
241
|
+
matchedTiers.direct.push(file);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const conditional = firstItemMatch(file, scope.writableConditional);
|
|
245
|
+
if (conditional) {
|
|
246
|
+
matchedTiers.conditional.push(file);
|
|
247
|
+
review.push(file);
|
|
248
|
+
reviewDetails.push(`${file} matched conditional writable scope ${patternDisplay(conditional)}`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const broad = firstItemMatch(file, scope.writableBroadFallback);
|
|
252
|
+
if (broad) {
|
|
253
|
+
matchedTiers.broad.push(file);
|
|
254
|
+
review.push(file);
|
|
255
|
+
reviewDetails.push(`${file} matched broad fallback scope ${patternDisplay(broad)}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (allWritable.length > 0) {
|
|
259
|
+
matchedTiers.unmatched.push(file);
|
|
260
|
+
review.push(file);
|
|
261
|
+
reviewDetails.push(`${file} did not match direct writable scope`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let status = 'clean';
|
|
266
|
+
if (violations.length > 0) status = 'violation';
|
|
267
|
+
else if (allWritable.length === 0) status = 'unknown';
|
|
268
|
+
else if (review.length > 0) status = 'needs_review';
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
status,
|
|
272
|
+
source,
|
|
273
|
+
writable: scope.writableDirect,
|
|
274
|
+
writableDirect: scope.writableDirect,
|
|
275
|
+
writableConditional: scope.writableConditional,
|
|
276
|
+
writableBroadFallback: scope.writableBroadFallback,
|
|
277
|
+
doNotTouch: scope.forbidden,
|
|
278
|
+
quality: qualityFor(scope),
|
|
279
|
+
matchedAreas,
|
|
280
|
+
violations,
|
|
281
|
+
review: [...new Set(review)],
|
|
282
|
+
reviewDetails: [...new Set(reviewDetails)],
|
|
283
|
+
matchedTiers,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function validationPlanFromContextOrMap(context, map) {
|
|
288
|
+
if (context && Array.isArray(context.validationPlan) && context.validationPlan.length > 0) {
|
|
289
|
+
return context.validationPlan;
|
|
290
|
+
}
|
|
291
|
+
const fallback = map.project.defaults && map.project.defaults.validation_cmd;
|
|
292
|
+
return fallback ? [{
|
|
293
|
+
profile: 'baseline',
|
|
294
|
+
command: fallback,
|
|
295
|
+
type: 'command',
|
|
296
|
+
required: true,
|
|
297
|
+
description: 'Baseline project validation.',
|
|
298
|
+
}] : [];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function createOrUpdateChange(cwd, options = {}) {
|
|
302
|
+
const root = repoRoot(cwd);
|
|
303
|
+
const map = loadProjectMap(root);
|
|
304
|
+
const files = changedFiles(root);
|
|
305
|
+
if (files.length === 0) return { root, change: null, clean: true };
|
|
306
|
+
|
|
307
|
+
const diffText = diffFromHead(root);
|
|
308
|
+
const id = options.changeId || createId('chg');
|
|
309
|
+
const existing = options.changeId && exists(changeFile(root, options.changeId))
|
|
310
|
+
? loadChange(root, options.changeId)
|
|
311
|
+
: null;
|
|
312
|
+
const contextId = options.context || (existing && existing.source ? existing.source.contextPacketId : null);
|
|
313
|
+
const context = loadContext(root, contextId);
|
|
314
|
+
const intent = context ? context.intent : (existing ? existing.intent : { raw: '', summary: '' });
|
|
315
|
+
const change = {
|
|
316
|
+
version: 1,
|
|
317
|
+
id,
|
|
318
|
+
createdAt: existing ? existing.createdAt : new Date().toISOString(),
|
|
319
|
+
updatedAt: new Date().toISOString(),
|
|
320
|
+
repo: {
|
|
321
|
+
root,
|
|
322
|
+
branch: currentBranch(root),
|
|
323
|
+
baselineCommit: currentHead(root),
|
|
324
|
+
},
|
|
325
|
+
source: {
|
|
326
|
+
kind: 'working_tree',
|
|
327
|
+
agent: 'unknown',
|
|
328
|
+
contextPacketId: context ? context.id : (existing && existing.source ? existing.source.contextPacketId : null),
|
|
329
|
+
},
|
|
330
|
+
originSplit: context && context.originSplit ? context.originSplit : (existing ? existing.originSplit || null : null),
|
|
331
|
+
intent: {
|
|
332
|
+
raw: intent.raw || '',
|
|
333
|
+
summary: intent.summary || intent.raw || '',
|
|
334
|
+
},
|
|
335
|
+
diff: {
|
|
336
|
+
patchPath: null,
|
|
337
|
+
diffHash: `sha256:${sha256(diffText)}`,
|
|
338
|
+
changedFiles: files,
|
|
339
|
+
text: diffText,
|
|
340
|
+
},
|
|
341
|
+
scope: scopeCheck(root, files, context, map),
|
|
342
|
+
validation: {
|
|
343
|
+
status: existing && existing.validation ? existing.validation.status : 'not_run',
|
|
344
|
+
plan: validationPlanFromContextOrMap(context, map),
|
|
345
|
+
results: existing && existing.validation ? existing.validation.results || [] : [],
|
|
346
|
+
},
|
|
347
|
+
risk: null,
|
|
348
|
+
nextActions: [],
|
|
349
|
+
humanDecision: existing ? existing.humanDecision || null : null,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
change.validation.status = validationStatus(change);
|
|
353
|
+
change.risk = evaluateChangeRisk(change, map);
|
|
354
|
+
change.evidencePolicy = evaluateEvidencePolicy(change, map);
|
|
355
|
+
change.nextActions = nextActions(change);
|
|
356
|
+
saveChange(root, change);
|
|
357
|
+
return { root, change, clean: false };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function nextActions(change) {
|
|
361
|
+
const verdict = change.evidencePolicy ? change.evidencePolicy.verdict : change.risk.verdict;
|
|
362
|
+
if (verdict === 'blocked') return ['fix_blockers', 'check_again'];
|
|
363
|
+
if (verdict === 'needs_validation') return ['validate'];
|
|
364
|
+
if (verdict === 'needs_manual_verification') return ['manual_validate', 'accept_with_review_reason'];
|
|
365
|
+
if (verdict === 'needs_review_reason') return ['accept_with_review_reason', 'check_again'];
|
|
366
|
+
if (verdict === 'evidence_insufficient') return ['add_focused_evidence', 'accept_with_review_reason'];
|
|
367
|
+
if (change.risk.verdict === 'needs_review') return ['accept_after_review', 'check_again'];
|
|
368
|
+
return ['accept', 'accept_with_commit'];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function findValidationCommand(change, map, options = {}) {
|
|
372
|
+
if (options.cmd) return { profile: options.profile || 'custom', command: options.cmd };
|
|
373
|
+
const plan = change.validation.plan || [];
|
|
374
|
+
if (options.profile) {
|
|
375
|
+
const match = plan.find((item) => item.profile === options.profile);
|
|
376
|
+
if (!match) throw new Error(`validation profile not in change plan: ${options.profile}`);
|
|
377
|
+
if (!match.command) throw new Error(`validation profile is manual or has no command: ${options.profile}`);
|
|
378
|
+
return match;
|
|
379
|
+
}
|
|
380
|
+
const first = plan.find((item) => item.command);
|
|
381
|
+
if (first) return first;
|
|
382
|
+
const fallback = map.project.defaults && map.project.defaults.validation_cmd;
|
|
383
|
+
if (fallback) return { profile: 'baseline', command: fallback };
|
|
384
|
+
throw new Error('no validation command configured; use --cmd or add .hive/map/validation.yaml');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validateChange(cwd, id, options = {}) {
|
|
388
|
+
const root = repoRoot(cwd);
|
|
389
|
+
const changeId = id || latestChangeId(root);
|
|
390
|
+
if (!changeId) throw new Error('no change id provided and no latest change exists');
|
|
391
|
+
const map = loadProjectMap(root);
|
|
392
|
+
const change = loadChange(root, changeId);
|
|
393
|
+
ensureDir(path.join(changeDir(root, change.id), 'validation'));
|
|
394
|
+
|
|
395
|
+
let result;
|
|
396
|
+
if (options.manual) {
|
|
397
|
+
if (options.manual === true) throw new Error('manual validation requires a profile id');
|
|
398
|
+
const status = options.result || 'passed';
|
|
399
|
+
if (!['passed', 'failed', 'not_applicable'].includes(status)) {
|
|
400
|
+
throw new Error('manual validation --result must be passed, failed, or not_applicable');
|
|
401
|
+
}
|
|
402
|
+
if (!options.note || options.note === true || !String(options.note).trim()) {
|
|
403
|
+
throw new Error('manual validation requires --note "..."');
|
|
404
|
+
}
|
|
405
|
+
result = {
|
|
406
|
+
id: createId('val'),
|
|
407
|
+
changeId: change.id,
|
|
408
|
+
profile: options.manual,
|
|
409
|
+
command: null,
|
|
410
|
+
type: 'manual',
|
|
411
|
+
startedAt: new Date().toISOString(),
|
|
412
|
+
endedAt: new Date().toISOString(),
|
|
413
|
+
exitCode: status === 'failed' ? 1 : 0,
|
|
414
|
+
status,
|
|
415
|
+
result: status,
|
|
416
|
+
note: String(options.note),
|
|
417
|
+
};
|
|
418
|
+
} else {
|
|
419
|
+
const selected = findValidationCommand(change, map, options);
|
|
420
|
+
const idVal = createId('val');
|
|
421
|
+
const startedAt = new Date().toISOString();
|
|
422
|
+
if (typeof options.onStart === 'function') {
|
|
423
|
+
options.onStart({
|
|
424
|
+
changeId: change.id,
|
|
425
|
+
profile: selected.profile || 'custom',
|
|
426
|
+
command: selected.command,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const execResult = cp.spawnSync(selected.command, {
|
|
430
|
+
cwd: root,
|
|
431
|
+
shell: true,
|
|
432
|
+
encoding: 'utf8',
|
|
433
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
434
|
+
});
|
|
435
|
+
const endedAt = new Date().toISOString();
|
|
436
|
+
const stdoutPath = path.join(changeDir(root, change.id), 'validation', `${idVal}.stdout.log`);
|
|
437
|
+
const stderrPath = path.join(changeDir(root, change.id), 'validation', `${idVal}.stderr.log`);
|
|
438
|
+
writeText(stdoutPath, execResult.stdout || '');
|
|
439
|
+
writeText(stderrPath, execResult.stderr || '');
|
|
440
|
+
result = {
|
|
441
|
+
id: idVal,
|
|
442
|
+
changeId: change.id,
|
|
443
|
+
profile: selected.profile || 'custom',
|
|
444
|
+
command: selected.command,
|
|
445
|
+
type: 'command',
|
|
446
|
+
startedAt,
|
|
447
|
+
endedAt,
|
|
448
|
+
exitCode: execResult.status == null ? 1 : execResult.status,
|
|
449
|
+
status: execResult.status === 0 ? 'passed' : 'failed',
|
|
450
|
+
stdoutPath: path.relative(root, stdoutPath).replace(/\\/g, '/'),
|
|
451
|
+
stderrPath: path.relative(root, stderrPath).replace(/\\/g, '/'),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
change.validation.results.push(result);
|
|
456
|
+
change.validation.status = validationStatus(change);
|
|
457
|
+
change.risk = evaluateChangeRisk(change, map);
|
|
458
|
+
change.evidencePolicy = evaluateEvidencePolicy(change, map);
|
|
459
|
+
change.nextActions = nextActions(change);
|
|
460
|
+
change.updatedAt = new Date().toISOString();
|
|
461
|
+
saveChange(root, change);
|
|
462
|
+
return { root, change, result };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function createMapDelta(root, change, commit) {
|
|
466
|
+
const context = loadContext(root, change.source.contextPacketId);
|
|
467
|
+
if (!context || !context.area || !context.area.id) return null;
|
|
468
|
+
const map = loadProjectMap(root);
|
|
469
|
+
const area = map.areas.find((item) => item.id === context.area.id);
|
|
470
|
+
if (!area) return null;
|
|
471
|
+
const entrypointPaths = new Set((area.entrypoints || []).map((entry) => entry.path));
|
|
472
|
+
const scope = normalizeAreaScope(root, area);
|
|
473
|
+
const candidate = (change.diff.changedFiles || []).find((file) => {
|
|
474
|
+
if (entrypointPaths.has(file.path)) return false;
|
|
475
|
+
if (scope.writableDirect.some((pattern) => matchesPattern(file.path, pattern))) return false;
|
|
476
|
+
return true;
|
|
477
|
+
});
|
|
478
|
+
if (!candidate) return null;
|
|
479
|
+
|
|
480
|
+
const delta = {
|
|
481
|
+
version: 1,
|
|
482
|
+
id: createId('delta'),
|
|
483
|
+
createdAt: new Date().toISOString(),
|
|
484
|
+
source: {
|
|
485
|
+
changeId: change.id,
|
|
486
|
+
commit: commit || null,
|
|
487
|
+
contextPacketId: context.id,
|
|
488
|
+
},
|
|
489
|
+
status: 'draft',
|
|
490
|
+
type: 'area_file_mapping',
|
|
491
|
+
claim: {
|
|
492
|
+
text: `${context.area.id} is related to ${candidate.path}.`,
|
|
493
|
+
durability: 'medium',
|
|
494
|
+
confidence: 'medium',
|
|
495
|
+
},
|
|
496
|
+
target: {
|
|
497
|
+
areaId: context.area.id,
|
|
498
|
+
},
|
|
499
|
+
proposedPatch: {
|
|
500
|
+
file: '.hive/map/areas.yaml',
|
|
501
|
+
operation: 'add_entrypoint',
|
|
502
|
+
value: {
|
|
503
|
+
path: candidate.path,
|
|
504
|
+
role: inferRole(candidate.path),
|
|
505
|
+
source: 'accepted_change',
|
|
506
|
+
confidence: 'medium',
|
|
507
|
+
sourceChangeId: change.id,
|
|
508
|
+
sourceCommit: commit || null,
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
review: {
|
|
512
|
+
required: true,
|
|
513
|
+
reason: 'Durable Project Map updates require human approval.',
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const yaml = stringifyYaml(delta);
|
|
518
|
+
const deltaFile = path.join(hiveDir(root), 'deltas', `${delta.id}.yaml`);
|
|
519
|
+
const changeDeltaDir = path.join(changeDir(root, change.id), 'map-delta');
|
|
520
|
+
ensureDir(changeDeltaDir);
|
|
521
|
+
writeText(deltaFile, yaml);
|
|
522
|
+
writeText(path.join(changeDeltaDir, `${delta.id}.yaml`), yaml);
|
|
523
|
+
return delta;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function acceptChange(cwd, id, options = {}) {
|
|
527
|
+
const root = repoRoot(cwd);
|
|
528
|
+
const changeId = id || latestChangeId(root);
|
|
529
|
+
if (!changeId) throw new Error('no change id provided and no latest change exists');
|
|
530
|
+
const map = loadProjectMap(root);
|
|
531
|
+
const change = loadChange(root, changeId);
|
|
532
|
+
change.risk = evaluateChangeRisk(change, map);
|
|
533
|
+
change.evidencePolicy = evaluateEvidencePolicy(change, map);
|
|
534
|
+
const verdict = change.evidencePolicy.verdict;
|
|
535
|
+
|
|
536
|
+
if (options.acceptRisk && (!options.reason || options.reason === true)) {
|
|
537
|
+
throw new Error('accept-risk requires --reason "..."');
|
|
538
|
+
}
|
|
539
|
+
if (options.skipValidation && (!options.reason || options.reason === true)) {
|
|
540
|
+
throw new Error('skip-validation requires --reason "..."');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (verdict === 'blocked' && !options.force) {
|
|
544
|
+
const reasons = (change.evidencePolicy.reasons || []).join('; ') || (change.risk.blockingReasons || []).join('; ');
|
|
545
|
+
throw new Error(`change is blocked: ${reasons}`);
|
|
546
|
+
}
|
|
547
|
+
if (verdict === 'needs_validation' && !options.skipValidation && !options.reviewed && !options.acceptRisk) {
|
|
548
|
+
throw new Error('change needs validation; run hive validate or pass --skip-validation --reason "..."');
|
|
549
|
+
}
|
|
550
|
+
if (verdict === 'needs_manual_verification' && !options.reviewed && !options.reason && !options.acceptRisk) {
|
|
551
|
+
throw new Error('change needs manual verification; run hive validate --manual <profile> --result passed --note "..." or accept with --reviewed --reason "..."');
|
|
552
|
+
}
|
|
553
|
+
if ((verdict === 'needs_review_reason' || verdict === 'evidence_insufficient') && !options.reviewed && !options.reason && !options.acceptRisk) {
|
|
554
|
+
throw new Error('change needs a review reason; pass --reviewed --reason "..." or --accept-risk --reason "..."');
|
|
555
|
+
}
|
|
556
|
+
if ((options.reviewed || options.reason) && (!options.reason || options.reason === true) && verdict !== 'acceptable') {
|
|
557
|
+
throw new Error('reviewed acceptance requires --reason "..."');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
let commit = null;
|
|
561
|
+
if (options.commit) {
|
|
562
|
+
commit = commitAll(root, options.message || change.intent.summary || `Accept ${change.id}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
change.humanDecision = {
|
|
566
|
+
status: 'accepted',
|
|
567
|
+
mode: options.acceptRisk ? 'accepted_risk'
|
|
568
|
+
: options.reviewed || options.reason ? 'reviewed_with_reason'
|
|
569
|
+
: options.skipValidation ? 'skipped_validation'
|
|
570
|
+
: 'normal',
|
|
571
|
+
decidedAt: new Date().toISOString(),
|
|
572
|
+
reason: options.reason || null,
|
|
573
|
+
reviewed: Boolean(options.reviewed),
|
|
574
|
+
acceptedRisk: Boolean(options.acceptRisk),
|
|
575
|
+
skippedValidation: Boolean(options.skipValidation),
|
|
576
|
+
evidencePolicyVerdictBeforeAccept: verdict,
|
|
577
|
+
commit,
|
|
578
|
+
};
|
|
579
|
+
change.updatedAt = new Date().toISOString();
|
|
580
|
+
writeJson(path.join(changeDir(root, change.id), 'evidence.json'), change);
|
|
581
|
+
saveChange(root, change);
|
|
582
|
+
const delta = createMapDelta(root, change, commit);
|
|
583
|
+
return { root, change, commit, delta, postAcceptHint: splitPostAcceptHint(root, change) };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function readDeltaFile(file) {
|
|
587
|
+
return parseYaml(fs.readFileSync(file, 'utf8'));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function allDeltas(root) {
|
|
591
|
+
const dir = path.join(hiveDir(root), 'deltas');
|
|
592
|
+
if (!exists(dir)) return [];
|
|
593
|
+
return fs.readdirSync(dir)
|
|
594
|
+
.filter((file) => file.endsWith('.yaml') || file.endsWith('.yml'))
|
|
595
|
+
.map((file) => readDeltaFile(path.join(dir, file)));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function applyDelta(cwd, id) {
|
|
599
|
+
const root = repoRoot(cwd);
|
|
600
|
+
const deltaFile = path.join(hiveDir(root), 'deltas', `${id}.yaml`);
|
|
601
|
+
if (!exists(deltaFile)) throw new Error(`delta not found: ${id}`);
|
|
602
|
+
const delta = readDeltaFile(deltaFile);
|
|
603
|
+
if (delta.status === 'applied') return { root, delta, changed: false };
|
|
604
|
+
if (delta.type !== 'area_file_mapping') throw new Error(`unsupported delta type: ${delta.type}`);
|
|
605
|
+
|
|
606
|
+
const map = loadProjectMap(root);
|
|
607
|
+
const area = map.areasDoc.areas.find((item) => item.id === delta.target.areaId);
|
|
608
|
+
if (!area) throw new Error(`target area not found: ${delta.target.areaId}`);
|
|
609
|
+
area.entrypoints = area.entrypoints || [];
|
|
610
|
+
const value = delta.proposedPatch.value;
|
|
611
|
+
const existsAlready = area.entrypoints.some((entry) => entry.path === value.path);
|
|
612
|
+
if (!existsAlready) area.entrypoints.push(value);
|
|
613
|
+
saveAreas(root, map.areasDoc);
|
|
614
|
+
delta.status = 'applied';
|
|
615
|
+
delta.appliedAt = new Date().toISOString();
|
|
616
|
+
fs.writeFileSync(deltaFile, stringifyYaml(delta));
|
|
617
|
+
return { root, delta, changed: !existsAlready };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function rejectDelta(cwd, id, reason) {
|
|
621
|
+
const root = repoRoot(cwd);
|
|
622
|
+
const deltaFile = path.join(hiveDir(root), 'deltas', `${id}.yaml`);
|
|
623
|
+
if (!exists(deltaFile)) throw new Error(`delta not found: ${id}`);
|
|
624
|
+
const delta = readDeltaFile(deltaFile);
|
|
625
|
+
delta.status = 'rejected';
|
|
626
|
+
delta.rejectedAt = new Date().toISOString();
|
|
627
|
+
delta.rejectionReason = reason || '';
|
|
628
|
+
fs.writeFileSync(deltaFile, stringifyYaml(delta));
|
|
629
|
+
return { root, delta };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
module.exports = {
|
|
633
|
+
acceptChange,
|
|
634
|
+
allDeltas,
|
|
635
|
+
applyDelta,
|
|
636
|
+
changeDir,
|
|
637
|
+
createOrUpdateChange,
|
|
638
|
+
latestChangeId,
|
|
639
|
+
loadChange,
|
|
640
|
+
rejectDelta,
|
|
641
|
+
validateChange,
|
|
642
|
+
};
|