@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,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* editor-detect.js — H-081 detect which editor(s) drive the SDLC pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Pure helper with injected I/O (same shape as platform-detect.js,
|
|
6
|
+
* compliance-check.js). Caller passes `fileExists` callback + optional
|
|
7
|
+
* `envVar` value; helper returns array of detected editors.
|
|
8
|
+
*
|
|
9
|
+
* Detection signals (in order):
|
|
10
|
+
* 1. HONE_EDITOR env var (comma-separated explicit override)
|
|
11
|
+
* 2. File-system fingerprints at repo root
|
|
12
|
+
* 3. .vscode/settings.json `github.copilot.enable` (Copilot opt-in)
|
|
13
|
+
*
|
|
14
|
+
* Closes silent-drop class of bugs where Hone scaffolds files in editor-
|
|
15
|
+
* specific paths that the adopter's actual editor doesn't read. Per #81.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Editor → fingerprint files at repo root that signal "this editor is in use."
|
|
20
|
+
* Multiple fingerprints per editor: any one match counts.
|
|
21
|
+
*/
|
|
22
|
+
const EDITOR_FINGERPRINTS = Object.freeze({
|
|
23
|
+
'claude-code': {
|
|
24
|
+
files: ['CLAUDE.md', '.claude/agents'],
|
|
25
|
+
description: 'Claude Code (Anthropic CLI)',
|
|
26
|
+
},
|
|
27
|
+
copilot: {
|
|
28
|
+
files: ['.github/copilot-instructions.md'],
|
|
29
|
+
description: 'GitHub Copilot',
|
|
30
|
+
additional_signal: 'vscode_copilot_enabled',
|
|
31
|
+
},
|
|
32
|
+
cursor: {
|
|
33
|
+
files: ['.cursorrules', '.cursor'],
|
|
34
|
+
description: 'Cursor (Anysphere)',
|
|
35
|
+
},
|
|
36
|
+
windsurf: {
|
|
37
|
+
files: ['.windsurfrules', '.windsurf'],
|
|
38
|
+
description: 'Windsurf (Codeium)',
|
|
39
|
+
},
|
|
40
|
+
aider: {
|
|
41
|
+
files: ['.aider.conf.yml', 'CONVENTIONS.md'],
|
|
42
|
+
description: 'Aider (paul-gauthier)',
|
|
43
|
+
},
|
|
44
|
+
continue: {
|
|
45
|
+
files: ['.continue', '.continuerc.json'],
|
|
46
|
+
description: 'Continue.dev',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Detect editors from file-system probes + env var override.
|
|
52
|
+
*
|
|
53
|
+
* #119 concern 2 (over-detection): Without `opts.listDir`, this is
|
|
54
|
+
* filesystem-PRESENCE detection — a stale empty `.cursor/` directory
|
|
55
|
+
* registers as Cursor present. Pass `opts.listDir` to upgrade to
|
|
56
|
+
* filesystem-CONTENT detection: directory fingerprints (those ending
|
|
57
|
+
* in `/`, like `.cursor`) require at least one file inside to count.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} [opts]
|
|
60
|
+
* @param {(relativePath: string) => boolean} [opts.fileExists] - filesystem check
|
|
61
|
+
* @param {string} [opts.envVar] - HONE_EDITOR value (comma-separated list)
|
|
62
|
+
* @param {(relativePath: string) => string|null} [opts.readFile] - for vscode/settings.json
|
|
63
|
+
* @param {(relativePath: string) => string[]} [opts.listDir] - #119: optional
|
|
64
|
+
* directory listing. When provided, directory fingerprints (.cursor/,
|
|
65
|
+
* .continue/, .claude/agents/) require at least one entry to count as
|
|
66
|
+
* detected. Without listDir, falls back to presence-only.
|
|
67
|
+
* @returns {string[]} - editor keys from EDITOR_FINGERPRINTS, sorted by table order
|
|
68
|
+
*/
|
|
69
|
+
function detectEditors(opts = {}) {
|
|
70
|
+
const { fileExists, envVar, readFile, listDir } = opts;
|
|
71
|
+
|
|
72
|
+
// 1. Explicit env var override wins
|
|
73
|
+
if (typeof envVar === 'string' && envVar.trim().length > 0) {
|
|
74
|
+
const requested = envVar.split(',').map((s) => s.trim()).filter(Boolean);
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const key of Object.keys(EDITOR_FINGERPRINTS)) {
|
|
77
|
+
if (requested.includes(key)) out.push(key);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. File-system probes
|
|
83
|
+
if (typeof fileExists !== 'function') return [];
|
|
84
|
+
|
|
85
|
+
const detected = new Set();
|
|
86
|
+
for (const [editor, { files }] of Object.entries(EDITOR_FINGERPRINTS)) {
|
|
87
|
+
if (files.some((f) => {
|
|
88
|
+
try {
|
|
89
|
+
if (fileExists(f) !== true) return false;
|
|
90
|
+
// #119: if listDir is provided, use the RETURN-SHAPE to distinguish
|
|
91
|
+
// directory vs file (avoids unreliable name-heuristics — `.cursor`
|
|
92
|
+
// looks like a file by extension but is actually a directory).
|
|
93
|
+
// - listDir(dir) → array of entries; empty array means dir is empty
|
|
94
|
+
// - listDir(file) → throws (fs.readdirSync semantics)
|
|
95
|
+
// Empty directory → exclude (stale scaffolding shouldn't count as
|
|
96
|
+
// the editor being in use).
|
|
97
|
+
if (typeof listDir === 'function') {
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = listDir(f);
|
|
101
|
+
} catch {
|
|
102
|
+
// listDir threw → it's a file (or unreadable) → presence-only OK
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(entries)) {
|
|
106
|
+
return entries.length > 0; // dir: require non-empty
|
|
107
|
+
}
|
|
108
|
+
return true; // listDir returned non-array → presence-only fallback
|
|
109
|
+
}
|
|
110
|
+
return true; // no listDir → presence-only (backward compat)
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
})) {
|
|
115
|
+
detected.add(editor);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3. .vscode/settings.json Copilot opt-in
|
|
120
|
+
if (!detected.has('copilot') && typeof readFile === 'function') {
|
|
121
|
+
try {
|
|
122
|
+
const settings = readFile('.vscode/settings.json');
|
|
123
|
+
if (settings && /["']github\.copilot\.enable["']\s*:\s*(?:true|\{)/.test(settings)) {
|
|
124
|
+
detected.add('copilot');
|
|
125
|
+
}
|
|
126
|
+
} catch { /* defensive */ }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [...detected];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Editor → expected projection surfaces (where the editor reads from).
|
|
134
|
+
* Used by pipeline-validate.js to assert each editor's expected files exist.
|
|
135
|
+
*/
|
|
136
|
+
const EDITOR_PROJECTIONS = Object.freeze({
|
|
137
|
+
'claude-code': {
|
|
138
|
+
project_doc: 'CLAUDE.md',
|
|
139
|
+
agents_dir: '.claude/agents',
|
|
140
|
+
agent_file_pattern: '*.agent.md',
|
|
141
|
+
},
|
|
142
|
+
copilot: {
|
|
143
|
+
project_doc: '.github/copilot-instructions.md',
|
|
144
|
+
agents_dir: null, // Copilot uses inline doc; no per-agent files
|
|
145
|
+
},
|
|
146
|
+
cursor: {
|
|
147
|
+
project_doc: '.cursorrules',
|
|
148
|
+
agents_dir: '.cursor/rules',
|
|
149
|
+
agent_file_pattern: '*.mdc',
|
|
150
|
+
},
|
|
151
|
+
windsurf: {
|
|
152
|
+
project_doc: '.windsurfrules',
|
|
153
|
+
agents_dir: null,
|
|
154
|
+
},
|
|
155
|
+
aider: {
|
|
156
|
+
project_doc: 'CONVENTIONS.md',
|
|
157
|
+
agents_dir: null,
|
|
158
|
+
},
|
|
159
|
+
continue: {
|
|
160
|
+
project_doc: '.continuerc.json',
|
|
161
|
+
agents_dir: null,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
EDITOR_FINGERPRINTS,
|
|
167
|
+
EDITOR_PROJECTIONS,
|
|
168
|
+
detectEditors,
|
|
169
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* fast-track-ratify.js — H-027 ratify fast-track at the epic level.
|
|
4
|
+
*
|
|
5
|
+
* Pure helpers — no I/O. Caller (hone-cli.js) reads + writes
|
|
6
|
+
* .github/EXECUTION_PLAN.yml and runs the readline prompt.
|
|
7
|
+
*
|
|
8
|
+
* Closes #50 sub-tasks (a) classifier + (b/c/d) CLI machinery. Sub-tasks
|
|
9
|
+
* (e) agent prompt wiring + (f) doc updates deferred to a follow-up
|
|
10
|
+
* story (separate PR — narrower blast radius).
|
|
11
|
+
*
|
|
12
|
+
* 10th instance of pure-helpers + thin-CLI-shell pattern in cli/lib/
|
|
13
|
+
* after H-018, H-035, H-009, H-008, H-021, H-061, H-010, H-015, H-013.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const yaml = require('js-yaml');
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
// HIGH_TOUCH_RULES — codified from issue #50 §Proposed design point 2
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
21
|
+
// Each rule is a (story) → bool predicate. ANY rule matching means the
|
|
22
|
+
// story is high-touch and must be excluded from epic ratification.
|
|
23
|
+
const HIGH_TOUCH_RULES = [
|
|
24
|
+
// 1. Migration: schema-change keywords, but NOT explicit "no schema changes"
|
|
25
|
+
(s) => {
|
|
26
|
+
if (!s.migration) return false;
|
|
27
|
+
if (/\bno schema changes\b/i.test(s.migration)) return false;
|
|
28
|
+
return /\b(table|column|schema|alter|drop|migration)\b/i.test(s.migration);
|
|
29
|
+
},
|
|
30
|
+
// 2. Data model: schema/table/column/migration/breaking
|
|
31
|
+
(s) => s.data_model && /\b(schema|table|column|migration|breaking)\b/i.test(s.data_model),
|
|
32
|
+
// 3. Security: auth/secret/token/org-isolation/encryption/credential
|
|
33
|
+
(s) => s.security && /\b(auth|secret|token|org.isolation|encryption|credential)\b/i.test(s.security),
|
|
34
|
+
// 4. Estimate: ≥5 points
|
|
35
|
+
(s) => typeof s.estimate === 'number' && s.estimate >= 5,
|
|
36
|
+
// 5. Breaking change flag
|
|
37
|
+
(s) => s.breaking === true,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
// isHighTouch — pure boolean classifier
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
43
|
+
function isHighTouch(story) {
|
|
44
|
+
if (!story || typeof story !== 'object') return false;
|
|
45
|
+
return HIGH_TOUCH_RULES.some(rule => rule(story));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
// parseExecutionPlan — yaml.load wrapper with graceful fallback
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
51
|
+
function parseExecutionPlan(text) {
|
|
52
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
53
|
+
return { error: 'empty', stories: [] };
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const parsed = yaml.load(text) || {};
|
|
57
|
+
return parsed;
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { error: 'malformed', message: e.message, stories: [] };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
64
|
+
// addRatifiedBlock — surgical APPEND (or REPLACE if exists) of
|
|
65
|
+
// `fast_track_ratified:` block. Preserves all comments + content.
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
67
|
+
// opts: { by, at, mode, highTouchExcluded[] }
|
|
68
|
+
// Returns { text, updated }
|
|
69
|
+
function addRatifiedBlock(text, opts = {}) {
|
|
70
|
+
if (typeof text !== 'string') return { text, updated: false };
|
|
71
|
+
const by = opts.by || 'unknown';
|
|
72
|
+
const at = opts.at || new Date().toISOString();
|
|
73
|
+
const mode = opts.mode || 'epic';
|
|
74
|
+
const highTouchExcluded = Array.isArray(opts.highTouchExcluded) ? opts.highTouchExcluded : [];
|
|
75
|
+
|
|
76
|
+
// Render the block
|
|
77
|
+
const lines = [
|
|
78
|
+
`fast_track_ratified:`,
|
|
79
|
+
` by: ${by}`,
|
|
80
|
+
` at: ${at}`,
|
|
81
|
+
` mode: ${mode}`,
|
|
82
|
+
];
|
|
83
|
+
if (highTouchExcluded.length > 0) {
|
|
84
|
+
lines.push(` high_touch_excluded:`);
|
|
85
|
+
for (const id of highTouchExcluded) lines.push(` - ${id}`);
|
|
86
|
+
} else {
|
|
87
|
+
lines.push(` high_touch_excluded: []`);
|
|
88
|
+
}
|
|
89
|
+
const block = lines.join('\n');
|
|
90
|
+
|
|
91
|
+
// If an existing block exists, REPLACE it surgically
|
|
92
|
+
// Match `fast_track_ratified:` block at column 0 + its 2-space-indented children
|
|
93
|
+
const existingRe = /^fast_track_ratified:[\s\S]*?(?=^\S|\Z)/m;
|
|
94
|
+
if (existingRe.test(text)) {
|
|
95
|
+
return {
|
|
96
|
+
text: text.replace(existingRe, block + '\n\n'),
|
|
97
|
+
updated: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Otherwise APPEND at end (with one blank line separator)
|
|
102
|
+
const sep = text.length === 0 || text.endsWith('\n\n') ? '' : (text.endsWith('\n') ? '\n' : '\n\n');
|
|
103
|
+
return {
|
|
104
|
+
text: text + sep + block + '\n',
|
|
105
|
+
updated: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
110
|
+
// getRatificationStatus — read current state of fast_track_ratified
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
112
|
+
function getRatificationStatus(text) {
|
|
113
|
+
const parsed = parseExecutionPlan(text);
|
|
114
|
+
const block = parsed.fast_track_ratified;
|
|
115
|
+
if (!block || typeof block !== 'object') {
|
|
116
|
+
return { ratified: false };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
ratified: true,
|
|
120
|
+
by: block.by || null,
|
|
121
|
+
at: block.at || null,
|
|
122
|
+
mode: block.mode || 'epic',
|
|
123
|
+
highTouchExcluded: Array.isArray(block.high_touch_excluded) ? block.high_touch_excluded : [],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
HIGH_TOUCH_RULES,
|
|
129
|
+
isHighTouch,
|
|
130
|
+
parseExecutionPlan,
|
|
131
|
+
addRatifiedBlock,
|
|
132
|
+
getRatificationStatus,
|
|
133
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* git-helpers.js — LC-004 (#143): shared git-log helpers for CLI shells.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from cli/hone-cli.js's inline `getSkillLastModifiedDays`
|
|
6
|
+
* (which was added inline for LC-003 — `hone audit-learnings`). LC-004
|
|
7
|
+
* adds a sibling `getFirstCommitDateMs` for the captured_at-fallback
|
|
8
|
+
* path — both helpers share the same execSync error-handling shape.
|
|
9
|
+
*
|
|
10
|
+
* Pure-helper-with-injected-IO style: each helper accepts `repoRoot`
|
|
11
|
+
* + the relative file path, returns a primitive (number-of-days,
|
|
12
|
+
* Unix milliseconds, or null on any failure mode). Callers (CLI
|
|
13
|
+
* shells) inject these helpers into pure pipeline-status / audit-
|
|
14
|
+
* learnings logic.
|
|
15
|
+
*
|
|
16
|
+
* Why a separate module: cli/hone-cli.js is 3000+ lines and adding more
|
|
17
|
+
* inline git-log helpers each iteration creates the wrong module
|
|
18
|
+
* boundary. Architect's gap-finding for LC-004 said extract first.
|
|
19
|
+
*
|
|
20
|
+
* Issue: #143 (LC-004) — captured_at fallback to git log file mtime.
|
|
21
|
+
*/
|
|
22
|
+
const { execSync } = require('node:child_process');
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Last-modified Unix timestamp (seconds) for a tracked file, via
|
|
28
|
+
* `git log -1 --format=%ct -- <path>`. Returns null if:
|
|
29
|
+
* - path doesn't exist on disk
|
|
30
|
+
* - git log returns empty (file has never been committed)
|
|
31
|
+
* - git is unavailable / repo is not a git repo
|
|
32
|
+
* - any other error
|
|
33
|
+
*
|
|
34
|
+
* Used by LC-003 (audit-learnings: skill freshness signal).
|
|
35
|
+
*/
|
|
36
|
+
function getLastCommitTimestampSeconds(repoRoot, relativePath) {
|
|
37
|
+
if (!repoRoot || !relativePath) return null;
|
|
38
|
+
const abs = path.join(repoRoot, relativePath);
|
|
39
|
+
if (!fs.existsSync(abs)) return null;
|
|
40
|
+
try {
|
|
41
|
+
const out = execSync(
|
|
42
|
+
`git log -1 --format=%ct -- ${JSON.stringify(relativePath)}`,
|
|
43
|
+
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
44
|
+
).trim();
|
|
45
|
+
if (!out) return null;
|
|
46
|
+
const seconds = Number(out);
|
|
47
|
+
return Number.isFinite(seconds) ? seconds : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* First-commit Unix timestamp (milliseconds) for a tracked file, via
|
|
55
|
+
* `git log --diff-filter=A --follow --format=%at -- <path>`. The
|
|
56
|
+
* `--diff-filter=A` clause restricts to ADD events; `--follow` follows
|
|
57
|
+
* file renames so a learnings file moved between paths still resolves.
|
|
58
|
+
* Returns null on any failure (same modes as getLastCommitTimestampSeconds).
|
|
59
|
+
*
|
|
60
|
+
* Architect's gap-finding for LC-004: captured_at semantically wants
|
|
61
|
+
* "when was this learning first WRITTEN" — which is the FIRST commit
|
|
62
|
+
* that added the file, not the last commit that modified it. Different
|
|
63
|
+
* git log flag from getLastCommitTimestampSeconds.
|
|
64
|
+
*
|
|
65
|
+
* Returned as MILLISECONDS for consistency with Date.parse() / Date.now()
|
|
66
|
+
* — caller can format to ISO string via new Date(ms).toISOString().
|
|
67
|
+
*
|
|
68
|
+
* Used by LC-004 (audit-learnings: captured_at fallback when YAML
|
|
69
|
+
* field is absent).
|
|
70
|
+
*/
|
|
71
|
+
function getFirstCommitDateMs(repoRoot, relativePath) {
|
|
72
|
+
if (!repoRoot || !relativePath) return null;
|
|
73
|
+
const abs = path.join(repoRoot, relativePath);
|
|
74
|
+
if (!fs.existsSync(abs)) return null;
|
|
75
|
+
try {
|
|
76
|
+
const out = execSync(
|
|
77
|
+
`git log --diff-filter=A --follow --format=%at -- ${JSON.stringify(relativePath)}`,
|
|
78
|
+
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
79
|
+
).trim();
|
|
80
|
+
if (!out) return null;
|
|
81
|
+
// Multiple ADD events possible (file renames); take the OLDEST (last line)
|
|
82
|
+
const lines = out.split('\n').filter(Boolean);
|
|
83
|
+
const oldestLine = lines[lines.length - 1];
|
|
84
|
+
const seconds = Number(oldestLine);
|
|
85
|
+
return Number.isFinite(seconds) ? seconds * 1000 : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convenience wrapper: how many days ago was the file last modified?
|
|
93
|
+
* Returns Math.max(0, round((now - lastCommit) / day_ms)) or null.
|
|
94
|
+
*
|
|
95
|
+
* Same shape as the original getSkillLastModifiedDays inlined in
|
|
96
|
+
* cli/hone-cli.js, just relocated for shared use across CLI commands.
|
|
97
|
+
*/
|
|
98
|
+
function getLastModifiedDays(repoRoot, relativePath, nowMs) {
|
|
99
|
+
const seconds = getLastCommitTimestampSeconds(repoRoot, relativePath);
|
|
100
|
+
if (seconds === null) return null;
|
|
101
|
+
const ref = (typeof nowMs === 'number' && Number.isFinite(nowMs)) ? nowMs : Date.now();
|
|
102
|
+
return Math.max(0, Math.round((ref - seconds * 1000) / 86400000));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
getLastCommitTimestampSeconds,
|
|
107
|
+
getFirstCommitDateMs,
|
|
108
|
+
getLastModifiedDays,
|
|
109
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# managed-by: hone — DO NOT EDIT (run `hone install-hooks --uninstall` to remove)
|
|
3
|
+
#
|
|
4
|
+
# Hone pre-commit hook (HC-001).
|
|
5
|
+
# Validates any staged metadata.yml under .github/pipeline/<STORY>/ against
|
|
6
|
+
# the framework JSON schema (per SC-011). Skips on develop/main/release branches.
|
|
7
|
+
#
|
|
8
|
+
# Bypass: HONE_PRECOMMIT_SKIP=1 git commit ... (use sparingly; this defeats the gate per E13-A-L3)
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
# Skip toggle for emergency commits (still emits a warning so it can't be silent)
|
|
13
|
+
if [ "${HONE_PRECOMMIT_SKIP:-0}" = "1" ]; then
|
|
14
|
+
echo "⚠ hone pre-commit: HONE_PRECOMMIT_SKIP=1 — skipping (per E13-A-L3, bypass should be rare and tracked)" >&2
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Skip on non-story branches
|
|
19
|
+
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
|
20
|
+
SKIP_BRANCHES_RE="${HONE_PRECOMMIT_SKIP_BRANCHES:-^(develop|main|master|release/.*)$}"
|
|
21
|
+
if echo "$BRANCH" | grep -qE "$SKIP_BRANCHES_RE"; then
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# Find staged metadata.yml files under .github/pipeline/<STORY>/
|
|
26
|
+
STAGED_METADATA="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^\.github/pipeline/[^/]+/metadata\.yml$' || true)"
|
|
27
|
+
|
|
28
|
+
if [ -z "$STAGED_METADATA" ]; then
|
|
29
|
+
exit 0
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Validate each staged metadata.yml via `hone validate-metadata`
|
|
33
|
+
EXIT_CODE=0
|
|
34
|
+
SCHEMA_PATH="${HONE_METADATA_SCHEMA:-.github/schema/metadata.schema.json}"
|
|
35
|
+
# Fallback to canonical (hone-server's own dev mode) if the adopter copy is missing
|
|
36
|
+
if [ ! -f "$SCHEMA_PATH" ] && [ -f enterprise-assets/.github/schema/metadata.schema.json ]; then
|
|
37
|
+
SCHEMA_PATH="enterprise-assets/.github/schema/metadata.schema.json"
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
while IFS= read -r meta; do
|
|
41
|
+
STORY_ID="$(echo "$meta" | sed -E 's|^\.github/pipeline/([^/]+)/metadata\.yml$|\1|')"
|
|
42
|
+
echo "hone pre-commit: validating $meta (story: $STORY_ID)" >&2
|
|
43
|
+
if ! hone validate-metadata "$STORY_ID" --schema "$SCHEMA_PATH" 2>&1; then
|
|
44
|
+
EXIT_CODE=1
|
|
45
|
+
fi
|
|
46
|
+
done <<< "$STAGED_METADATA"
|
|
47
|
+
|
|
48
|
+
if [ "$EXIT_CODE" -ne 0 ]; then
|
|
49
|
+
echo "" >&2
|
|
50
|
+
echo "✗ hone pre-commit: metadata.yml validation failed. Fix the findings above OR run with HONE_PRECOMMIT_SKIP=1 (tracked per E13-A-L3)." >&2
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
exit 0
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# managed-by: hone — DO NOT EDIT (run `hone install-hooks --uninstall` to remove)
|
|
3
|
+
#
|
|
4
|
+
# Hone pre-push hook (HC-001).
|
|
5
|
+
# For the current branch's story (if any), assert that for every step marked
|
|
6
|
+
# `completed` in metadata.yml, the corresponding step-N artifact file exists.
|
|
7
|
+
# Catches the failure mode where a step is marked done in metadata but the
|
|
8
|
+
# artifact wasn't committed (would block PR review).
|
|
9
|
+
#
|
|
10
|
+
# Bypass: HONE_PREPUSH_SKIP=1 git push (use sparingly per E13-A-L3)
|
|
11
|
+
# Thorough: HONE_PREPUSH_THOROUGH=1 git push (also runs the story's regression tests)
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
if [ "${HONE_PREPUSH_SKIP:-0}" = "1" ]; then
|
|
16
|
+
echo "⚠ hone pre-push: HONE_PREPUSH_SKIP=1 — skipping (per E13-A-L3, bypass should be rare and tracked)" >&2
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
|
21
|
+
SKIP_BRANCHES_RE="${HONE_PREPUSH_SKIP_BRANCHES:-^(develop|main|master|release/.*)$}"
|
|
22
|
+
if echo "$BRANCH" | grep -qE "$SKIP_BRANCHES_RE"; then
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# Extract story ID from branch name (matches SC-NNN, H-NNN, RP-NNN, AU-NNN, SR-NNN, HC-NNN, E-NNN-X)
|
|
27
|
+
STORY_ID="$(echo "$BRANCH" | grep -oE '(SC|H|RP|AU|SR|HC|LC|SA)-[0-9]+[A-Z]?|E[0-9]+-[A-Z]' | head -1 || true)"
|
|
28
|
+
if [ -z "$STORY_ID" ]; then
|
|
29
|
+
exit 0 # Not a story branch
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
META=".github/pipeline/$STORY_ID/metadata.yml"
|
|
33
|
+
if [ ! -f "$META" ]; then
|
|
34
|
+
exit 0 # Story branch but no metadata yet; pre-commit warned
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Walk completed steps; assert artifact files exist
|
|
38
|
+
EXIT_CODE=0
|
|
39
|
+
# Read step IDs whose status is "completed" (or "complete")
|
|
40
|
+
COMPLETED_STEPS="$(grep -oE 'step_[0-9]+[a-z]?:[[:space:]]*\{[[:space:]]*status:[[:space:]]*(completed|complete)' "$META" | grep -oE 'step_[0-9]+[a-z]?' || true)"
|
|
41
|
+
|
|
42
|
+
for STEP in $COMPLETED_STEPS; do
|
|
43
|
+
# Find the artifact path: artifact: <name>.md
|
|
44
|
+
ARTIFACT_LINE="$(grep -E "^[[:space:]]+${STEP}:[[:space:]]*\{" "$META" || true)"
|
|
45
|
+
ARTIFACT="$(echo "$ARTIFACT_LINE" | grep -oE 'artifact:[[:space:]]*[a-zA-Z0-9_.-]+' | sed -E 's|artifact:[[:space:]]*||' || true)"
|
|
46
|
+
if [ -n "$ARTIFACT" ]; then
|
|
47
|
+
ARTIFACT_PATH=".github/pipeline/$STORY_ID/$ARTIFACT"
|
|
48
|
+
if [ ! -f "$ARTIFACT_PATH" ]; then
|
|
49
|
+
echo "✗ hone pre-push: $STEP marked completed but artifact $ARTIFACT_PATH is missing" >&2
|
|
50
|
+
EXIT_CODE=1
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
53
|
+
done
|
|
54
|
+
|
|
55
|
+
# Thorough mode: run the story's regression tests
|
|
56
|
+
if [ "${HONE_PREPUSH_THOROUGH:-0}" = "1" ]; then
|
|
57
|
+
TEST_PATTERN="tests/regression/${STORY_ID}-*.test.js"
|
|
58
|
+
if compgen -G "$TEST_PATTERN" > /dev/null; then
|
|
59
|
+
echo "hone pre-push (thorough): running $TEST_PATTERN" >&2
|
|
60
|
+
if ! node --test $TEST_PATTERN 2>&1 | tail -10; then
|
|
61
|
+
EXIT_CODE=1
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
if [ "$EXIT_CODE" -ne 0 ]; then
|
|
67
|
+
echo "" >&2
|
|
68
|
+
echo "✗ hone pre-push: artifact / test gate failed. Fix above OR run with HONE_PREPUSH_SKIP=1 (tracked per E13-A-L3)." >&2
|
|
69
|
+
exit 1
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
exit 0
|