@hone-ai/cli 1.4.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 (60) hide show
  1. package/bin/hone.js +2 -0
  2. package/hone-cli.js +4006 -0
  3. package/lib/README.md +119 -0
  4. package/lib/adversarial-negative-lint.js +149 -0
  5. package/lib/audit.js +156 -0
  6. package/lib/auto-detect.js +213 -0
  7. package/lib/autofix-guardrails.js +124 -0
  8. package/lib/branch-protection.js +256 -0
  9. package/lib/ci-classifier.js +150 -0
  10. package/lib/ci-failures.js +173 -0
  11. package/lib/claude-md-tokens.js +71 -0
  12. package/lib/compliance-check.js +62 -0
  13. package/lib/config-augment.js +133 -0
  14. package/lib/config-update.js +70 -0
  15. package/lib/dependency-audit.js +108 -0
  16. package/lib/derive-domain.js +185 -0
  17. package/lib/doc-registry.js +63 -0
  18. package/lib/doctor-admin-merge.js +185 -0
  19. package/lib/doctor-bind-default.js +118 -0
  20. package/lib/doctor-docs.js +205 -0
  21. package/lib/doctor-placeholders.js +144 -0
  22. package/lib/doctor-skill-staleness.js +122 -0
  23. package/lib/domain-skill-template.md +114 -0
  24. package/lib/editor-detect.js +169 -0
  25. package/lib/fast-track-ratify.js +133 -0
  26. package/lib/git-helpers.js +109 -0
  27. package/lib/hook-templates/pre-commit.sh +54 -0
  28. package/lib/hook-templates/pre-push.sh +72 -0
  29. package/lib/install-hooks.js +205 -0
  30. package/lib/knowledge-graph.js +188 -0
  31. package/lib/learnings-audit.js +254 -0
  32. package/lib/learnings-parse.js +331 -0
  33. package/lib/learnings-sync.js +75 -0
  34. package/lib/mcp-detect.js +154 -0
  35. package/lib/metrics-collect.js +214 -0
  36. package/lib/overlay-merge.js +267 -0
  37. package/lib/performance-analyzer.js +142 -0
  38. package/lib/pipeline-config.js +83 -0
  39. package/lib/pipeline-status.js +207 -0
  40. package/lib/pipeline-validate.js +322 -0
  41. package/lib/platform-detect.js +86 -0
  42. package/lib/platform-discover.js +334 -0
  43. package/lib/publish-learning.js +160 -0
  44. package/lib/python-install.js +84 -0
  45. package/lib/refresh-check.js +67 -0
  46. package/lib/refresh-knowledge.js +360 -0
  47. package/lib/rule-resolver.js +146 -0
  48. package/lib/security-scanner.js +168 -0
  49. package/lib/setup-grounding.js +138 -0
  50. package/lib/skill-assertions.js +276 -0
  51. package/lib/skill-audit-render.js +158 -0
  52. package/lib/skill-audit.js +391 -0
  53. package/lib/stack-detect.js +170 -0
  54. package/lib/stack-paths.js +285 -0
  55. package/lib/story-classifier-extract.js +203 -0
  56. package/lib/story-classifier.js +282 -0
  57. package/lib/sync-overwrite.js +47 -0
  58. package/lib/synthetic-pipeline.js +299 -0
  59. package/lib/validate-metadata.js +175 -0
  60. package/package.json +41 -0
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+ /**
3
+ * install-hooks.js — HC-001 (first git hook the framework ships).
4
+ *
5
+ * Pure helper that installs / uninstalls the framework's pre-commit and
6
+ * pre-push hooks into a target repo's `.git/hooks/`. Implements OptionsFlow
7
+ * E15-A-L1 (deferred during SC-009 absorption).
8
+ *
9
+ * Architecture decisions (per HC-001 step-1-plan.md):
10
+ * - Hook content as STATIC `.sh` files in `cli/lib/hook-templates/`
11
+ * - Default mode: NATIVE (`.git/hooks/`); detect husky and skip if present
12
+ * - Marker: COMMENT line in the hook (`# managed-by: hone`)
13
+ * - Idempotent: re-install over managed hook is no-op; never overwrite unmanaged
14
+ *
15
+ * Failure modes (all return structured result; never throw):
16
+ * - target .git/hooks/ missing → 'no-git-dir' finding
17
+ * - existing unmanaged hook (no --force) → 'unmanaged-existing-hook' skipped entry
18
+ * - husky detected (no --mode native) → 'husky-detected' skipped entry
19
+ * - template file missing → 'template-missing' finding
20
+ *
21
+ * Pure-helper-with-injected-IO style (Category B per cli/lib/README.md).
22
+ */
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+
27
+ const HOOK_NAMES = ['pre-commit', 'pre-push'];
28
+ const MANAGED_MARKER = 'managed-by: hone';
29
+ const TEMPLATES_DIR = path.join(__dirname, 'hook-templates');
30
+
31
+ /**
32
+ * Install framework hooks into a target repo.
33
+ *
34
+ * @param {object} args
35
+ * @param {string} args.repoRoot
36
+ * @param {string} [args.mode] 'auto' (default) | 'native' | 'skip-husky'
37
+ * @param {boolean} [args.force] overwrite unmanaged hooks
38
+ * @returns {{
39
+ * findings: Array,
40
+ * summary: { total, errors, warnings, info },
41
+ * installed: string[],
42
+ * skipped: Array<{hook, reason, userAction?}>,
43
+ * }}
44
+ */
45
+ function installHooks(args) {
46
+ const { repoRoot, mode = 'auto', force = false } = args || {};
47
+ const findings = [];
48
+ const installed = [];
49
+ const skipped = [];
50
+
51
+ if (!repoRoot || typeof repoRoot !== 'string') {
52
+ return wrap([{ severity: 'ERROR', code: 'invalid-args', message: 'repoRoot is required' }], installed, skipped);
53
+ }
54
+
55
+ const hooksDir = path.join(repoRoot, '.git/hooks');
56
+ if (!fs.existsSync(hooksDir)) {
57
+ return wrap([{ severity: 'ERROR', code: 'no-git-dir', message: `not a git repo (no .git/hooks at ${repoRoot})` }],
58
+ installed, skipped);
59
+ }
60
+
61
+ // Husky detection (when mode = 'auto')
62
+ const huskyPresent = fs.existsSync(path.join(repoRoot, '.husky'));
63
+ if (huskyPresent && mode === 'auto') {
64
+ for (const hook of HOOK_NAMES) {
65
+ skipped.push({
66
+ hook,
67
+ reason: 'husky-detected — adopter uses husky; pass --mode native to override',
68
+ });
69
+ }
70
+ return wrap(findings, installed, skipped);
71
+ }
72
+
73
+ for (const hook of HOOK_NAMES) {
74
+ const target = path.join(hooksDir, hook);
75
+ const templatePath = path.join(TEMPLATES_DIR, `${hook}.sh`);
76
+
77
+ if (!fs.existsSync(templatePath)) {
78
+ findings.push({
79
+ severity: 'ERROR',
80
+ code: 'template-missing',
81
+ message: `template not found: ${templatePath}`,
82
+ });
83
+ continue;
84
+ }
85
+
86
+ // Check existing hook
87
+ if (fs.existsSync(target)) {
88
+ const existing = fs.readFileSync(target, 'utf8');
89
+ const isManaged = existing.includes(MANAGED_MARKER);
90
+ if (isManaged && !force) {
91
+ skipped.push({
92
+ hook,
93
+ reason: 'already-managed — re-install is a no-op; pass --force to overwrite',
94
+ });
95
+ continue;
96
+ }
97
+ if (!isManaged && !force) {
98
+ skipped.push({
99
+ hook,
100
+ reason: 'unmanaged-existing-hook — refusing to overwrite adopter customization',
101
+ userAction: 'pass --force to overwrite, or remove the hook manually first',
102
+ });
103
+ continue;
104
+ }
105
+ }
106
+
107
+ // Write hook
108
+ try {
109
+ const content = fs.readFileSync(templatePath, 'utf8');
110
+ fs.writeFileSync(target, content, { mode: 0o755 });
111
+ // Ensure executable bit (writeFileSync mode option may not apply on all platforms)
112
+ try { fs.chmodSync(target, 0o755); } catch {}
113
+ installed.push(hook);
114
+ } catch (e) {
115
+ findings.push({
116
+ severity: 'ERROR',
117
+ code: 'write-failed',
118
+ hook,
119
+ message: `failed to write ${target}: ${e.message}`,
120
+ });
121
+ }
122
+ }
123
+
124
+ return wrap(findings, installed, skipped);
125
+ }
126
+
127
+ /**
128
+ * Uninstall framework hooks (only removes hooks carrying the managed-by marker).
129
+ *
130
+ * @param {object} args
131
+ * @param {string} args.repoRoot
132
+ * @returns {{ findings, summary, removed: string[], skipped: Array }}
133
+ */
134
+ function uninstallHooks(args) {
135
+ const { repoRoot } = args || {};
136
+ const findings = [];
137
+ const removed = [];
138
+ const skipped = [];
139
+
140
+ if (!repoRoot || typeof repoRoot !== 'string') {
141
+ return wrapUninstall([{ severity: 'ERROR', code: 'invalid-args', message: 'repoRoot is required' }],
142
+ removed, skipped);
143
+ }
144
+
145
+ const hooksDir = path.join(repoRoot, '.git/hooks');
146
+ if (!fs.existsSync(hooksDir)) {
147
+ return wrapUninstall([{ severity: 'ERROR', code: 'no-git-dir', message: `not a git repo (no .git/hooks at ${repoRoot})` }],
148
+ removed, skipped);
149
+ }
150
+
151
+ for (const hook of HOOK_NAMES) {
152
+ const target = path.join(hooksDir, hook);
153
+ if (!fs.existsSync(target)) continue;
154
+
155
+ const existing = fs.readFileSync(target, 'utf8');
156
+ if (!existing.includes(MANAGED_MARKER)) {
157
+ skipped.push({
158
+ hook,
159
+ reason: 'unmanaged — not removing (was not installed by hone)',
160
+ });
161
+ continue;
162
+ }
163
+
164
+ try {
165
+ fs.unlinkSync(target);
166
+ removed.push(hook);
167
+ } catch (e) {
168
+ findings.push({
169
+ severity: 'ERROR',
170
+ code: 'unlink-failed',
171
+ hook,
172
+ message: `failed to remove ${target}: ${e.message}`,
173
+ });
174
+ }
175
+ }
176
+
177
+ return wrapUninstall(findings, removed, skipped);
178
+ }
179
+
180
+ function wrap(findings, installed, skipped) {
181
+ const summary = {
182
+ total: findings.length,
183
+ errors: findings.filter(f => f.severity === 'ERROR').length,
184
+ warnings: findings.filter(f => f.severity === 'WARN').length,
185
+ info: findings.filter(f => f.severity === 'INFO').length,
186
+ };
187
+ return { findings, summary, installed, skipped };
188
+ }
189
+
190
+ function wrapUninstall(findings, removed, skipped) {
191
+ const summary = {
192
+ total: findings.length,
193
+ errors: findings.filter(f => f.severity === 'ERROR').length,
194
+ warnings: findings.filter(f => f.severity === 'WARN').length,
195
+ info: findings.filter(f => f.severity === 'INFO').length,
196
+ };
197
+ return { findings, summary, removed, skipped };
198
+ }
199
+
200
+ module.exports = {
201
+ installHooks,
202
+ uninstallHooks,
203
+ HOOK_NAMES,
204
+ MANAGED_MARKER,
205
+ };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ /**
3
+ * knowledge-graph.js — HC-020 in-memory knowledge graph from existing
4
+ * YAML cross-references.
5
+ *
6
+ * Zero schema changes. Zero dependencies. Reads existing fields:
7
+ * referenced_skill, evidence, references (item-level)
8
+ *
9
+ * Graph is a Map-based adjacency list:
10
+ * nodes: Map<id, { id, type, name, ...attrs }>
11
+ * outEdges: Map<fromId, [{ to, type }]>
12
+ * inEdges: Map<toId, [{ from, type }]>
13
+ *
14
+ * Node types (4): learning, skill, file, story
15
+ * Edge types (4): REFERENCES_SKILL, HAS_EVIDENCE, HAS_LEARNING, REFERENCES
16
+ *
17
+ * Architecture: docs/architecture/knowledge-graph-phases.md (Phase 1)
18
+ */
19
+
20
+ class KnowledgeGraph {
21
+ constructor() {
22
+ this.nodes = new Map();
23
+ this.outEdges = new Map();
24
+ this.inEdges = new Map();
25
+ }
26
+
27
+ addNode(id, attrs) {
28
+ if (!this.nodes.has(id)) {
29
+ this.nodes.set(id, { id, ...attrs });
30
+ }
31
+ }
32
+
33
+ addEdge(from, to, type) {
34
+ if (!this.outEdges.has(from)) this.outEdges.set(from, []);
35
+ this.outEdges.get(from).push({ to, type });
36
+ if (!this.inEdges.has(to)) this.inEdges.set(to, []);
37
+ this.inEdges.get(to).push({ from, type });
38
+ }
39
+
40
+ // ── Query Functions (HC-020b) ──────────────────────────────
41
+
42
+ /** Which learnings reference this skill? */
43
+ learningsForSkill(skillName) {
44
+ const skillId = `skill:${skillName}`;
45
+ return (this.inEdges.get(skillId) || [])
46
+ .filter(e => e.type === 'REFERENCES_SKILL')
47
+ .map(e => this.nodes.get(e.from))
48
+ .filter(Boolean);
49
+ }
50
+
51
+ /** Which code files are evidence for this learning? */
52
+ filesForLearning(learningId) {
53
+ return (this.outEdges.get(learningId) || [])
54
+ .filter(e => e.type === 'HAS_EVIDENCE')
55
+ .map(e => this.nodes.get(e.to))
56
+ .filter(Boolean);
57
+ }
58
+
59
+ /** Which learnings mention this file as evidence? */
60
+ learningsForFile(filePath) {
61
+ const fileId = `file:${filePath}`;
62
+ return (this.inEdges.get(fileId) || [])
63
+ .filter(e => e.type === 'HAS_EVIDENCE')
64
+ .map(e => this.nodes.get(e.from))
65
+ .filter(Boolean);
66
+ }
67
+
68
+ /** HC-019d-fix-L2: Which OTHER files are connected via shared learnings? */
69
+ siblingFiles(filePath) {
70
+ const fileId = `file:${filePath}`;
71
+ const learnings = (this.inEdges.get(fileId) || [])
72
+ .filter(e => e.type === 'HAS_EVIDENCE')
73
+ .map(e => e.from);
74
+ const siblings = new Set();
75
+ for (const lid of learnings) {
76
+ for (const edge of (this.outEdges.get(lid) || [])) {
77
+ if (edge.type === 'HAS_EVIDENCE' && edge.to !== fileId) {
78
+ siblings.add(edge.to);
79
+ }
80
+ }
81
+ }
82
+ return [...siblings].map(s => this.nodes.get(s)).filter(Boolean);
83
+ }
84
+
85
+ /** Which skills does this story touch? (via story→learning→skill) */
86
+ skillsForStory(storyId) {
87
+ const storyNode = `story:${storyId}`;
88
+ const learnings = (this.outEdges.get(storyNode) || [])
89
+ .filter(e => e.type === 'HAS_LEARNING')
90
+ .map(e => e.to);
91
+ const skills = new Set();
92
+ for (const lid of learnings) {
93
+ for (const edge of (this.outEdges.get(lid) || [])) {
94
+ if (edge.type === 'REFERENCES_SKILL') skills.add(edge.to);
95
+ }
96
+ }
97
+ return [...skills].map(s => this.nodes.get(s)).filter(Boolean);
98
+ }
99
+
100
+ // ── Metrics ────────────────────────────────────────────────
101
+
102
+ get nodeCount() { return this.nodes.size; }
103
+ get edgeCount() {
104
+ let count = 0;
105
+ for (const edges of this.outEdges.values()) count += edges.length;
106
+ return count;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Build graph from existing YAML data. Zero schema changes.
112
+ *
113
+ * @param {object} opts
114
+ * @param {object[]} opts.learningFiles — parsed .github/learnings/*.yml
115
+ * @returns {KnowledgeGraph}
116
+ */
117
+ function buildKnowledgeGraph({ learningFiles = [] }) {
118
+ const graph = new KnowledgeGraph();
119
+
120
+ for (const file of learningFiles) {
121
+ if (!file || typeof file !== 'object') continue;
122
+
123
+ const storyId = file.story_id;
124
+ if (storyId) {
125
+ graph.addNode(`story:${storyId}`, { type: 'story', name: storyId });
126
+ }
127
+
128
+ // Support Schema C (learnings array) and Schema B (enterprise_candidates)
129
+ const items = file.learnings || file.enterprise_candidates || [];
130
+
131
+ for (const item of items) {
132
+ if (!item || !item.id) continue;
133
+ const learningId = `learning:${item.id}`;
134
+
135
+ graph.addNode(learningId, {
136
+ type: 'learning',
137
+ name: item.title || item.id,
138
+ learningType: item.type || null,
139
+ referencedSection: item.referenced_section || null,
140
+ enterpriseSummary: item.enterprise_summary || null,
141
+ category: item.category || null,
142
+ status: file.status || 'pending',
143
+ });
144
+
145
+ // Story → Learning
146
+ if (storyId) {
147
+ graph.addEdge(`story:${storyId}`, learningId, 'HAS_LEARNING');
148
+ }
149
+
150
+ // Learning → Skill
151
+ if (item.referenced_skill) {
152
+ const skillId = `skill:${item.referenced_skill}`;
153
+ graph.addNode(skillId, { type: 'skill', name: item.referenced_skill });
154
+ graph.addEdge(learningId, skillId, 'REFERENCES_SKILL');
155
+ }
156
+
157
+ // Learning → Evidence files
158
+ for (const evidence of (item.evidence || [])) {
159
+ const fileId = `file:${evidence}`;
160
+ graph.addNode(fileId, { type: 'file', name: evidence });
161
+ graph.addEdge(learningId, fileId, 'HAS_EVIDENCE');
162
+ }
163
+
164
+ // Learning → References (item-level, not file-level)
165
+ // v1.1 fix: reads item.references, not file.references
166
+ for (const ref of (item.references || [])) {
167
+ if (typeof ref !== 'string') continue;
168
+ if (ref.includes('/learnings/') && ref.endsWith('.yml')) {
169
+ const match = ref.match(/learnings\/([^.]+)\.yml/);
170
+ if (match) {
171
+ const targetStory = `story:${match[1]}`;
172
+ graph.addNode(targetStory, { type: 'story', name: match[1] });
173
+ graph.addEdge(learningId, targetStory, 'REFERENCES');
174
+ }
175
+ } else {
176
+ // Code file reference (additional evidence)
177
+ const fileId = `file:${ref}`;
178
+ graph.addNode(fileId, { type: 'file', name: ref });
179
+ graph.addEdge(learningId, fileId, 'HAS_EVIDENCE');
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ return graph;
186
+ }
187
+
188
+ module.exports = { KnowledgeGraph, buildKnowledgeGraph };
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+ /**
3
+ * learnings-audit.js — LC-003 (#58 G2 partial, story 3/3): pure helper for
4
+ * the `hone audit-learnings` retrospective CLI.
5
+ *
6
+ * Read-only retrospective: walks the captured learnings corpus, classifies
7
+ * each entry by freshness/incorporation/contradiction status, and reports
8
+ * which learnings still need attention vs. which are settled.
9
+ *
10
+ * Pure-helper-with-injected-I/O: the caller owns filesystem + git access
11
+ * and injects:
12
+ * - learningsFiles: array of {fileLabel, content} pairs
13
+ * - skillNames: array of known skills
14
+ * - getSkillLastModifiedDays(name): number|null — days since the skill
15
+ * file was last touched (caller queries `git log -1 --format=%ct`)
16
+ * - today: 'YYYY-MM-DD' (caller injects for testability; defaults to
17
+ * new Date().toISOString().slice(0,10))
18
+ *
19
+ * Built on LC-001's schema (category + referenced_skill). Without LC-001
20
+ * this audit cannot distinguish promotion candidates from compliance
21
+ * evidence — see the LC-002-L1 learning for the same logic in audit-skills.
22
+ *
23
+ * Issue: #58 (G2 partial, story 3/3).
24
+ */
25
+ const { parseLearningsFile, LEARNING_CATEGORIES } = require('./learnings-parse');
26
+ const yaml = require('js-yaml');
27
+
28
+ /** Status enum for each audited learning entry. */
29
+ const STATUS = Object.freeze({
30
+ FRESH: 'fresh', // age ≤ 30d, not yet incorporated, no contradiction
31
+ AGING: 'aging', // 30d < age ≤ 180d, no incorporation, no contradiction
32
+ STALE: 'stale', // age > 180d, no incorporation, no contradiction
33
+ INCORPORATED: 'incorporated', // promoted, OR referenced skill was touched after capture
34
+ CONTRADICTED: 'contradicted', // a later learning marks this skill as exception-with-rationale
35
+ UNKNOWN: 'unknown', // can't determine (missing captured_at, etc.)
36
+ });
37
+
38
+ const FRESH_THRESHOLD_DAYS = 30;
39
+ const STALE_THRESHOLD_DAYS = 180;
40
+
41
+ /**
42
+ * Audit a corpus of learnings.
43
+ *
44
+ * @param {object} opts
45
+ * @param {Array<{fileLabel: string, content: string}>} opts.learningsFiles
46
+ * @param {string[]} [opts.skillNames]
47
+ * @param {(name: string) => number|null} [opts.getSkillLastModifiedDays]
48
+ * Returns days since the named skill file was last modified.
49
+ * null = skill doesn't exist or no commits found.
50
+ * @param {(fileLabel: string) => number|null} [opts.getCapturedAtFromGit]
51
+ * LC-004 (#143): fallback for entries with no `captured_at` field.
52
+ * Given a learnings file label (e.g. "H-001.yml"), returns the
53
+ * Unix MILLISECONDS of the file's first commit, or null if the
54
+ * file is unknown to git. Without this, ~65% of OptionsFlow's
55
+ * pre-LC-001 corpus lands in `unknown` because Schemas A/B don't
56
+ * carry the field.
57
+ * @param {string} [opts.today] - 'YYYY-MM-DD' for deterministic testing
58
+ * @returns {{ entries: Array, summary: object }}
59
+ */
60
+ function auditLearnings(opts = {}) {
61
+ const learningsFiles = opts.learningsFiles || [];
62
+ const skillNames = opts.skillNames || [];
63
+ const getSkillLastModifiedDays = typeof opts.getSkillLastModifiedDays === 'function'
64
+ ? opts.getSkillLastModifiedDays
65
+ : () => null;
66
+ // LC-004: optional injected git-log fallback
67
+ const getCapturedAtFromGit = typeof opts.getCapturedAtFromGit === 'function'
68
+ ? opts.getCapturedAtFromGit
69
+ : null;
70
+ const todayStr = opts.today || new Date().toISOString().slice(0, 10);
71
+ const todayMs = Date.parse(todayStr + 'T00:00:00Z');
72
+ const knownSkills = new Set(skillNames);
73
+
74
+ // ─── Pass 1: parse + collect raw entries (so we can do cross-entry analysis) ───
75
+ const rawEntries = [];
76
+ const parseErrors = [];
77
+ for (const lf of learningsFiles) {
78
+ if (!lf || !lf.content) continue;
79
+ const fileLabel = lf.fileLabel || '<unknown>';
80
+ let parsed;
81
+ try { parsed = yaml.load(lf.content); }
82
+ catch (e) {
83
+ parseErrors.push({ file: fileLabel, error: e.message });
84
+ continue;
85
+ }
86
+ if (!parsed || typeof parsed !== 'object') continue;
87
+
88
+ const fileCapturedAt = parsed.captured_at || null;
89
+ const fileStoryId = parsed.story_id || null;
90
+ const fileTopStatus = parsed.status || 'pending';
91
+
92
+ // Walk items in any of the 3 schemas (no eligibility filter — we audit
93
+ // EVERYTHING captured, including promoted/retracted).
94
+ const items = [];
95
+ if (Array.isArray(parsed)) {
96
+ for (const i of parsed) items.push({ item: i, schema: 'A' });
97
+ } else {
98
+ if (Array.isArray(parsed.enterprise_candidates)) {
99
+ for (const c of parsed.enterprise_candidates) items.push({ item: c, schema: 'B' });
100
+ }
101
+ if (Array.isArray(parsed.learnings)) {
102
+ for (const l of parsed.learnings) items.push({ item: l, schema: 'C' });
103
+ }
104
+ }
105
+
106
+ for (const { item, schema } of items) {
107
+ if (!item || typeof item !== 'object') continue;
108
+ const ref = item.referenced_skill || item.candidate_for || item.skill_update || null;
109
+ const refSkill = ref && typeof ref === 'string'
110
+ ? ref.replace(/^enterprise\/skills\//, '').replace(/\/SKILL\.md$/, '')
111
+ : null;
112
+ const capturedAt = item.captured_at || fileCapturedAt || null;
113
+ const itemStatus = item.status || fileTopStatus;
114
+ const category = (typeof item.category === 'string' && item.category) || null;
115
+ // Schema A/C carry full ids (e.g. "X-7-L1"); Schema B uses raw
116
+ // numeric ids that need the story prefix synthesized.
117
+ const synthId = item.id == null
118
+ ? null
119
+ : (schema === 'B' && fileStoryId)
120
+ ? `${fileStoryId}-${item.id}`
121
+ : String(item.id);
122
+
123
+ rawEntries.push({
124
+ file: fileLabel,
125
+ storyId: fileStoryId,
126
+ learningId: synthId,
127
+ schema,
128
+ category,
129
+ referencedSkill: refSkill,
130
+ capturedAt,
131
+ rawStatus: itemStatus,
132
+ enterpriseCandidate: item.enterprise_candidate === true,
133
+ });
134
+ }
135
+ }
136
+
137
+ // ─── Pass 2: build a per-skill index for contradiction detection ───
138
+ // Map: refSkill → array of { capturedAtMs, category }
139
+ const perSkill = new Map();
140
+ for (const e of rawEntries) {
141
+ if (!e.referencedSkill || !e.capturedAt) continue;
142
+ const ms = Date.parse(e.capturedAt);
143
+ if (Number.isNaN(ms)) continue;
144
+ if (!perSkill.has(e.referencedSkill)) perSkill.set(e.referencedSkill, []);
145
+ perSkill.get(e.referencedSkill).push({ capturedAtMs: ms, category: e.category });
146
+ }
147
+
148
+ // ─── Pass 3: classify each entry ───
149
+ const entries = [];
150
+ for (const e of rawEntries) {
151
+ const out = {
152
+ file: e.file,
153
+ storyId: e.storyId,
154
+ learningId: e.learningId,
155
+ category: e.category,
156
+ referencedSkill: e.referencedSkill,
157
+ capturedAt: e.capturedAt,
158
+ ageDays: null,
159
+ status: STATUS.UNKNOWN,
160
+ reason: '',
161
+ };
162
+
163
+ let capturedMs = e.capturedAt ? Date.parse(e.capturedAt) : NaN;
164
+
165
+ // LC-004 (#143): fallback for missing captured_at — query git for the
166
+ // file's first-commit timestamp. Resolves ~65% of OptionsFlow's
167
+ // pre-LC-001 corpus that lands in `unknown` because Schemas A/B
168
+ // don't carry the field.
169
+ if (Number.isNaN(capturedMs) && getCapturedAtFromGit && e.file) {
170
+ const fallbackMs = getCapturedAtFromGit(e.file);
171
+ if (typeof fallbackMs === 'number' && Number.isFinite(fallbackMs)) {
172
+ capturedMs = fallbackMs;
173
+ out.capturedAt = new Date(fallbackMs).toISOString();
174
+ out.capturedAtSource = 'git-fallback'; // surfaces in JSON output
175
+ }
176
+ }
177
+
178
+ if (Number.isNaN(capturedMs)) {
179
+ out.reason = 'no captured_at (and git-fallback returned null or absent)';
180
+ entries.push(out);
181
+ continue;
182
+ }
183
+ out.ageDays = Math.max(0, Math.round((todayMs - capturedMs) / 86400000));
184
+
185
+ // 1. Promoted → incorporated
186
+ if (e.rawStatus === 'promoted') {
187
+ out.status = STATUS.INCORPORATED;
188
+ out.reason = 'status: promoted';
189
+ entries.push(out);
190
+ continue;
191
+ }
192
+
193
+ // 2. Skill touched after capture → incorporated
194
+ if (e.referencedSkill && knownSkills.has(e.referencedSkill)) {
195
+ const skillAge = getSkillLastModifiedDays(e.referencedSkill);
196
+ if (typeof skillAge === 'number' && skillAge < out.ageDays) {
197
+ out.status = STATUS.INCORPORATED;
198
+ out.reason = `skill "${e.referencedSkill}" modified ${skillAge}d ago, after capture (${out.ageDays}d ago)`;
199
+ entries.push(out);
200
+ continue;
201
+ }
202
+ }
203
+
204
+ // 3. Contradiction: a LATER learning with the same skill has category=exception
205
+ if (e.referencedSkill && e.category !== LEARNING_CATEGORIES.EXCEPTION_WITH_RATIONALE) {
206
+ const peers = perSkill.get(e.referencedSkill) || [];
207
+ const contradicting = peers.find(p =>
208
+ p.capturedAtMs > capturedMs &&
209
+ p.category === LEARNING_CATEGORIES.EXCEPTION_WITH_RATIONALE
210
+ );
211
+ if (contradicting) {
212
+ out.status = STATUS.CONTRADICTED;
213
+ out.reason = `later exception-with-rationale learning targets "${e.referencedSkill}"`;
214
+ entries.push(out);
215
+ continue;
216
+ }
217
+ }
218
+
219
+ // 4. Age-based bucketing
220
+ if (out.ageDays > STALE_THRESHOLD_DAYS) {
221
+ out.status = STATUS.STALE;
222
+ out.reason = `age ${out.ageDays}d > ${STALE_THRESHOLD_DAYS}d, not incorporated`;
223
+ } else if (out.ageDays > FRESH_THRESHOLD_DAYS) {
224
+ out.status = STATUS.AGING;
225
+ out.reason = `age ${out.ageDays}d > ${FRESH_THRESHOLD_DAYS}d, not incorporated`;
226
+ } else {
227
+ out.status = STATUS.FRESH;
228
+ out.reason = `age ${out.ageDays}d ≤ ${FRESH_THRESHOLD_DAYS}d`;
229
+ }
230
+ entries.push(out);
231
+ }
232
+
233
+ // ─── Summary ───
234
+ const counts = { fresh: 0, aging: 0, stale: 0, incorporated: 0, contradicted: 0, unknown: 0 };
235
+ for (const e of entries) counts[e.status] = (counts[e.status] || 0) + 1;
236
+
237
+ return {
238
+ entries,
239
+ summary: {
240
+ total: entries.length,
241
+ ...counts,
242
+ parse_errors: parseErrors.length,
243
+ today: todayStr,
244
+ },
245
+ parseErrors,
246
+ };
247
+ }
248
+
249
+ module.exports = {
250
+ auditLearnings,
251
+ STATUS,
252
+ FRESH_THRESHOLD_DAYS,
253
+ STALE_THRESHOLD_DAYS,
254
+ };