@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,83 @@
1
+ 'use strict';
2
+ /**
3
+ * pipeline-config.js — SC-003 (Phase 3 of SC family).
4
+ * Pure helper to read adopter-overridable thresholds from
5
+ * `.pipeline-config.yml`'s `story_classifier:` block.
6
+ *
7
+ * Reads from (in order):
8
+ * 1. <repoRoot>/.pipeline-config.yml
9
+ * 2. <repoRoot>/.github/.pipeline-config.yml
10
+ *
11
+ * Failure modes (all degrade gracefully — return empty object):
12
+ * - file missing
13
+ * - file unreadable / unparseable YAML
14
+ * - story_classifier block absent
15
+ * - block has unknown keys (passed through; helper just extracts)
16
+ *
17
+ * The 9 inputs to classifyStory are NOT read from config — those come
18
+ * per-story from the issue or --input-file. This config holds only
19
+ * adopter-team-level THRESHOLD DEFAULTS (estimate, recently-modified
20
+ * window) that apply across all stories.
21
+ *
22
+ * Safety-critical rules (R1/R2/R3 in story-classifier.js) are
23
+ * NOT configurable — adopter cannot downgrade bug+high-blast away
24
+ * from hot-fix, security away from full-sdlc, or first-of-its-kind
25
+ * away from full-sdlc, regardless of what they put in this config.
26
+ *
27
+ * Issue: user-request 2026-05-05 SC-003. Builds on SC-001/SC-002.
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ const KNOWN_THRESHOLD_KEYS = ['estimate_full_sdlc', 'recently_modified_window_days'];
34
+
35
+ /**
36
+ * Read story-classifier thresholds from .pipeline-config.yml.
37
+ *
38
+ * @param {string} repoRoot
39
+ * @returns {{ estimate_full_sdlc?: string, recently_modified_window_days?: number }}
40
+ * Empty object if config is missing, malformed, or has no story_classifier block.
41
+ */
42
+ function readStoryClassifierConfig(repoRoot) {
43
+ if (!repoRoot || typeof repoRoot !== 'string') return {};
44
+
45
+ const candidates = [
46
+ path.join(repoRoot, '.pipeline-config.yml'),
47
+ path.join(repoRoot, '.github/.pipeline-config.yml'),
48
+ ];
49
+
50
+ for (const p of candidates) {
51
+ if (!fs.existsSync(p)) continue;
52
+ let raw;
53
+ try { raw = fs.readFileSync(p, 'utf8'); }
54
+ catch { continue; }
55
+
56
+ let parsed;
57
+ try {
58
+ const yaml = require('js-yaml');
59
+ parsed = yaml.load(raw);
60
+ } catch {
61
+ // Malformed YAML — silently skip (not a fatal error)
62
+ continue;
63
+ }
64
+
65
+ if (!parsed || typeof parsed !== 'object') continue;
66
+ const block = parsed.story_classifier;
67
+ if (!block || typeof block !== 'object') continue;
68
+
69
+ // Pick out only the known threshold keys; ignore unknowns
70
+ const out = {};
71
+ for (const key of KNOWN_THRESHOLD_KEYS) {
72
+ if (block[key] !== undefined) out[key] = block[key];
73
+ }
74
+ return out;
75
+ }
76
+
77
+ return {};
78
+ }
79
+
80
+ module.exports = {
81
+ readStoryClassifierConfig,
82
+ KNOWN_THRESHOLD_KEYS,
83
+ };
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+ /**
3
+ * pipeline-status.js — H-009 pure helpers for the `hone status` and
4
+ * `hone next` CLI commands. No I/O — callers (hone-cli.js) do all
5
+ * filesystem reads and pass parsed inputs in. Keeps all rules
6
+ * unit-testable without temp dirs.
7
+ *
8
+ * Same shape as cli/lib/auto-detect.js (H-018) and cli/lib/learnings-parse.js
9
+ * (H-035) — pure helpers, all I/O at the CLI shell.
10
+ *
11
+ * Closes #18.
12
+ */
13
+
14
+ const yaml = require('js-yaml');
15
+
16
+ // ── Canonical step file naming convention (H-009/A2) ───────────────
17
+ // Verified across .github/pipeline/H-001/, H-014/, H-029/, H-035/, H-076/.
18
+ // step_3b is conditional (manual E2E mode only).
19
+ // step_5b is conditional (SA-002 skill audit, present when story consults skills).
20
+ // step_5c is conditional (H-076 CI gate, present after PR creation).
21
+ //
22
+ // SR-002 (#160): added step_5b + step_5c. Single STEPS table consolidates
23
+ // the 4 prior parallel objects (file/display/agent maps + order array)
24
+ // into one declaration so the "add a new step" affordance is a single edit.
25
+ // Future steps go here without touching the helpers below.
26
+ const STEPS = [
27
+ { key: 'step_0', file: 'step-0-grooming.md', display: 'Step 0 — Grooming', agent: 'story-groomer', conditional: false },
28
+ { key: 'step_1', file: 'step-1-plan.md', display: 'Step 1 — Plan', agent: 'implementation-planner', conditional: false },
29
+ { key: 'step_2', file: 'step-2-tests.md', display: 'Step 2 — Unit Tests', agent: 'unit-test-writer', conditional: false },
30
+ { key: 'step_3a', file: 'step-3-e2e-plan.md', display: 'Step 3a — E2E Plan', agent: 'e2e-qa-planner', conditional: false },
31
+ { key: 'step_3b', file: 'step-3b-spec.md', display: 'Step 3b — E2E Spec', agent: 'e2e-test-spec-writer', conditional: true },
32
+ { key: 'step_4', file: 'step-4-implementation.md', display: 'Step 4 — Implementation', agent: 'code-builder', conditional: false },
33
+ { key: 'step_5', file: 'step-5-review.md', display: 'Step 5 — Review', agent: 'code-reviewer', conditional: false },
34
+ { key: 'step_5b', file: 'step-5b-skill-audit.md', display: 'Step 5b — Skill Audit', agent: 'code-reviewer', conditional: true },
35
+ { key: 'step_5c', file: 'step-5c-ci.md', display: 'Step 5c — CI Gate', agent: 'code-reviewer', conditional: true },
36
+ ];
37
+
38
+ // Derived maps preserved for back-compat — same shape, same exports.
39
+ // Existing callers (cli/hone-cli.js + tests + downstream tooling) read
40
+ // STEP_FILE_MAP / STEP_DISPLAY_NAME / STEP_AGENT / STEP_ORDER unchanged.
41
+ const STEP_FILE_MAP = Object.fromEntries(STEPS.map(s => [s.key, s.file]));
42
+ const STEP_DISPLAY_NAME = Object.fromEntries(STEPS.map(s => [s.key, s.display]));
43
+ const STEP_AGENT = Object.fromEntries(STEPS.map(s => [s.key, s.agent]));
44
+ const STEP_ORDER = STEPS.map(s => s.key);
45
+ const CONDITIONAL_STEPS = new Set(STEPS.filter(s => s.conditional).map(s => s.key));
46
+
47
+ // SR-001 (#142): canonical STORY_ID_PATTERN — UNION of two adopter
48
+ // conventions. The single-alternative form documented in H-005 silently
49
+ // failed to match the second alternative's inputs (e.g., it claimed to
50
+ // match "E13-A" but actually returned the wrong substring of any
51
+ // branch like "feature/E34-A-...").
52
+ //
53
+ // Now matches:
54
+ // 1. E[0-9]+-[A-Z]<rest> — OptionsFlow's "E<num>-<letter>" convention
55
+ // (E13-A, E34-A, E29-H, etc.)
56
+ // 2. [A-Z]+[-_]<alphanum> — Jira (ABC-123), Hone (H-009), LC-001,
57
+ // SA-001, RP-001, underscore variants
58
+ // (STORY_123)
59
+ //
60
+ // Alternation order matters: the E-prefix alternative is tried first
61
+ // so digits-in-middle stories (E34-A) match correctly even when the
62
+ // second alternative would also greedily-match a later substring
63
+ // (A-resolve in "feature/E34-A-resolve-...").
64
+ //
65
+ // Substituted into ai-review.yml at install time via
66
+ // server/scripts/setup-ai-pipeline.sh (which still ships the OLD
67
+ // single-alt default for back-compat with existing .pipeline-config.yml
68
+ // files; updating that default is a separate PR).
69
+ //
70
+ // Reused here for branch → STORY-ID extraction in:
71
+ // - `hone status` / `hone next` (existing)
72
+ // - `hone step-5b` (SA-002, this is what surfaced the bug — when run
73
+ // against OptionsFlow's feature/E34-A-* branches, story id was
74
+ // mis-extracted as "A-resolve")
75
+ const STORY_ID_PATTERN = /(E[0-9]+-[A-Z][A-Za-z0-9]*|[A-Z]+[-_][A-Za-z0-9]+)/;
76
+
77
+ // ─────────────────────────────────────────────────────────────────────
78
+ // extractStoryIdFromBranch — pure regex on branch name
79
+ // ─────────────────────────────────────────────────────────────────────
80
+ function extractStoryIdFromBranch(branchName) {
81
+ if (!branchName || typeof branchName !== 'string') return null;
82
+ const m = branchName.match(STORY_ID_PATTERN);
83
+ return m ? m[0] : null;
84
+ }
85
+
86
+ // ─────────────────────────────────────────────────────────────────────
87
+ // parseMetadata — YAML parse + shape validation, graceful catch on errors
88
+ // ─────────────────────────────────────────────────────────────────────
89
+ function parseMetadata(yamlText) {
90
+ let parsed;
91
+ try { parsed = yaml.load(yamlText); }
92
+ catch (e) { return { error: 'malformed', message: e.message }; }
93
+ if (!parsed || typeof parsed !== 'object') {
94
+ return { error: 'empty' };
95
+ }
96
+ return {
97
+ storyId: parsed.story_id || null,
98
+ title: parsed.title || null,
99
+ branch: parsed.branch || null,
100
+ base: parsed.base || null,
101
+ steps: parsed.steps || {},
102
+ };
103
+ }
104
+
105
+ // ─────────────────────────────────────────────────────────────────────
106
+ // getStepIcon — 5-state icon for one step
107
+ // ─────────────────────────────────────────────────────────────────────
108
+ // '⏭️' if status is skipped (HC-007: intentional skip overrides file presence)
109
+ // '✓' if metadata says completed/approved AND artifact exists
110
+ // '⏳' if status is in_progress|pending AND artifact exists
111
+ // '✗' if status is failed AND artifact exists (SR-002: surface step_5c failures)
112
+ // '⬜' otherwise (filesystem wins on disagreement — AC-5)
113
+ //
114
+ // Filesystem-as-source-of-truth: even when metadata says completed, if
115
+ // the underlying .md file is missing, return ⬜. Otherwise we'd lie to
116
+ // the operator about pipeline state.
117
+ //
118
+ // SR-002 (#160): added 'approved' as a "done" status (used by step_5b /
119
+ // step_5c for human-approved gates) and 'failed' as a distinct icon
120
+ // (so a CI gate failure doesn't masquerade as in-progress).
121
+ //
122
+ // HC-007 (#163): added 'skipped' — boundary-below-threshold stories
123
+ // legitimately skip step_3b; rendering ⬜ (gap) is a false alarm.
124
+ // Skipped overrides file presence because the decision is recorded in
125
+ // metadata, not in the filesystem.
126
+ const DONE_STATUSES = new Set(['completed', 'approved']);
127
+ const IN_PROGRESS_STATUSES = new Set(['in_progress', 'pending']);
128
+ function getStepIcon(metadataStatus, artifactExists) {
129
+ if (metadataStatus === 'skipped') return '⏭️';
130
+ if (!artifactExists) return '⬜';
131
+ if (DONE_STATUSES.has(metadataStatus)) return '✓';
132
+ if (IN_PROGRESS_STATUSES.has(metadataStatus)) return '⏳';
133
+ if (metadataStatus === 'failed') return '✗';
134
+ return '⬜';
135
+ }
136
+
137
+ // ─────────────────────────────────────────────────────────────────────
138
+ // computeStepStates — combine parsed metadata with filesystem presence
139
+ // ─────────────────────────────────────────────────────────────────────
140
+ function computeStepStates(parsedMetadata, artifactPresence) {
141
+ const result = [];
142
+ const steps = parsedMetadata.steps || {};
143
+ for (const key of STEP_ORDER) {
144
+ const meta = steps[key] || {};
145
+ const fileExists = !!artifactPresence[key];
146
+ // SR-002 (#160): conditional steps (step_3b, step_5b, step_5c) skip
147
+ // when neither metadata nor file exists. Same convention as step_3b
148
+ // pre-SR-002 — generalized to all conditional steps now that we have
149
+ // 3 of them.
150
+ if (CONDITIONAL_STEPS.has(key) && !meta.status && !fileExists) continue;
151
+ const entry = {
152
+ key,
153
+ displayName: STEP_DISPLAY_NAME[key],
154
+ agent: STEP_AGENT[key],
155
+ icon: getStepIcon(meta.status, fileExists),
156
+ status: meta.status || 'not_started',
157
+ artifactPath: STEP_FILE_MAP[key],
158
+ };
159
+ // HC-007 (#163): surface skip reason so tracker/CLI can display it
160
+ if (meta.status === 'skipped' && meta.reason) {
161
+ entry.reason = meta.reason;
162
+ }
163
+ result.push(entry);
164
+ }
165
+ return result;
166
+ }
167
+
168
+ // ─────────────────────────────────────────────────────────────────────
169
+ // findNextIncompleteStep — first step where icon !== '✓' and not skipped
170
+ // HC-007: skipped steps are intentionally not done — don't report them as
171
+ // "next incomplete" in `hone next`.
172
+ // ─────────────────────────────────────────────────────────────────────
173
+ function findNextIncompleteStep(stepStates) {
174
+ return stepStates.find(s => s.icon !== '✓' && s.icon !== '⏭️') || null;
175
+ }
176
+
177
+ // ─────────────────────────────────────────────────────────────────────
178
+ // summarizeForAll — one-line summary for `hone status --all`
179
+ // ─────────────────────────────────────────────────────────────────────
180
+ function summarizeForAll(parsedMetadata, artifactPresence) {
181
+ const states = computeStepStates(parsedMetadata, artifactPresence);
182
+ const next = findNextIncompleteStep(states);
183
+ const summary = next
184
+ ? `${next.displayName} ${next.icon === '⏳' ? 'in progress' : 'not started'}`
185
+ : 'pipeline complete';
186
+ return {
187
+ storyId: parsedMetadata.storyId,
188
+ branch: parsedMetadata.branch,
189
+ summary,
190
+ };
191
+ }
192
+
193
+ module.exports = {
194
+ // Constants (testable shape)
195
+ STEP_FILE_MAP,
196
+ STEP_DISPLAY_NAME,
197
+ STEP_AGENT,
198
+ STEP_ORDER,
199
+ STORY_ID_PATTERN,
200
+ // Pure helpers
201
+ extractStoryIdFromBranch,
202
+ parseMetadata,
203
+ getStepIcon,
204
+ computeStepStates,
205
+ findNextIncompleteStep,
206
+ summarizeForAll,
207
+ };
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+ /**
3
+ * pipeline-validate.js — H-081 editor-aware pipeline validation.
4
+ *
5
+ * Performs the per-editor + cross-editor checks proposed in #81 component 3:
6
+ * a. Discovery: agent files exist at the editor's expected paths
7
+ * b. Step coverage: every pipeline step has an agent prompt or inline section
8
+ * c. Rule presence: each canonical enforcement rule appears in editor surface
9
+ * d. Format validity: per-editor format expectations met
10
+ * e. Drift detection: every canonical rule appears in EVERY editor projection
11
+ * f. Skill referenced: every skill is referenced by ≥1 agent prompt
12
+ *
13
+ * Pure helper with injected I/O. Returns findings + summary.
14
+ *
15
+ * ─── HS-003 Audit (PR-this) ────────────────────────────────────────────
16
+ * Use case distinction vs synthetic-pipeline.js:
17
+ * - synthetic-pipeline.js asserts "did the agent APPLY the rule when
18
+ * running this story?" → execution-level evidence; substring match
19
+ * is gameable.
20
+ * - pipeline-validate.js asserts "does the editor's PROMPT/scaffolding
21
+ * teach the rule?" → adopter-scaffolding-level coverage check;
22
+ * substring match is appropriate IF the rule is "topic is covered."
23
+ *
24
+ * Per-rule audit categorization (added in HS-003):
25
+ * - 'step-5c-ci-gate' (a) — "is Step 5c covered in the prompt?"; mention is sufficient
26
+ * - 'capture-learnings' (a) — "is the learnings substep documented?"; mention is sufficient
27
+ * - 'no-bypass-no-verify' (c) — UPGRADED: rule is FORBID, not MENTION. Substring
28
+ * match passed "you may use --no-verify in emergencies" (gameable). Now uses a
29
+ * structural check verifying negative-context proximity (forbidden/never/MUST NOT).
30
+ * - 'fast-track-protocol' (a) — "is the fast-track protocol explained?"; mention is sufficient
31
+ *
32
+ * Categories: (a) substring-acceptable-because-scaffolding; (b) prose-only-OK;
33
+ * (c) gameable-and-must-fix.
34
+ */
35
+
36
+ const { EDITOR_PROJECTIONS } = require('./editor-detect');
37
+
38
+ // Pipeline steps that should be covered by an agent prompt or inline section
39
+ const PIPELINE_STEPS = ['story-groomer', 'implementation-planner', 'unit-test-writer',
40
+ 'e2e-qa-planner', 'e2e-test-spec-writer', 'code-builder', 'code-reviewer'];
41
+
42
+ // Canonical enforcement rules that should appear in EVERY editor's surface.
43
+ // Two shapes supported:
44
+ // - string: case-insensitive regex/substring; rule is "topic is covered"
45
+ // - { positive: <regex>, forbidden: <regex> }: positive must appear AND
46
+ // it must NOT appear in a permissive context (e.g., "you may use X")
47
+ const CANONICAL_RULES = Object.freeze({
48
+ 'step-5c-ci-gate': 'Step 5c', // category (a): mention is sufficient
49
+ 'capture-learnings': 'Capture Learnings', // category (a)
50
+ 'no-bypass-no-verify': { // category (c): UPGRADED for HS-003
51
+ // Must mention the flag AND in a forbidding context (NEVER, forbidden,
52
+ // MUST NOT, do not). Permissive phrasings ("you may use --no-verify")
53
+ // fail the rule.
54
+ positive: '--no-verify',
55
+ forbidden_context: '(may|allowed|sometimes|emergency|emergencies|except|escape hatch).{0,30}--no-verify|--no-verify.{0,30}(allowed|may be used|emergency|escape hatch)',
56
+ // Must mention --no-verify in a forbidding context:
57
+ required_context: '(NEVER|never|forbidden|MUST NOT|must not|do not|don\'t|prohibited|banned).{0,80}--no-verify|--no-verify.{0,80}(forbidden|never|prohibited|banned|MUST NOT|must not)',
58
+ },
59
+ 'fast-track-protocol': 'fast.?track', // category (a)
60
+ });
61
+
62
+ /**
63
+ * Validate the SDLC pipeline scaffolding for the given editors.
64
+ *
65
+ * @param {object} opts
66
+ * @param {string[]} opts.editors - detected editor keys
67
+ * @param {Map<string, string>} opts.surfaceContents - map: file path → content
68
+ * @param {(relativePath: string) => boolean} opts.fileExists
69
+ * @param {string[]} [opts.skillNames] - known skill names (for orphan check)
70
+ * @returns {{ findings: Array<{severity, category, editor?, rule?, message}>, summary }}
71
+ */
72
+ function validatePipeline(opts = {}) {
73
+ const {
74
+ editors = [],
75
+ surfaceContents = new Map(),
76
+ fileExists,
77
+ skillNames = [],
78
+ // RR-002 + RR-004 (architect plan #117 stories 2 & 4): rule-baseline source.
79
+ //
80
+ // RR-002 default was 'canonical' (always) with warning when overlays
81
+ // detected. RR-004 promotes the default behavior:
82
+ //
83
+ // - source explicitly set → use it (canonical or merged)
84
+ // - source omitted + overlays present → default 'merged' (was: warn)
85
+ // - source omitted + no overlays → default 'canonical' (unchanged)
86
+ //
87
+ // The promotion eliminates the OVERLAYS_PRESENT_BUT_CANONICAL_BASELINE
88
+ // warning by removing the underlying gap. Adopters who previously got
89
+ // the warning now get correct merged validation by default.
90
+ //
91
+ // Backward compat: adopters who passed source: 'canonical' explicitly
92
+ // are unaffected. Adopters who relied on the implicit canonical default
93
+ // when overlays exist (silent canonical mode) get behavior change —
94
+ // this is documented in CHANGELOG.md and was the architect's risk R1.
95
+ repoRoot, // required when source = 'merged'
96
+ readFile, // required when source = 'merged'
97
+ overlayPaths = [], // optional list of overlay file paths
98
+ } = opts;
99
+ const sourceWasExplicit = ('source' in opts);
100
+ // Compute effective source AFTER destructuring (need fileExists to detect overlays)
101
+ let source = opts.source;
102
+ if (!sourceWasExplicit && typeof opts.fileExists === 'function') {
103
+ // RR-004 default-flip: implicit + overlays present → 'merged'
104
+ if (opts.fileExists('.hone-local/rule-overlays') || (opts.overlayPaths || []).length > 0) {
105
+ source = 'merged';
106
+ } else {
107
+ source = 'canonical';
108
+ }
109
+ }
110
+ if (source === undefined) source = 'canonical';
111
+ const findings = [];
112
+
113
+ // ─── RR-002: derive effective rule set ───
114
+ // CANONICAL_RULES is the framework's hardcoded list. When source = 'merged',
115
+ // each adopter overlay's rule_id is added as an additional substring rule
116
+ // (the rule_id should appear in the editor surface so the agent knows about
117
+ // adopter-specific enforcement).
118
+ let effectiveRules = { ...CANONICAL_RULES };
119
+ if (source === 'merged' && typeof fileExists === 'function' && typeof readFile === 'function') {
120
+ try {
121
+ const { resolveRules } = require('./rule-resolver');
122
+ const resolved = resolveRules({ repoRoot, fileExists, readFile, overlayPaths });
123
+ for (const overlay of resolved.overlays) {
124
+ // Adopter overlay rule_ids become additional checks. Use the rule_id
125
+ // string as the substring marker — adopters expecting custom enforcement
126
+ // should reference the rule_id in their editor projections.
127
+ if (!effectiveRules[overlay.ruleId]) {
128
+ effectiveRules[overlay.ruleId] = overlay.ruleId;
129
+ }
130
+ }
131
+ } catch { /* defensive — resolveRules failure → fall back to canonical */ }
132
+ }
133
+ // RR-004: removed OVERLAYS_PRESENT_BUT_CANONICAL_BASELINE warning since the
134
+ // default-flip above already routes implicit + overlays-present to 'merged'.
135
+ // Adopters who explicitly set source: 'canonical' with overlays present are
136
+ // making an informed choice and don't need a warning.
137
+
138
+ if (editors.length === 0) {
139
+ findings.push({
140
+ severity: 'WARN',
141
+ category: 'NO_EDITORS_DETECTED',
142
+ message: 'No editors detected — set HONE_EDITOR env var or add editor fingerprint files',
143
+ });
144
+ }
145
+
146
+ if (typeof fileExists !== 'function') {
147
+ return {
148
+ findings: [{ severity: 'ERROR', category: 'BAD_INPUT', message: 'fileExists callback missing' }],
149
+ summary: { error: 'missing fileExists' },
150
+ };
151
+ }
152
+
153
+ // ─── Per-editor: a. Discovery + d. Format ───
154
+ for (const editor of editors) {
155
+ const proj = EDITOR_PROJECTIONS[editor];
156
+ if (!proj) {
157
+ findings.push({
158
+ severity: 'WARN',
159
+ category: 'UNKNOWN_EDITOR',
160
+ editor,
161
+ message: `editor "${editor}" has no entry in EDITOR_PROJECTIONS table`,
162
+ });
163
+ continue;
164
+ }
165
+
166
+ // a. Discovery — project_doc exists?
167
+ if (proj.project_doc && !fileExists(proj.project_doc)) {
168
+ findings.push({
169
+ severity: 'ERROR',
170
+ category: 'DISCOVERY',
171
+ editor,
172
+ message: `${editor} expects project_doc "${proj.project_doc}" but it doesn't exist`,
173
+ });
174
+ }
175
+
176
+ // a. Discovery — agents_dir has at least one agent file?
177
+ if (proj.agents_dir && !fileExists(proj.agents_dir)) {
178
+ findings.push({
179
+ severity: 'ERROR',
180
+ category: 'DISCOVERY',
181
+ editor,
182
+ message: `${editor} expects agents_dir "${proj.agents_dir}" but it doesn't exist`,
183
+ });
184
+ }
185
+ }
186
+
187
+ // ─── Per-editor: c. Rule presence ───
188
+ // For each editor's project_doc surface, verify each canonical rule appears.
189
+ // HS-003: rule may be a string (substring/regex) OR an object
190
+ // { positive, forbidden_context?, required_context? } for structural checks
191
+ // (e.g., "no-bypass-no-verify" — must mention --no-verify in a forbidding
192
+ // context; permissive phrasing fails the rule).
193
+ for (const editor of editors) {
194
+ const proj = EDITOR_PROJECTIONS[editor];
195
+ if (!proj || !proj.project_doc) continue;
196
+ const content = surfaceContents.get(proj.project_doc);
197
+ if (typeof content !== 'string') {
198
+ // #119 concern 3: don't silently skip when fileExists says yes but
199
+ // surfaceContents has nothing. That mismatch is a real gap (caller
200
+ // failed to read a file the validator was told existed).
201
+ if (fileExists(proj.project_doc)) {
202
+ findings.push({
203
+ severity: 'ERROR',
204
+ category: 'SURFACE_UNREADABLE',
205
+ editor,
206
+ message: `${editor} project_doc "${proj.project_doc}" exists per fileExists but no content provided in surfaceContents — caller should read this file before calling validatePipeline`,
207
+ });
208
+ }
209
+ continue;
210
+ }
211
+
212
+ for (const [ruleId, ruleSpec] of Object.entries(effectiveRules)) {
213
+ const passes = evaluateRule(ruleSpec, content);
214
+ if (!passes.matched) {
215
+ findings.push({
216
+ severity: 'WARN',
217
+ category: 'RULE_MISSING',
218
+ editor,
219
+ rule: ruleId,
220
+ message: passes.reason
221
+ ? `rule "${ruleId}" not satisfied in ${editor}'s project_doc (${proj.project_doc}): ${passes.reason}`
222
+ : `rule "${ruleId}" not found in ${editor}'s project_doc (${proj.project_doc})`,
223
+ });
224
+ }
225
+ }
226
+ }
227
+
228
+ // ─── Cross-editor: e. Drift detection ───
229
+ if (editors.length >= 2) {
230
+ // For each rule, count how many editors' surfaces have it
231
+ for (const [ruleId, ruleSpec] of Object.entries(effectiveRules)) {
232
+ const editorsWithRule = [];
233
+ const editorsWithoutRule = [];
234
+ for (const editor of editors) {
235
+ const proj = EDITOR_PROJECTIONS[editor];
236
+ if (!proj || !proj.project_doc) continue;
237
+ const content = surfaceContents.get(proj.project_doc);
238
+ if (typeof content !== 'string') continue;
239
+ if (evaluateRule(ruleSpec, content).matched) editorsWithRule.push(editor);
240
+ else editorsWithoutRule.push(editor);
241
+ }
242
+ // Drift: some editors have it, some don't
243
+ if (editorsWithRule.length > 0 && editorsWithoutRule.length > 0) {
244
+ findings.push({
245
+ severity: 'ERROR',
246
+ category: 'DRIFT',
247
+ rule: ruleId,
248
+ message: `rule "${ruleId}" present in [${editorsWithRule.join(',')}] but missing from [${editorsWithoutRule.join(',')}] — drift between editor projections`,
249
+ });
250
+ }
251
+ }
252
+ }
253
+
254
+ // ─── Cross-editor: f. Skill referenced ───
255
+ // For each skill, verify ≥1 surface mentions it.
256
+ for (const skillName of skillNames) {
257
+ let mentioned = false;
258
+ for (const content of surfaceContents.values()) {
259
+ if (typeof content === 'string' && content.includes(skillName)) {
260
+ mentioned = true;
261
+ break;
262
+ }
263
+ }
264
+ if (!mentioned) {
265
+ findings.push({
266
+ severity: 'INFO',
267
+ category: 'ORPHAN_SKILL',
268
+ message: `skill "${skillName}" not referenced by any editor surface — possibly orphaned`,
269
+ });
270
+ }
271
+ }
272
+
273
+ const summary = {
274
+ editors_validated: editors.length,
275
+ surfaces_scanned: surfaceContents.size,
276
+ error_count: findings.filter((f) => f.severity === 'ERROR').length,
277
+ warn_count: findings.filter((f) => f.severity === 'WARN').length,
278
+ info_count: findings.filter((f) => f.severity === 'INFO').length,
279
+ };
280
+
281
+ return { findings, summary };
282
+ }
283
+
284
+ /**
285
+ * Evaluate one CANONICAL_RULES entry against a content string.
286
+ * (HS-003) Handles both shapes:
287
+ * - string: case-insensitive regex/substring match
288
+ * - object { positive, forbidden_context?, required_context? }: structural
289
+ * check requiring positive AND (if specified) required context AND
290
+ * no forbidden context.
291
+ *
292
+ * @param {string|object} ruleSpec
293
+ * @param {string} content
294
+ * @returns {{ matched: boolean, reason?: string }}
295
+ */
296
+ function evaluateRule(ruleSpec, content) {
297
+ if (typeof ruleSpec === 'string') {
298
+ return { matched: new RegExp(ruleSpec, 'i').test(content) };
299
+ }
300
+ if (!ruleSpec || typeof ruleSpec !== 'object') {
301
+ return { matched: false, reason: 'invalid rule spec' };
302
+ }
303
+ // Structural rule
304
+ const { positive, forbidden_context, required_context } = ruleSpec;
305
+ if (positive && !new RegExp(positive, 'i').test(content)) {
306
+ return { matched: false, reason: 'positive marker missing' };
307
+ }
308
+ if (forbidden_context && new RegExp(forbidden_context, 'i').test(content)) {
309
+ return { matched: false, reason: 'rule appears in a permissive context (e.g., "may use" or "emergency"); rule is FORBID, not MENTION' };
310
+ }
311
+ if (required_context && !new RegExp(required_context, 'i').test(content)) {
312
+ return { matched: false, reason: 'rule mentioned but not in a forbidding context (NEVER, forbidden, MUST NOT, prohibited)' };
313
+ }
314
+ return { matched: true };
315
+ }
316
+
317
+ module.exports = {
318
+ evaluateRule,
319
+ PIPELINE_STEPS,
320
+ CANONICAL_RULES,
321
+ validatePipeline,
322
+ };