@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.
- package/bin/hone.js +2 -0
- package/hone-cli.js +4006 -0
- package/lib/README.md +119 -0
- package/lib/adversarial-negative-lint.js +149 -0
- package/lib/audit.js +156 -0
- package/lib/auto-detect.js +213 -0
- package/lib/autofix-guardrails.js +124 -0
- package/lib/branch-protection.js +256 -0
- package/lib/ci-classifier.js +150 -0
- package/lib/ci-failures.js +173 -0
- package/lib/claude-md-tokens.js +71 -0
- package/lib/compliance-check.js +62 -0
- package/lib/config-augment.js +133 -0
- package/lib/config-update.js +70 -0
- package/lib/dependency-audit.js +108 -0
- package/lib/derive-domain.js +185 -0
- package/lib/doc-registry.js +63 -0
- package/lib/doctor-admin-merge.js +185 -0
- package/lib/doctor-bind-default.js +118 -0
- package/lib/doctor-docs.js +205 -0
- package/lib/doctor-placeholders.js +144 -0
- package/lib/doctor-skill-staleness.js +122 -0
- package/lib/domain-skill-template.md +114 -0
- package/lib/editor-detect.js +169 -0
- package/lib/fast-track-ratify.js +133 -0
- package/lib/git-helpers.js +109 -0
- package/lib/hook-templates/pre-commit.sh +54 -0
- package/lib/hook-templates/pre-push.sh +72 -0
- package/lib/install-hooks.js +205 -0
- package/lib/knowledge-graph.js +188 -0
- package/lib/learnings-audit.js +254 -0
- package/lib/learnings-parse.js +331 -0
- package/lib/learnings-sync.js +75 -0
- package/lib/mcp-detect.js +154 -0
- package/lib/metrics-collect.js +214 -0
- package/lib/overlay-merge.js +267 -0
- package/lib/performance-analyzer.js +142 -0
- package/lib/pipeline-config.js +83 -0
- package/lib/pipeline-status.js +207 -0
- package/lib/pipeline-validate.js +322 -0
- package/lib/platform-detect.js +86 -0
- package/lib/platform-discover.js +334 -0
- package/lib/publish-learning.js +160 -0
- package/lib/python-install.js +84 -0
- package/lib/refresh-check.js +67 -0
- package/lib/refresh-knowledge.js +360 -0
- package/lib/rule-resolver.js +146 -0
- package/lib/security-scanner.js +168 -0
- package/lib/setup-grounding.js +138 -0
- package/lib/skill-assertions.js +276 -0
- package/lib/skill-audit-render.js +158 -0
- package/lib/skill-audit.js +391 -0
- package/lib/stack-detect.js +170 -0
- package/lib/stack-paths.js +285 -0
- package/lib/story-classifier-extract.js +203 -0
- package/lib/story-classifier.js +282 -0
- package/lib/sync-overwrite.js +47 -0
- package/lib/synthetic-pipeline.js +299 -0
- package/lib/validate-metadata.js +175 -0
- 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
|
+
};
|