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.
Files changed (33) hide show
  1. package/README.md +443 -0
  2. package/bin/hive.js +6 -0
  3. package/docs/cli-semantics.md +386 -0
  4. package/docs/skills/hive-lite-finish/SKILL.md +282 -0
  5. package/docs/skills/hive-lite-finish/agents/openai.yaml +4 -0
  6. package/docs/skills/hive-lite-finish/references/safety.md +95 -0
  7. package/docs/skills/hive-lite-finish/references/verdicts.md +123 -0
  8. package/docs/skills/hive-lite-map-maintainer/SKILL.md +203 -0
  9. package/docs/skills/hive-lite-map-maintainer/agents/openai.yaml +7 -0
  10. package/docs/skills/hive-lite-map-maintainer/references/lifecycle.md +114 -0
  11. package/docs/skills/hive-lite-map-maintainer/references/repair-rules.md +201 -0
  12. package/docs/skills/hive-lite-start-prompt/SKILL.md +283 -0
  13. package/docs/skills/hive-lite-start-prompt/agents/openai.yaml +4 -0
  14. package/docs/skills/hive-lite-start-prompt/references/input-calibration.md +82 -0
  15. package/docs/skills/hive-lite-start-prompt/references/preflight.md +116 -0
  16. package/package.json +40 -0
  17. package/src/cli.js +910 -0
  18. package/src/lib/change.js +642 -0
  19. package/src/lib/context.js +1104 -0
  20. package/src/lib/evidence.js +230 -0
  21. package/src/lib/fsx.js +54 -0
  22. package/src/lib/git.js +128 -0
  23. package/src/lib/glob.js +47 -0
  24. package/src/lib/health.js +1012 -0
  25. package/src/lib/id.js +13 -0
  26. package/src/lib/map.js +713 -0
  27. package/src/lib/next.js +341 -0
  28. package/src/lib/risk.js +122 -0
  29. package/src/lib/roles.js +109 -0
  30. package/src/lib/scope.js +168 -0
  31. package/src/lib/skills.js +349 -0
  32. package/src/lib/status.js +344 -0
  33. package/src/lib/yaml.js +223 -0
