@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,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