@@ -0,0 +1,1012 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { createId } = require('./id');
4
+ const { exists, readText } = require('./fsx');
5
+ const { tryGit } = require('./git');
6
+ const { matchesPattern } = require('./glob');
7
+ const { hiveDir, mapFile } = require('./map');
8
+ const { normalizeAreaScope } = require('./scope');
9
+ const { parseYaml } = require('./yaml');
10
+ const {
11
+ ROLE_TAXONOMY,
12
+ isCanonicalRole,
13
+ isInternalBehaviorRole,
14
+ isKnownRole,
15
+ isUiManualRole,
16
+ normalizeRole,
17
+ } = require('./roles');
18
+
19
+ const GENERIC_AREA_IDS = new Set([
20
+ 'dashboard',
21
+ 'frontend',
22
+ 'backend',
23
+ 'server',
24
+ 'client',
25
+ 'components',
26
+ 'src',
27
+ 'app',
28
+ 'web',
29
+ 'ui',
30
+ 'api',
31
+ 'database',
32
+ 'db',
33
+ 'common',
34
+ 'shared',
35
+ 'utils',
36
+ 'lib',
37
+ ]);
38
+
39
+ const GENERIC_ALIASES = new Set([
40
+ ...GENERIC_AREA_IDS,
41
+ 'front end',
42
+ 'back end',
43
+ ]);
44
+
45
+ const BUILT_IN_MANUAL_PROFILES = new Set([
46
+ 'manual-verification',
47
+ 'generic-ui-visual',
48
+ 'generic-cli-behavior',
49
+ 'generic-doc-review',
50
+ ]);
51
+
52
+ function normalizePath(value) {
53
+ return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
54
+ }
55
+
56
+ function patternFrom(value) {
57
+ if (!value) return '';
58
+ if (typeof value === 'string') return normalizePath(value.trim());
59
+ return normalizePath((value.pattern || value.path || '').trim());
60
+ }
61
+
62
+ function readYamlHealth(root, name, fallback, findings) {
63
+ const file = mapFile(root, name);
64
+ if (!exists(file)) {
65
+ addFinding(findings, {
66
+ severity: 'critical',
67
+ code: 'MAP_FILE_MISSING',
68
+ file: `.hive/map/${name}`,
69
+ field: '',
70
+ message: `.hive/map/${name} is missing.`,
71
+ impact: 'Hive Lite cannot evaluate Project Map health.',
72
+ fix: 'Run hive-lite init or restore the missing map file.',
73
+ });
74
+ return { doc: fallback, valid: false };
75
+ }
76
+ try {
77
+ return { doc: parseYaml(readText(file)), valid: true };
78
+ } catch (error) {
79
+ addFinding(findings, {
80
+ severity: 'critical',
81
+ code: 'INVALID_YAML',
82
+ file: `.hive/map/${name}`,
83
+ field: '',
84
+ message: `Could not parse .hive/map/${name}: ${error.message}`,
85
+ impact: 'Hive Lite cannot safely read the Project Map.',
86
+ fix: 'Fix the YAML syntax, then rerun hive-lite map health.',
87
+ });
88
+ return { doc: fallback, valid: false };
89
+ }
90
+ }
91
+
92
+ function validateMapFilesNotIgnored(root, findings) {
93
+ const files = [
94
+ '.hive/config.yaml',
95
+ '.hive/map/project.yaml',
96
+ '.hive/map/areas.yaml',
97
+ '.hive/map/rules.yaml',
98
+ '.hive/map/validation.yaml',
99
+ ];
100
+ const output = tryGit(['check-ignore', '--no-index', ...files], { cwd: root });
101
+ if (!output) return;
102
+ for (const ignored of output.split(/\r?\n/).filter(Boolean)) {
103
+ addFinding(findings, {
104
+ severity: 'warning',
105
+ code: 'MAP_FILE_IGNORED',
106
+ file: ignored,
107
+ field: '',
108
+ message: `${ignored} is ignored by git ignore/exclude rules.`,
109
+ impact: 'Project Map changes may not appear in git status and may not be committed for future agents.',
110
+ fix: 'Do not ignore `.hive/map/**` or `.hive/config.yaml`; ignore only runtime evidence such as `.hive/context/`, `.hive/changes/`, `.hive/patches/`, and `.hive/state/`.',
111
+ });
112
+ }
113
+ }
114
+
115
+ function trackedFiles(root) {
116
+ return tryGit(['ls-files'], { cwd: root }).split(/\r?\n/).filter(Boolean).map(normalizePath);
117
+ }
118
+
119
+ function packageScripts(root) {
120
+ const file = path.join(root, 'package.json');
121
+ if (!exists(file)) return {};
122
+ try {
123
+ return JSON.parse(readText(file)).scripts || {};
124
+ } catch {
125
+ return {};
126
+ }
127
+ }
128
+
129
+ function countMatches(files, pattern) {
130
+ return files.filter((file) => matchesPattern(file, pattern)).length;
131
+ }
132
+
133
+ function exactPathStatus(root, pattern) {
134
+ if (pattern.includes('*')) return { exists: true, isDirectory: false };
135
+ const full = path.join(root, pattern);
136
+ if (!fs.existsSync(full)) return { exists: false, isDirectory: false };
137
+ return { exists: true, isDirectory: fs.statSync(full).isDirectory() };
138
+ }
139
+
140
+ function looksLikeFile(pattern) {
141
+ if (pattern.includes('*')) return false;
142
+ return /\.[A-Za-z0-9]+$/.test(path.basename(pattern));
143
+ }
144
+
145
+ function isBroadWritablePattern(pattern, matchCount) {
146
+ const normalized = normalizePath(pattern);
147
+ if (!normalized || normalized === '.' || normalized === './') return true;
148
+ if (normalized.includes('**')) return true;
149
+ if (normalized.endsWith('/*')) return matchCount == null || matchCount > 12;
150
+ if (!normalized.includes('*') && !looksLikeFile(normalized)) return matchCount == null || matchCount > 8;
151
+ return matchCount > 30;
152
+ }
153
+
154
+ function field(areaId, suffix) {
155
+ return `areas[${areaId}]${suffix ? `.${suffix}` : ''}`;
156
+ }
157
+
158
+ function addFinding(findings, data) {
159
+ findings.push({
160
+ id: createId('finding'),
161
+ severity: data.severity,
162
+ code: data.code,
163
+ areaId: data.areaId || null,
164
+ file: data.file || '.hive/map/areas.yaml',
165
+ field: data.field || '',
166
+ message: data.message,
167
+ impact: data.impact || '',
168
+ fix: data.fix || '',
169
+ metrics: data.metrics || undefined,
170
+ relatedCommand: data.relatedCommand || null,
171
+ });
172
+ }
173
+
174
+ function focusForArea(area) {
175
+ return String(area.name || area.id || '').replace(/[_\.]+/g, ' ').trim();
176
+ }
177
+
178
+ function mapPromptCommand(area) {
179
+ const focus = focusForArea(area);
180
+ return `hive-lite map prompt --focus ${JSON.stringify(focus)} --max-areas 3`;
181
+ }
182
+
183
+ function isGenericAreaId(id) {
184
+ const normalized = String(id || '').toLowerCase();
185
+ if (!normalized.includes('.')) return true;
186
+ const last = normalized.split('.').pop();
187
+ return GENERIC_AREA_IDS.has(normalized) || GENERIC_AREA_IDS.has(last);
188
+ }
189
+
190
+ function validateAreaId(area, areaIds, findings) {
191
+ if (!area.id) {
192
+ addFinding(findings, {
193
+ severity: 'critical',
194
+ code: 'MISSING_AREA_ID',
195
+ field: 'areas[].id',
196
+ message: 'Area is missing id.',
197
+ impact: 'Hive Lite cannot route intents to this area.',
198
+ fix: 'Add a stable area id such as dashboard.action_inbox.',
199
+ });
200
+ return;
201
+ }
202
+ if (areaIds.has(area.id)) {
203
+ addFinding(findings, {
204
+ severity: 'critical',
205
+ code: 'DUPLICATE_AREA_ID',
206
+ areaId: area.id,
207
+ field: field(area.id, 'id'),
208
+ message: `Duplicate area id: ${area.id}.`,
209
+ impact: 'find/check cannot unambiguously refer to this area.',
210
+ fix: 'Rename or merge duplicate areas.',
211
+ });
212
+ }
213
+ areaIds.add(area.id);
214
+ if (!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(area.id)) {
215
+ addFinding(findings, {
216
+ severity: 'warning',
217
+ code: 'GENERIC_AREA_ID',
218
+ areaId: area.id,
219
+ field: field(area.id, 'id'),
220
+ message: `Area id "${area.id}" should be a product/work area id with at least two segments.`,
221
+ impact: 'find may route to a broad technical area instead of a precise work area.',
222
+ fix: 'Prefer ids such as dashboard.action_inbox or change.acceptance_flow.',
223
+ relatedCommand: mapPromptCommand(area),
224
+ });
225
+ } else if (isGenericAreaId(area.id)) {
226
+ addFinding(findings, {
227
+ severity: 'warning',
228
+ code: 'GENERIC_AREA_ID',
229
+ areaId: area.id,
230
+ field: field(area.id, 'id'),
231
+ message: `Area id "${area.id}" is generic.`,
232
+ impact: 'Generic areas often produce broad Context Packets.',
233
+ fix: 'Prefer product/work areas rather than parent modules.',
234
+ relatedCommand: mapPromptCommand(area),
235
+ });
236
+ }
237
+ }
238
+
239
+ function validateFindTerms(area, aliasOwners, findings) {
240
+ const aliases = (area.aliases || []).filter(Boolean);
241
+ const concepts = (area.concepts || []).filter(Boolean);
242
+ const userIntents = (area.user_intents || area.userIntents || []).filter(Boolean);
243
+
244
+ if (aliases.length === 0 && concepts.length === 0 && userIntents.length === 0) {
245
+ addFinding(findings, {
246
+ severity: 'critical',
247
+ code: 'MISSING_FIND_TERMS',
248
+ areaId: area.id,
249
+ field: field(area.id, 'aliases'),
250
+ message: 'Area has no aliases, concepts, or user_intents.',
251
+ impact: 'find has almost no deterministic signal to match this area.',
252
+ fix: 'Add user-facing aliases, concepts, or user_intents.',
253
+ relatedCommand: mapPromptCommand(area),
254
+ });
255
+ return;
256
+ }
257
+ if (aliases.length === 0) {
258
+ addFinding(findings, {
259
+ severity: 'warning',
260
+ code: 'MISSING_ALIASES',
261
+ areaId: area.id,
262
+ field: field(area.id, 'aliases'),
263
+ message: 'Area aliases are empty.',
264
+ impact: 'find may depend on weak token or grep matches.',
265
+ fix: 'Add 2-6 phrases users naturally say for this area.',
266
+ relatedCommand: mapPromptCommand(area),
267
+ });
268
+ }
269
+ if (concepts.length === 0) {
270
+ addFinding(findings, {
271
+ severity: 'warning',
272
+ code: 'MISSING_CONCEPTS',
273
+ areaId: area.id,
274
+ field: field(area.id, 'concepts'),
275
+ message: 'Area concepts are empty.',
276
+ impact: 'find has fewer stable semantic keywords to route intents.',
277
+ fix: 'Add product/workflow concepts for this area.',
278
+ relatedCommand: mapPromptCommand(area),
279
+ });
280
+ }
281
+ if (aliases.length > 0 && aliases.every((alias) => GENERIC_ALIASES.has(String(alias).toLowerCase()))) {
282
+ addFinding(findings, {
283
+ severity: 'warning',
284
+ code: 'GENERIC_ALIASES_ONLY',
285
+ areaId: area.id,
286
+ field: field(area.id, 'aliases'),
287
+ message: 'Area aliases are all generic.',
288
+ impact: 'find may choose this area for unrelated intents.',
289
+ fix: 'Add product-specific aliases.',
290
+ relatedCommand: mapPromptCommand(area),
291
+ });
292
+ }
293
+ for (const alias of aliases) {
294
+ const key = String(alias).toLowerCase();
295
+ const owners = aliasOwners.get(key) || [];
296
+ if (owners.length > 1) {
297
+ addFinding(findings, {
298
+ severity: 'warning',
299
+ code: 'DUPLICATE_ALIAS',
300
+ areaId: area.id,
301
+ field: field(area.id, 'aliases'),
302
+ message: `Alias "${alias}" also appears in ${owners.filter((id) => id !== area.id).join(', ')}.`,
303
+ impact: 'find may choose the wrong area when this phrase appears.',
304
+ fix: 'Keep shared parent terms in concepts and make aliases more specific.',
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ function validateEntryPoints(root, area, findings) {
311
+ const entrypoints = area.entrypoints || [];
312
+ const writableDirect = normalizeAreaScope(root, area).writableDirect || [];
313
+ if (entrypoints.length === 0) {
314
+ addFinding(findings, {
315
+ severity: 'critical',
316
+ code: 'MISSING_ENTRYPOINTS',
317
+ areaId: area.id,
318
+ field: field(area.id, 'entrypoints'),
319
+ message: 'Area has no entrypoints.',
320
+ impact: 'find may rely on grep-only context instead of durable map evidence.',
321
+ fix: 'Add 1-6 real files an agent should inspect first.',
322
+ relatedCommand: mapPromptCommand(area),
323
+ });
324
+ return;
325
+ }
326
+
327
+ let existing = 0;
328
+ for (let index = 0; index < entrypoints.length; index += 1) {
329
+ const entry = entrypoints[index];
330
+ const entryPath = patternFrom(entry);
331
+ if (!entryPath) {
332
+ addFinding(findings, {
333
+ severity: 'critical',
334
+ code: 'ENTRYPOINT_NOT_FOUND',
335
+ areaId: area.id,
336
+ field: field(area.id, `entrypoints[${index}].path`),
337
+ message: 'Entrypoint is missing path.',
338
+ impact: 'find cannot use this entrypoint.',
339
+ fix: 'Add a real file path or remove this entrypoint.',
340
+ });
341
+ continue;
342
+ }
343
+ if (!entry.role) {
344
+ addFinding(findings, {
345
+ severity: 'warning',
346
+ code: 'ENTRYPOINT_ROLE_MISSING',
347
+ areaId: area.id,
348
+ field: field(area.id, `entrypoints[${index}].role`),
349
+ message: `Entrypoint is missing role: ${entryPath}.`,
350
+ impact: 'check has less deterministic evidence-policy signal for changed files.',
351
+ fix: `Set role to one of: ${ROLE_TAXONOMY.join(', ')}.`,
352
+ relatedCommand: mapPromptCommand(area),
353
+ });
354
+ } else if (!isKnownRole(entry.role)) {
355
+ addFinding(findings, {
356
+ severity: 'critical',
357
+ code: 'UNKNOWN_ROLE_VALUE',
358
+ areaId: area.id,
359
+ field: field(area.id, `entrypoints[${index}].role`),
360
+ message: `Entrypoint role is not supported: ${entry.role}.`,
361
+ impact: 'check cannot reliably decide whether this change needs manual verification, focused tests, or review.',
362
+ fix: `Use one of the allowed roles: ${ROLE_TAXONOMY.join(', ')}.`,
363
+ });
364
+ } else {
365
+ const canonical = normalizeRole(entry.role);
366
+ if (!isCanonicalRole(entry.role)) {
367
+ addFinding(findings, {
368
+ severity: 'info',
369
+ code: 'NON_CANONICAL_ROLE_VALUE',
370
+ areaId: area.id,
371
+ field: field(area.id, `entrypoints[${index}].role`),
372
+ message: `Entrypoint role "${entry.role}" is accepted as "${canonical}".`,
373
+ impact: 'Future map drafts should use canonical role names.',
374
+ fix: `Replace "${entry.role}" with "${canonical}" when you next edit this area.`,
375
+ });
376
+ }
377
+ if (canonical === 'unknown' && writableDirect.includes(entryPath)) {
378
+ addFinding(findings, {
379
+ severity: 'warning',
380
+ code: 'ENTRYPOINT_ROLE_UNKNOWN_IN_WRITABLE_DIRECT',
381
+ areaId: area.id,
382
+ field: field(area.id, `entrypoints[${index}].role`),
383
+ message: `Writable direct entrypoint has unknown role: ${entryPath}.`,
384
+ impact: 'check will require conservative review instead of a precise evidence policy.',
385
+ fix: 'Assign a specific role if this file has durable behavior in the area.',
386
+ relatedCommand: mapPromptCommand(area),
387
+ });
388
+ }
389
+ }
390
+ const full = path.join(root, entryPath);
391
+ if (!fs.existsSync(full)) {
392
+ addFinding(findings, {
393
+ severity: 'warning',
394
+ code: 'ENTRYPOINT_NOT_FOUND',
395
+ areaId: area.id,
396
+ field: field(area.id, `entrypoints[${index}].path`),
397
+ message: `Entrypoint does not exist: ${entryPath}.`,
398
+ impact: 'find may route to stale files.',
399
+ fix: 'Update the path or remove the stale entrypoint.',
400
+ relatedCommand: mapPromptCommand(area),
401
+ });
402
+ continue;
403
+ }
404
+ if (fs.statSync(full).isDirectory()) {
405
+ addFinding(findings, {
406
+ severity: 'warning',
407
+ code: 'ENTRYPOINT_IS_DIRECTORY',
408
+ areaId: area.id,
409
+ field: field(area.id, `entrypoints[${index}].path`),
410
+ message: `Entrypoint is a directory: ${entryPath}.`,
411
+ impact: 'entrypoints should point to files an agent can inspect first.',
412
+ fix: 'Replace this with specific files.',
413
+ });
414
+ continue;
415
+ }
416
+ existing += 1;
417
+ }
418
+
419
+ if (existing === 0) {
420
+ addFinding(findings, {
421
+ severity: 'critical',
422
+ code: 'ENTRYPOINTS_ALL_MISSING',
423
+ areaId: area.id,
424
+ field: field(area.id, 'entrypoints'),
425
+ message: 'All entrypoints are missing or unusable.',
426
+ impact: 'find cannot produce a reliable map-backed Context Packet.',
427
+ fix: 'Refresh entrypoint paths from current repo files.',
428
+ relatedCommand: mapPromptCommand(area),
429
+ });
430
+ }
431
+ }
432
+
433
+ function rawScope(area) {
434
+ const scope = area.scope || {};
435
+ return {
436
+ direct: scope.writable_direct || (!area.scope ? area.writable_scope || [] : []),
437
+ conditional: scope.writable_conditional || [],
438
+ broad: scope.writable_broad_fallback || [],
439
+ forbidden: scope.forbidden || area.do_not_touch || [],
440
+ };
441
+ }
442
+
443
+ function validateWritableScope(root, tracked, area, findings) {
444
+ const normalized = normalizeAreaScope(root, area);
445
+ const raw = rawScope(area);
446
+
447
+ if (normalized.writableDirect.length === 0) {
448
+ addFinding(findings, {
449
+ severity: 'critical',
450
+ code: 'MISSING_WRITABLE_DIRECT',
451
+ areaId: area.id,
452
+ field: field(area.id, 'scope.writable_direct'),
453
+ message: 'scope.writable_direct is missing or empty.',
454
+ impact: 'find cannot safely produce a precise edit_context for this area.',
455
+ fix: 'Add 2-8 exact writable files.',
456
+ relatedCommand: mapPromptCommand(area),
457
+ });
458
+ }
459
+
460
+ if (normalized.writableDirect.length === 0 && normalized.writableBroadFallback.length > 0) {
461
+ addFinding(findings, {
462
+ severity: 'critical',
463
+ code: 'ONLY_BROAD_FALLBACK',
464
+ areaId: area.id,
465
+ field: field(area.id, 'scope.writable_broad_fallback'),
466
+ message: 'Area only has broad writable fallback scope.',
467
+ impact: 'find should downgrade this area to discovery_context until direct files are mapped.',
468
+ fix: 'Keep broad patterns as fallback, but add narrow writable_direct files.',
469
+ relatedCommand: mapPromptCommand(area),
470
+ });
471
+ }
472
+
473
+ for (const value of raw.direct) {
474
+ const pattern = patternFrom(value);
475
+ if (!pattern) continue;
476
+ const count = countMatches(tracked, pattern);
477
+ const status = exactPathStatus(root, pattern);
478
+ if (isBroadWritablePattern(pattern, count)) {
479
+ addFinding(findings, {
480
+ severity: 'critical',
481
+ code: 'BROAD_WRITABLE_DIRECT',
482
+ areaId: area.id,
483
+ field: field(area.id, area.scope ? 'scope.writable_direct' : 'writable_scope'),
484
+ message: `Writable direct scope is too broad: ${pattern}.`,
485
+ impact: 'This can make find issue an unsafe edit permit.',
486
+ fix: 'Move broad patterns to writable_broad_fallback with requires_review: true, and add exact writable_direct files.',
487
+ metrics: { matchedGitFiles: count },
488
+ relatedCommand: mapPromptCommand(area),
489
+ });
490
+ } else if (count > 30) {
491
+ addFinding(findings, {
492
+ severity: 'critical',
493
+ code: 'WRITABLE_DIRECT_MATCHES_TOO_MANY',
494
+ areaId: area.id,
495
+ field: field(area.id, 'scope.writable_direct'),
496
+ message: `Writable direct pattern ${pattern} matches ${count} tracked files.`,
497
+ impact: 'Direct writable scope is too large for a safe edit_context.',
498
+ fix: 'Replace this with exact files or smaller patterns.',
499
+ metrics: { matchedGitFiles: count, threshold: 30 },
500
+ });
501
+ } else if (count > 12) {
502
+ addFinding(findings, {
503
+ severity: 'warning',
504
+ code: 'WRITABLE_DIRECT_MATCHES_TOO_MANY',
505
+ areaId: area.id,
506
+ field: field(area.id, 'scope.writable_direct'),
507
+ message: `Writable direct pattern ${pattern} matches ${count} tracked files.`,
508
+ impact: 'Large direct scope reduces the value of check.',
509
+ fix: 'Prefer 2-8 exact writable files.',
510
+ metrics: { matchedGitFiles: count, threshold: 12 },
511
+ });
512
+ }
513
+ if (!pattern.includes('*') && !status.exists) {
514
+ addFinding(findings, {
515
+ severity: 'critical',
516
+ code: 'WRITABLE_DIRECT_NOT_FOUND',
517
+ areaId: area.id,
518
+ field: field(area.id, 'scope.writable_direct'),
519
+ message: `Writable direct path does not exist: ${pattern}.`,
520
+ impact: 'find may authorize edits to stale paths.',
521
+ fix: 'Update or remove this writable_direct path.',
522
+ relatedCommand: mapPromptCommand(area),
523
+ });
524
+ } else if (status.isDirectory) {
525
+ addFinding(findings, {
526
+ severity: 'critical',
527
+ code: 'BROAD_WRITABLE_DIRECT',
528
+ areaId: area.id,
529
+ field: field(area.id, 'scope.writable_direct'),
530
+ message: `Writable direct path is a directory: ${pattern}.`,
531
+ impact: 'Direct writable should usually be exact files.',
532
+ fix: 'Replace this directory with exact writable files.',
533
+ });
534
+ }
535
+ }
536
+
537
+ for (const value of raw.broad) {
538
+ const pattern = patternFrom(value);
539
+ if (!pattern) continue;
540
+ const requiresReview = value && typeof value === 'object' ? value.requires_review === true || value.requiresReview === true : false;
541
+ if (!value.reason) {
542
+ addFinding(findings, {
543
+ severity: 'warning',
544
+ code: 'BROAD_FALLBACK_MISSING_REASON',
545
+ areaId: area.id,
546
+ field: field(area.id, 'scope.writable_broad_fallback'),
547
+ message: `Broad fallback scope is missing reason: ${pattern}.`,
548
+ impact: 'Humans and agents cannot tell when this fallback is appropriate.',
549
+ fix: 'Add a reason explaining when this fallback may be used.',
550
+ });
551
+ }
552
+ if (!requiresReview) {
553
+ addFinding(findings, {
554
+ severity: 'critical',
555
+ code: 'BROAD_FALLBACK_REQUIRES_REVIEW_FALSE',
556
+ areaId: area.id,
557
+ field: field(area.id, 'scope.writable_broad_fallback'),
558
+ message: `Broad fallback should require review: ${pattern}.`,
559
+ impact: 'Broad fallback edits should not be accepted as ordinary clean scope.',
560
+ fix: 'Set requires_review: true.',
561
+ });
562
+ }
563
+ }
564
+
565
+ for (const value of raw.conditional) {
566
+ const pattern = patternFrom(value);
567
+ if (!pattern) continue;
568
+ const requiresReview = value && typeof value === 'object' ? value.requires_review === true || value.requiresReview === true : false;
569
+ if (!value.reason) {
570
+ addFinding(findings, {
571
+ severity: 'warning',
572
+ code: 'CONDITIONAL_SCOPE_MISSING_REASON',
573
+ areaId: area.id,
574
+ field: field(area.id, 'scope.writable_conditional'),
575
+ message: `Conditional writable scope is missing reason: ${pattern}.`,
576
+ impact: 'check may require review without explaining why.',
577
+ fix: 'Add a reason that describes when this scope is valid.',
578
+ });
579
+ }
580
+ if (!requiresReview) {
581
+ addFinding(findings, {
582
+ severity: 'warning',
583
+ code: 'CONDITIONAL_SCOPE_REQUIRES_REVIEW_FALSE',
584
+ areaId: area.id,
585
+ field: field(area.id, 'scope.writable_conditional'),
586
+ message: `Conditional writable scope should require review: ${pattern}.`,
587
+ impact: 'Conditional edits should be visible to the human reviewer.',
588
+ fix: 'Set requires_review: true.',
589
+ });
590
+ }
591
+ }
592
+
593
+ if (normalized.forbidden.length === 0) {
594
+ addFinding(findings, {
595
+ severity: 'warning',
596
+ code: 'FORBIDDEN_EMPTY',
597
+ areaId: area.id,
598
+ field: field(area.id, 'scope.forbidden'),
599
+ message: 'Area forbidden scope is empty.',
600
+ impact: 'The Context Packet lacks explicit do-not-touch boundaries.',
601
+ fix: 'Add local forbidden paths or rely on strong global rules.',
602
+ });
603
+ }
604
+
605
+ const writablePatterns = [
606
+ ...normalized.writableDirect,
607
+ ...normalized.writableConditional.map(patternFrom),
608
+ ...normalized.writableBroadFallback.map(patternFrom),
609
+ ].filter(Boolean);
610
+ for (const writable of writablePatterns) {
611
+ for (const forbidden of normalized.forbidden) {
612
+ const overlap = tracked.filter((file) => matchesPattern(file, writable) && matchesPattern(file, forbidden)).slice(0, 3);
613
+ if (overlap.length > 0) {
614
+ addFinding(findings, {
615
+ severity: 'critical',
616
+ code: 'WRITABLE_FORBIDDEN_CONFLICT',
617
+ areaId: area.id,
618
+ field: field(area.id, 'scope'),
619
+ message: `Writable scope ${writable} overlaps forbidden scope ${forbidden}.`,
620
+ impact: 'find/check may disagree about whether edits are allowed.',
621
+ fix: 'Remove the overlap or move the path into conditional scope with review.',
622
+ metrics: { sample: overlap },
623
+ });
624
+ }
625
+ }
626
+ }
627
+ }
628
+
629
+ function commandScript(command) {
630
+ const text = String(command || '').trim();
631
+ let match = text.match(/\b(?:npm|pnpm|bun)\s+run\s+([A-Za-z0-9:_-]+)/);
632
+ if (match) return match[1];
633
+ match = text.match(/\byarn\s+([A-Za-z0-9:_-]+)/);
634
+ if (match && match[1] !== 'run') return match[1];
635
+ if (/\b--filter\b/.test(text)) return null;
636
+ match = text.match(/\bpnpm\s+([A-Za-z0-9:_-]+)/);
637
+ if (match && !['exec', 'dlx', 'install', 'add'].includes(match[1])) return match[1];
638
+ return null;
639
+ }
640
+
641
+ function profileEvidenceType(profile) {
642
+ return profile.evidence_type || profile.evidenceType || '';
643
+ }
644
+
645
+ function profileCoversRoles(profile) {
646
+ return profile.covers_roles || profile.coversRoles || [];
647
+ }
648
+
649
+ function validateValidation(area, profilesById, scripts, findings) {
650
+ const profileIds = (((area.validation || {}).profiles) || []).filter(Boolean);
651
+ if (profileIds.length === 0) {
652
+ addFinding(findings, {
653
+ severity: 'warning',
654
+ code: 'MISSING_VALIDATION',
655
+ areaId: area.id,
656
+ field: field(area.id, 'validation.profiles'),
657
+ message: 'Area has no validation profiles.',
658
+ impact: 'find may produce a Context Packet without a validation plan.',
659
+ fix: 'Add a validation profile or project default validation command.',
660
+ });
661
+ return;
662
+ }
663
+ for (const profileId of profileIds) {
664
+ const profile = profilesById.get(profileId);
665
+ if (!profile) {
666
+ addFinding(findings, {
667
+ severity: 'critical',
668
+ code: 'VALIDATION_PROFILE_NOT_FOUND',
669
+ areaId: area.id,
670
+ file: '.hive/map/validation.yaml',
671
+ field: field(area.id, 'validation.profiles'),
672
+ message: `Validation profile not found: ${profileId}.`,
673
+ impact: 'hive validate cannot run the profile referenced by this area.',
674
+ fix: 'Add the profile to validation.yaml or remove the reference.',
675
+ });
676
+ continue;
677
+ }
678
+ const hasManual = profile.type === 'manual' || (profile.instructions || []).length > 0;
679
+ const evidenceType = profileEvidenceType(profile);
680
+ const coversRoles = profileCoversRoles(profile);
681
+ if (!profile.command && !hasManual) {
682
+ addFinding(findings, {
683
+ severity: 'critical',
684
+ code: 'VALIDATION_PROFILE_EMPTY',
685
+ areaId: area.id,
686
+ file: '.hive/map/validation.yaml',
687
+ field: `profiles[${profileId}]`,
688
+ message: `Validation profile ${profileId} has no command or manual instructions.`,
689
+ impact: 'hive validate cannot collect useful evidence for this profile.',
690
+ fix: 'Add a command or mark it as manual with instructions.',
691
+ });
692
+ }
693
+ if (!evidenceType) {
694
+ addFinding(findings, {
695
+ severity: 'warning',
696
+ code: 'VALIDATION_PROFILE_MISSING_EVIDENCE_TYPE',
697
+ areaId: area.id,
698
+ file: '.hive/map/validation.yaml',
699
+ field: `profiles[${profileId}].evidence_type`,
700
+ message: `Validation profile ${profileId} is missing evidence_type.`,
701
+ impact: 'check has less signal about whether this profile proves build, test, manual, or review evidence.',
702
+ fix: 'Add evidence_type such as build, typecheck, existing_tests, focused_test, manual_visual, or manual_cli.',
703
+ });
704
+ }
705
+ if (!Array.isArray(coversRoles) || coversRoles.length === 0) {
706
+ addFinding(findings, {
707
+ severity: 'warning',
708
+ code: 'VALIDATION_PROFILE_MISSING_COVERS_ROLES',
709
+ areaId: area.id,
710
+ file: '.hive/map/validation.yaml',
711
+ field: `profiles[${profileId}].covers_roles`,
712
+ message: `Validation profile ${profileId} is missing covers_roles.`,
713
+ impact: 'check cannot tell which changed roles this validation meaningfully covers.',
714
+ fix: `Add covers_roles using allowed roles: ${ROLE_TAXONOMY.join(', ')}.`,
715
+ });
716
+ } else {
717
+ for (const role of coversRoles) {
718
+ if (!isKnownRole(role)) {
719
+ addFinding(findings, {
720
+ severity: 'critical',
721
+ code: 'UNKNOWN_ROLE_VALUE',
722
+ areaId: area.id,
723
+ file: '.hive/map/validation.yaml',
724
+ field: `profiles[${profileId}].covers_roles`,
725
+ message: `Validation profile ${profileId} covers unsupported role: ${role}.`,
726
+ impact: 'Evidence coverage cannot be evaluated deterministically.',
727
+ fix: `Use one of the allowed roles: ${ROLE_TAXONOMY.join(', ')}.`,
728
+ });
729
+ }
730
+ }
731
+ }
732
+ const script = commandScript(profile.command);
733
+ if (script && Object.keys(scripts).length > 0 && !scripts[script]) {
734
+ addFinding(findings, {
735
+ severity: 'warning',
736
+ code: 'VALIDATION_COMMAND_SCRIPT_NOT_FOUND',
737
+ areaId: area.id,
738
+ file: '.hive/map/validation.yaml',
739
+ field: `profiles[${profileId}].command`,
740
+ message: `Validation command references package script "${script}", but root package.json does not define it.`,
741
+ impact: 'hive validate may fail or require a custom command.',
742
+ fix: 'Update validation.yaml or add the missing package script.',
743
+ });
744
+ }
745
+ }
746
+ }
747
+
748
+ function areaRoles(area) {
749
+ return (area.entrypoints || [])
750
+ .map((entry) => normalizeRole(entry.role))
751
+ .filter(Boolean);
752
+ }
753
+
754
+ function validateVerification(area, profilesById, findings) {
755
+ const verification = area.verification || {};
756
+ const roles = areaRoles(area);
757
+ const hasUiRole = roles.some((role) => isUiManualRole(role));
758
+ const internalRoles = roles.filter((role) => isInternalBehaviorRole(role));
759
+ const focusedRecommended = verification.focused_test_recommended_for_roles || verification.focusedTestRecommendedForRoles || [];
760
+ const focusedRequired = verification.focused_test_required_for_roles || verification.focusedTestRequiredForRoles || [];
761
+ const focusedPolicy = [...focusedRecommended, ...focusedRequired].map((role) => normalizeRole(role)).filter(Boolean);
762
+ const validationProfiles = (((area.validation || {}).profiles) || []).filter(Boolean);
763
+ const manualProfiles = verification.manual_profiles || verification.manualProfiles || [];
764
+
765
+ for (const role of [...focusedRecommended, ...focusedRequired]) {
766
+ if (!isKnownRole(role)) {
767
+ addFinding(findings, {
768
+ severity: 'critical',
769
+ code: 'UNKNOWN_ROLE_VALUE',
770
+ areaId: area.id,
771
+ field: field(area.id, 'verification'),
772
+ message: `Verification policy references unsupported role: ${role}.`,
773
+ impact: 'check cannot reliably apply the evidence policy.',
774
+ fix: `Use one of the allowed roles: ${ROLE_TAXONOMY.join(', ')}.`,
775
+ });
776
+ }
777
+ }
778
+
779
+ if (hasUiRole && verification.direct_manual_allowed !== true && manualProfiles.length === 0) {
780
+ addFinding(findings, {
781
+ severity: 'warning',
782
+ code: 'UI_ROLE_WITHOUT_MANUAL_VERIFICATION',
783
+ areaId: area.id,
784
+ field: field(area.id, 'verification.manual_profiles'),
785
+ message: 'UI-facing area has no manual verification fallback.',
786
+ impact: 'check may not clearly ask for human-visible evidence after UI changes.',
787
+ fix: 'Set verification.direct_manual_allowed: true and add a generic manual profile such as manual-verification.',
788
+ relatedCommand: mapPromptCommand(area),
789
+ });
790
+ }
791
+
792
+ if (internalRoles.length > 0 && focusedPolicy.length === 0 && verification.review_with_reason_allowed !== true && validationProfiles.length === 0) {
793
+ addFinding(findings, {
794
+ severity: 'warning',
795
+ code: 'INTERNAL_ROLE_WITHOUT_VERIFICATION_POLICY',
796
+ areaId: area.id,
797
+ field: field(area.id, 'verification'),
798
+ message: `Internal roles lack focused-test or review policy: ${[...new Set(internalRoles)].join(', ')}.`,
799
+ impact: 'check may not know whether to ask for focused evidence or a review reason.',
800
+ fix: 'Add focused_test_recommended_for_roles or review_with_reason_allowed for this area.',
801
+ relatedCommand: mapPromptCommand(area),
802
+ });
803
+ }
804
+
805
+ for (const profileId of manualProfiles) {
806
+ if (BUILT_IN_MANUAL_PROFILES.has(profileId)) continue;
807
+ const profile = profilesById.get(profileId);
808
+ if (!profile) {
809
+ addFinding(findings, {
810
+ severity: 'warning',
811
+ code: 'MANUAL_PROFILE_NOT_FOUND',
812
+ areaId: area.id,
813
+ file: '.hive/map/validation.yaml',
814
+ field: field(area.id, 'verification.manual_profiles'),
815
+ message: `Manual profile not found: ${profileId}.`,
816
+ impact: 'hive validate --manual can still record evidence, but agents lack durable instructions for that profile.',
817
+ fix: 'Add a manual profile to validation.yaml or use a configured generic manual profile.',
818
+ });
819
+ }
820
+ }
821
+
822
+ const broadFallback = ((area.scope || {}).writable_broad_fallback || (area.scope || {}).writableBroadFallback || []);
823
+ if (broadFallback.length > 0 && verification.review_with_reason_allowed !== true) {
824
+ addFinding(findings, {
825
+ severity: 'warning',
826
+ code: 'BROAD_SCOPE_WITHOUT_REVIEW_POLICY',
827
+ areaId: area.id,
828
+ field: field(area.id, 'verification.review_with_reason_allowed'),
829
+ message: 'Area has broad fallback scope but no explicit review-with-reason policy.',
830
+ impact: 'Broad fallback edits should be routed through explicit human review.',
831
+ fix: 'Set verification.review_with_reason_allowed: true unless broad fallback edits should be blocked.',
832
+ relatedCommand: mapPromptCommand(area),
833
+ });
834
+ }
835
+ }
836
+
837
+ function validateRisk(area, findings) {
838
+ if (!area.description) {
839
+ addFinding(findings, {
840
+ severity: 'info',
841
+ code: 'MISSING_DESCRIPTION',
842
+ areaId: area.id,
843
+ field: field(area.id, 'description'),
844
+ message: 'Area description is empty.',
845
+ impact: 'Humans have less context when reviewing map health.',
846
+ fix: 'Add a one-sentence description of this work area.',
847
+ });
848
+ }
849
+ const tags = area.risk && area.risk.tags ? area.risk.tags : [];
850
+ if (tags.length === 0) {
851
+ addFinding(findings, {
852
+ severity: 'info',
853
+ code: 'MISSING_RISK_TAGS',
854
+ areaId: area.id,
855
+ field: field(area.id, 'risk.tags'),
856
+ message: 'Area risk tags are empty.',
857
+ impact: 'Risk review signals may be less clear.',
858
+ fix: 'Add simple tags such as ui, api, auth, payment, data, or ci.',
859
+ });
860
+ }
861
+ }
862
+
863
+ function readinessForArea(root, areaId, findings, area) {
864
+ const areaFindings = findings.filter((finding) => finding.areaId === areaId);
865
+ if (areaFindings.some((finding) => finding.severity === 'critical')) return 'needs_map';
866
+ const scope = area ? normalizeAreaScope(root, area) : null;
867
+ if (!scope || scope.writableDirect.length === 0) return 'discovery_only';
868
+ if (areaFindings.some((finding) => finding.code === 'MISSING_VALIDATION')) return 'discovery_only';
869
+ return 'edit_ready';
870
+ }
871
+
872
+ function summarize(result, areas, findings) {
873
+ const counts = {
874
+ critical: findings.filter((finding) => finding.severity === 'critical').length,
875
+ warnings: findings.filter((finding) => finding.severity === 'warning').length,
876
+ info: findings.filter((finding) => finding.severity === 'info').length,
877
+ };
878
+ let status = 'healthy';
879
+ if (result.invalidMap) status = 'invalid_map';
880
+ else if (counts.critical > 0) status = 'unsafe_for_edit_context';
881
+ else if (counts.warnings > 0) status = 'needs_attention';
882
+ return {
883
+ status,
884
+ summary: {
885
+ areasChecked: areas.length,
886
+ critical: counts.critical,
887
+ warnings: counts.warnings,
888
+ info: counts.info,
889
+ },
890
+ };
891
+ }
892
+
893
+ function evaluateMapHealth(root, options = {}) {
894
+ const initialFindings = [];
895
+ const areasResult = readYamlHealth(root, 'areas.yaml', { version: 1, areas: [] }, initialFindings);
896
+ const validationResult = readYamlHealth(root, 'validation.yaml', { version: 1, profiles: [] }, initialFindings);
897
+ const invalidMap = !areasResult.valid || !validationResult.valid;
898
+ const findings = [...initialFindings];
899
+ validateMapFilesNotIgnored(root, findings);
900
+ const areasDoc = areasResult.doc || {};
901
+ const validationDoc = validationResult.doc || {};
902
+
903
+ if (!Array.isArray(areasDoc.areas)) {
904
+ addFinding(findings, {
905
+ severity: 'critical',
906
+ code: 'INVALID_SCHEMA',
907
+ field: 'areas',
908
+ message: '.hive/map/areas.yaml must contain an areas array.',
909
+ impact: 'Hive Lite cannot evaluate or use project areas.',
910
+ fix: 'Change areas.yaml to use `areas: []` or a list of area objects.',
911
+ });
912
+ }
913
+
914
+ const allAreas = Array.isArray(areasDoc.areas) ? areasDoc.areas : [];
915
+ const selectedAreas = options.area ? allAreas.filter((area) => area.id === options.area) : allAreas;
916
+ if (options.area && selectedAreas.length === 0) {
917
+ addFinding(findings, {
918
+ severity: 'critical',
919
+ code: 'AREA_NOT_FOUND',
920
+ areaId: options.area,
921
+ field: `areas[${options.area}]`,
922
+ message: `Area not found: ${options.area}.`,
923
+ impact: 'The requested area cannot be checked.',
924
+ fix: 'Run hive-lite map health without --area to see configured area ids.',
925
+ });
926
+ }
927
+
928
+ const profiles = Array.isArray(validationDoc.profiles) ? validationDoc.profiles : [];
929
+ const profilesById = new Map(profiles.map((profile) => [profile.id, profile]));
930
+ const scripts = packageScripts(root);
931
+ const tracked = trackedFiles(root);
932
+ const areaIds = new Set();
933
+ const aliasOwners = new Map();
934
+ for (const area of allAreas) {
935
+ for (const alias of area.aliases || []) {
936
+ const key = String(alias).toLowerCase();
937
+ const owners = aliasOwners.get(key) || [];
938
+ owners.push(area.id || '(missing id)');
939
+ aliasOwners.set(key, owners);
940
+ }
941
+ }
942
+
943
+ for (const area of selectedAreas) {
944
+ validateAreaId(area, areaIds, findings);
945
+ if (!area.id) continue;
946
+ validateFindTerms(area, aliasOwners, findings);
947
+ validateEntryPoints(root, area, findings);
948
+ validateWritableScope(root, tracked, area, findings);
949
+ validateValidation(area, profilesById, scripts, findings);
950
+ validateVerification(area, profilesById, findings);
951
+ validateRisk(area, findings);
952
+ }
953
+
954
+ const base = { invalidMap, findings };
955
+ const summarized = summarize(base, selectedAreas, findings);
956
+ const areas = selectedAreas.map((area) => {
957
+ const areaFindings = findings.filter((finding) => finding.areaId === area.id);
958
+ const scope = normalizeAreaScope(root, area);
959
+ return {
960
+ id: area.id,
961
+ readiness: readinessForArea(root, area.id, findings, area),
962
+ critical: areaFindings.filter((finding) => finding.severity === 'critical').length,
963
+ warnings: areaFindings.filter((finding) => finding.severity === 'warning').length,
964
+ info: areaFindings.filter((finding) => finding.severity === 'info').length,
965
+ scope: {
966
+ directWritableFiles: scope.writableDirect.length,
967
+ broadFallbackFiles: scope.writableBroadFallback.length,
968
+ conditionalFiles: scope.writableConditional.length,
969
+ hasForbidden: scope.forbidden.length > 0,
970
+ },
971
+ evidencePolicy: {
972
+ roles: [...new Set(areaRoles(area))],
973
+ hasVerification: Boolean(area.verification),
974
+ directManualAllowed: Boolean(area.verification && area.verification.direct_manual_allowed === true),
975
+ reviewWithReasonAllowed: Boolean(area.verification && area.verification.review_with_reason_allowed === true),
976
+ },
977
+ };
978
+ });
979
+
980
+ const suggestions = [];
981
+ for (const area of selectedAreas) {
982
+ const areaFindings = findings.filter((finding) => finding.areaId === area.id && finding.severity !== 'info');
983
+ if (areaFindings.length > 0) {
984
+ suggestions.push({
985
+ kind: 'run_map_prompt',
986
+ areaId: area.id,
987
+ command: mapPromptCommand(area),
988
+ });
989
+ }
990
+ }
991
+
992
+ return {
993
+ version: 1,
994
+ command: 'map health',
995
+ generatedAt: new Date().toISOString(),
996
+ root,
997
+ status: summarized.status,
998
+ summary: summarized.summary,
999
+ options: {
1000
+ area: options.area || null,
1001
+ },
1002
+ findings,
1003
+ areas,
1004
+ suggestions,
1005
+ exitCode: summarized.status === 'invalid_map' ? 2 : summarized.summary.critical > 0 ? 1 : 0,
1006
+ mapDir: path.join(hiveDir(root), 'map'),
1007
+ };
1008
+ }
1009
+
1010
+ module.exports = {
1011
+ evaluateMapHealth,
1012
+ };