@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,282 @@
1
+ 'use strict';
2
+ /**
3
+ * story-classifier.js — SC-001 (#user-request 2026-05-05): pure helper
4
+ * that recommends SDLC track + agent invocation per story.
5
+ *
6
+ * Inputs (9 fields per architect plan):
7
+ * - type: bug | enhancement | feature | refactor | docs | chore | meta-epic
8
+ * - adopter_blast_radius: zero | low | medium | high | critical
9
+ * - surface_area: single-file | single-module | multi-module | cross-package | external-dep
10
+ * - estimate: XS | S | M | L | XL
11
+ * - design_options: "1" | "2-3" | "many"
12
+ * - recently_modified_overlap: bool
13
+ * - cross_repo_dependency: bool
14
+ * - has_test_matrix: simple | parametrized | exhaustive
15
+ * - has_security_implications: bool
16
+ * - is_first_of_its_kind: bool
17
+ *
18
+ * Output:
19
+ * { track, architect: { engage, axes }, agents, artifacts, rationale, inputs_echo }
20
+ *
21
+ * Pipeline-of-rules architecture (per AU-001-L1):
22
+ * Pass 1: shouldEngageArchitect
23
+ * Pass 2: determineTrack
24
+ * Pass 3: recommendAgents
25
+ *
26
+ * Pure-helper-with-injected-IO style (Category B per cli/lib/README.md):
27
+ * - No filesystem, no network, no process.exit
28
+ * - All inputs explicit; no environment lookups
29
+ * - Same shape as cli/lib/skill-audit.js, cli/lib/learnings-audit.js
30
+ *
31
+ * Issue: user-request 2026-05-05 (SC-001 family — Phase 1).
32
+ */
33
+
34
+ // ─── Enums (frozen, exported for test-shape assertions) ───
35
+
36
+ const TRACKS = Object.freeze({
37
+ HOT_FIX: 'hot-fix',
38
+ FULL_SDLC: 'full-sdlc',
39
+ FAST_TRACK: 'fast-track',
40
+ });
41
+
42
+ const ARCHITECT_AXES = Object.freeze({
43
+ CROSS_PACKAGE: 'cross-package',
44
+ MULTIPLE_DESIGN_OPTIONS: 'multiple-design-options',
45
+ META_OR_EPIC: 'meta-or-epic',
46
+ RECENTLY_MODIFIED: 'recently-modified-overlap',
47
+ FIRST_OF_ITS_KIND: 'first-of-its-kind',
48
+ EXTERNAL_COORDINATION: 'external-coordination',
49
+ CROSS_REPO_DEPENDENCY: 'cross-repo-dependency',
50
+ SECURITY_IMPLICATIONS: 'security-implications',
51
+ });
52
+
53
+ // Estimate ordering for threshold comparisons
54
+ const ESTIMATE_ORDER = ['XS', 'S', 'M', 'L', 'XL'];
55
+ function estimateGreaterOrEqual(a, b) {
56
+ const ai = ESTIMATE_ORDER.indexOf(String(a || '').toUpperCase());
57
+ const bi = ESTIMATE_ORDER.indexOf(String(b || '').toUpperCase());
58
+ if (ai < 0 || bi < 0) return false;
59
+ return ai >= bi;
60
+ }
61
+
62
+ // Default thresholds (some configurable via opts; SAFETY-CRITICAL ones not)
63
+ const DEFAULT_THRESHOLDS = Object.freeze({
64
+ estimate_full_sdlc: 'L', // configurable; team risk tolerance
65
+ recently_modified_window_days: 14, // configurable
66
+ });
67
+
68
+ // ─── Pass 1: Architect engagement (any-of) ───
69
+
70
+ function shouldEngageArchitect(input) {
71
+ const axes = [];
72
+ // Cross-package or external-dep surface → architect for sequencing
73
+ if (input.surface_area === 'cross-package' || input.surface_area === 'external-dep') {
74
+ axes.push(ARCHITECT_AXES.CROSS_PACKAGE);
75
+ }
76
+ // Multiple design options → architect for tradeoff analysis
77
+ if (input.design_options === 'many' || input.design_options === '2-3') {
78
+ axes.push(ARCHITECT_AXES.MULTIPLE_DESIGN_OPTIONS);
79
+ }
80
+ // Meta / epic → architect for decomposition
81
+ if (input.type === 'meta-epic') {
82
+ axes.push(ARCHITECT_AXES.META_OR_EPIC);
83
+ }
84
+ // Recently-modified file overlap → architect for gap analysis vs prior PR
85
+ if (input.recently_modified_overlap === true) {
86
+ axes.push(ARCHITECT_AXES.RECENTLY_MODIFIED);
87
+ }
88
+ // First-of-its-kind pattern → architect for novel-pattern review
89
+ if (input.is_first_of_its_kind === true) {
90
+ axes.push(ARCHITECT_AXES.FIRST_OF_ITS_KIND);
91
+ }
92
+ // Cross-repo dependency → architect for upstream coordination
93
+ if (input.cross_repo_dependency === true) {
94
+ axes.push(ARCHITECT_AXES.CROSS_REPO_DEPENDENCY);
95
+ }
96
+ // Security implications → architect for risk review
97
+ if (input.has_security_implications === true) {
98
+ axes.push(ARCHITECT_AXES.SECURITY_IMPLICATIONS);
99
+ }
100
+ return { engage: axes.length > 0, axes };
101
+ }
102
+
103
+ // ─── Pass 2: Track determination (priority order) ───
104
+
105
+ function determineTrack(input, thresholds) {
106
+ const reasons = [];
107
+
108
+ // ── SAFETY-CRITICAL RULES (not configurable) ──
109
+
110
+ // R1: security implications → full-sdlc, never fast-track or hot-fix.
111
+ // HC-009: moved above the hot-fix rule. Security bugs need full-sdlc
112
+ // review even when blast radius + surface area would qualify for hot-fix.
113
+ if (input.has_security_implications === true) {
114
+ return {
115
+ track: TRACKS.FULL_SDLC,
116
+ reason: 'has_security_implications=true → full-sdlc (safety-critical, framework-fixed)',
117
+ };
118
+ }
119
+
120
+ // R2: bug + high-or-critical adopter blast + single surface → hot-fix path.
121
+ // Architect's "100% on hot-fix safety axis" target.
122
+ // HC-009: now checked AFTER R1 (security). A security bug always gets
123
+ // full-sdlc regardless of blast radius or surface area.
124
+ if (input.type === 'bug'
125
+ && (input.adopter_blast_radius === 'high' || input.adopter_blast_radius === 'critical')
126
+ && (input.surface_area === 'single-file' || input.surface_area === 'single-module')) {
127
+ return {
128
+ track: TRACKS.HOT_FIX,
129
+ reason: `bug + adopter_blast_radius=${input.adopter_blast_radius} + ${input.surface_area} → hot-fix path (safety-critical, framework-fixed)`,
130
+ };
131
+ }
132
+
133
+ // R3: first-of-its-kind → full-sdlc.
134
+ if (input.is_first_of_its_kind === true) {
135
+ return {
136
+ track: TRACKS.FULL_SDLC,
137
+ reason: 'is_first_of_its_kind=true → full-sdlc (architect engagement axis)',
138
+ };
139
+ }
140
+
141
+ // ── CONFIGURABLE RULES ──
142
+
143
+ // R4: large estimate → full-sdlc (configurable threshold)
144
+ const fullSdlcEstimate = thresholds.estimate_full_sdlc || DEFAULT_THRESHOLDS.estimate_full_sdlc;
145
+ if (estimateGreaterOrEqual(input.estimate, fullSdlcEstimate)) {
146
+ return {
147
+ track: TRACKS.FULL_SDLC,
148
+ reason: `estimate=${input.estimate} ≥ ${fullSdlcEstimate} → full-sdlc (configurable threshold)`,
149
+ };
150
+ }
151
+
152
+ // R5: recently-modified overlap → full-sdlc, BUT ONLY when the work
153
+ // has real blast radius. Tiny framework-tooling iterations
154
+ // (RP-002..005 style: XS, blast_radius=zero, no adopter impact) get
155
+ // multiple sequential PRs touching the same file but don't need full
156
+ // SDLC ceremony each time. Calibration finding from SC-001 corpus.
157
+ // Architect engagement still triggers (axes include "recently-modified");
158
+ // the team just doesn't need full step-0..5 artifacts for an XS iteration.
159
+ if (input.recently_modified_overlap === true
160
+ && input.adopter_blast_radius !== 'zero'
161
+ && input.estimate !== 'XS') {
162
+ return {
163
+ track: TRACKS.FULL_SDLC,
164
+ reason: 'recently_modified_overlap=true (with non-zero blast or non-XS estimate) → full-sdlc (parametrized regression coverage)',
165
+ };
166
+ }
167
+
168
+ // R6: exhaustive test matrix → full-sdlc
169
+ if (input.has_test_matrix === 'exhaustive') {
170
+ return {
171
+ track: TRACKS.FULL_SDLC,
172
+ reason: 'has_test_matrix=exhaustive → full-sdlc',
173
+ };
174
+ }
175
+
176
+ // R7: cross-package surface area + non-trivial → full-sdlc
177
+ if (input.surface_area === 'cross-package' && input.type !== 'chore') {
178
+ return {
179
+ track: TRACKS.FULL_SDLC,
180
+ reason: 'surface_area=cross-package + non-chore → full-sdlc',
181
+ };
182
+ }
183
+
184
+ // R8 (default): fast-track
185
+ return {
186
+ track: TRACKS.FAST_TRACK,
187
+ reason: 'no escalation rules matched → fast-track (default)',
188
+ };
189
+ }
190
+
191
+ // ─── Pass 3: Per-step agent + artifact recommendation ───
192
+
193
+ function recommendAgents(input, track) {
194
+ const agents = [];
195
+ const artifacts = [];
196
+
197
+ // HC-013b: hot-fix is MINIMAL — speed is critical, skip grooming + plan.
198
+ if (track === TRACKS.HOT_FIX) {
199
+ agents.push('unit-test-writer', 'code-builder', 'code-reviewer');
200
+ artifacts.push('step-2-tests.md', 'step-4-implementation.md', 'step-5-review.md');
201
+ return { agents, artifacts };
202
+ }
203
+
204
+ // HC-013b: fast-track gets LIGHTWEIGHT grooming + plan (not skip them).
205
+ // Without a plan, the test-writer doesn't know WHAT to test. Without
206
+ // grooming, acceptance criteria are implicit. The cost of a lightweight
207
+ // plan (30 seconds) is far less than the cost of rework from skipping it.
208
+ //
209
+ // full-sdlc gets FULL grooming + plan (unchanged).
210
+ agents.push('story-groomer', 'implementation-planner', 'unit-test-writer');
211
+ artifacts.push('step-0-grooming.md', 'step-1-plan.md', 'step-2-tests.md');
212
+
213
+ // Skip E2E for CLI-internal / pure-helper / single-module surfaces.
214
+ // Add E2E for cross-package + non-doc/chore types.
215
+ const surface = input && input.surface_area;
216
+ const type = input && input.type;
217
+ if ((surface === 'multi-module' || surface === 'cross-package')
218
+ && type !== 'docs' && type !== 'chore') {
219
+ agents.push('e2e-qa-planner');
220
+ artifacts.push('step-3-e2e-plan.md');
221
+ }
222
+
223
+ agents.push('code-builder', 'code-reviewer');
224
+ artifacts.push('step-4-implementation.md', 'step-5-review.md');
225
+
226
+ return { agents, artifacts };
227
+ }
228
+
229
+ // ─── Public API ───
230
+
231
+ /**
232
+ * Classify a story by SDLC track + architect engagement axes.
233
+ *
234
+ * @param {object} input - 9 classification fields (see file header)
235
+ * @param {object} [thresholds] - optional adopter overrides (estimate_full_sdlc,
236
+ * recently_modified_window_days). SAFETY-CRITICAL
237
+ * rules ignore overrides.
238
+ * @returns {{
239
+ * track: string,
240
+ * architect: { engage: boolean, axes: string[] },
241
+ * agents: string[],
242
+ * artifacts: string[],
243
+ * rationale: string,
244
+ * inputs_echo: object,
245
+ * }}
246
+ */
247
+ function classifyStory(input, thresholds) {
248
+ // Defensive: handle null/undefined/non-object inputs
249
+ const safeInput = (input && typeof input === 'object') ? input : {};
250
+ const safeThresholds = (thresholds && typeof thresholds === 'object') ? thresholds : {};
251
+
252
+ const architect = shouldEngageArchitect(safeInput);
253
+ const trackResult = determineTrack(safeInput, safeThresholds);
254
+ const { agents, artifacts } = recommendAgents(safeInput, trackResult.track);
255
+
256
+ // Build rationale: combine track decision + architect axes
257
+ const rationaleParts = [trackResult.reason];
258
+ if (architect.engage) {
259
+ rationaleParts.push(`architect engagement axes: [${architect.axes.join(', ')}]`);
260
+ }
261
+ const rationale = rationaleParts.join(' | ');
262
+
263
+ return {
264
+ track: trackResult.track,
265
+ architect,
266
+ agents,
267
+ artifacts,
268
+ rationale,
269
+ inputs_echo: { ...safeInput },
270
+ };
271
+ }
272
+
273
+ module.exports = {
274
+ classifyStory,
275
+ shouldEngageArchitect,
276
+ determineTrack,
277
+ recommendAgents,
278
+ TRACKS,
279
+ ARCHITECT_AXES,
280
+ DEFAULT_THRESHOLDS,
281
+ estimateGreaterOrEqual,
282
+ };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+ /**
3
+ * sync-overwrite.js — H-024 pure helper for `hone sync`.
4
+ *
5
+ * Detects whether a local file has been edited away from the
6
+ * server-delivered version, so we can warn before overwriting and
7
+ * silently destroying local additions (the OPS-2 / E13-A case).
8
+ *
9
+ * Pure function — no I/O. The CLI shell handles fs reads + writes.
10
+ */
11
+
12
+ /**
13
+ * @param {string} serverContent — what `hone sync` is about to write
14
+ * @param {string|null} localContent — current file contents, or null if file doesn't exist
15
+ * @returns {{ kind: 'new'|'identical'|'modified', hasLocalEdits: boolean }}
16
+ */
17
+ function detectLocalEdits(serverContent, localContent) {
18
+ if (localContent === null || localContent === undefined) {
19
+ return { kind: 'new', hasLocalEdits: false };
20
+ }
21
+ if (normalize(serverContent) === normalize(localContent)) {
22
+ return { kind: 'identical', hasLocalEdits: false };
23
+ }
24
+ return { kind: 'modified', hasLocalEdits: true };
25
+ }
26
+
27
+ /**
28
+ * Normalize line endings + trailing whitespace so we don't false-positive
29
+ * on CRLF↔LF or editor-stripped trailing spaces. Keep it simple: this is
30
+ * a "should we warn" gate, not a content-equality contract.
31
+ */
32
+ function normalize(s) {
33
+ return String(s).replace(/\r\n/g, '\n').replace(/[ \t]+$/gm, '').trimEnd();
34
+ }
35
+
36
+ /**
37
+ * Build a single-line warning the CLI prints when refusing to overwrite.
38
+ *
39
+ * @param {string} relPath — relative path the user will recognize
40
+ * @returns {string}
41
+ */
42
+ function formatSkipMessage(relPath) {
43
+ return ` ⚠ skipping ${relPath} — local edits not on server. ` +
44
+ `Use \`hone promote\` to upstream them, or pass \`--force\` to overwrite.`;
45
+ }
46
+
47
+ module.exports = { detectLocalEdits, formatSkipMessage, normalize };
@@ -0,0 +1,299 @@
1
+ 'use strict';
2
+ /**
3
+ * synthetic-pipeline.js — H-086 synthetic SDLC verification fixtures + verifier.
4
+ *
5
+ * The CI-grade harness for "did the editor actually APPLY rule X" verification.
6
+ * Pure helper: generates synthetic story fixtures with expected rule-applied
7
+ * markers, and verifies captured pipeline output against them.
8
+ *
9
+ * M scope:
10
+ * - SYNTHETIC_FIXTURES — library of synthetic stories, each with known
11
+ * properties + expected markers per pipeline step
12
+ * - generateFixture(scenario) — produces { story, expectedMarkers }
13
+ * - verifyMarkers({ capturedOutputs, expectedMarkers }) — asserts each
14
+ * expected marker appears in the matching step's captured output
15
+ *
16
+ * L scope deferred:
17
+ * - Per-editor runtime drivers (Claude CLI invocation, Copilot probe, etc.)
18
+ * - CI workflow cron template
19
+ * - Slack/issue notification on regression
20
+ * - enterprise/SDLC_VERIFICATION.md doc
21
+ *
22
+ * Closes #86 (M scope).
23
+ */
24
+
25
+ /**
26
+ * Library of synthetic story fixtures. Each fixture exercises a SPECIFIC
27
+ * rule that should fire when the story is run through the pipeline.
28
+ *
29
+ * Fixture shape:
30
+ * {
31
+ * name: string,
32
+ * description: string,
33
+ * story_metadata: object // gets serialized into step-0 grooming
34
+ * expected_markers: {
35
+ * [step_name]: string[] // patterns (case-insensitive substrings) that should appear in that step's output
36
+ * }
37
+ * }
38
+ */
39
+ const SYNTHETIC_FIXTURES = Object.freeze({
40
+ 'fix-with-regression-policy': {
41
+ name: 'fix-with-regression-policy',
42
+ description: 'Fix story with fix_for set — Step 5 must apply E24-A regression policy + capture learning',
43
+ story_metadata: {
44
+ story_id: 'SYN-001',
45
+ story_type: 'bug_fix',
46
+ fix_for: 'SYN-prior-001',
47
+ files_touched: ['src/handler.js', 'tests/regression/SYN-001.test.js'],
48
+ },
49
+ // HS-002: structural assertions (was: substring 'regression' / 'learnings')
50
+ // Empirically verified against real shipped step-5-review.md files:
51
+ // - "regression: 7/7" / "5/5 pass" count pattern (proves TDD-Green)
52
+ // - "1 enterprise-candidate (...)" bullet (proves Capture Learnings)
53
+ // Prose-only "we wrote regression tests" without count/structure → REJECTED.
54
+ expected_markers: {
55
+ 'step-5-review': [
56
+ '(regression|tests?)\\b.*\\d+\\s*\\/\\s*\\d+|\\d+\\s*\\/\\s*\\d+.*(regression|tests?)\\b',
57
+ '\\b\\d+\\s+enterprise[-\\s]candidate',
58
+ ],
59
+ 'step-1-plan': [
60
+ 'tests/regression', // explicit path, not just word "regression"
61
+ ],
62
+ },
63
+ },
64
+ 'feature-touching-source': {
65
+ name: 'feature-touching-source',
66
+ description: 'Feature story touching source — Step 5 should not require regression test BUT must still capture learnings',
67
+ story_metadata: {
68
+ story_id: 'SYN-002',
69
+ story_type: 'feature_add',
70
+ files_touched: ['src/feature.js', 'tests/regression/SYN-002.test.js'],
71
+ },
72
+ // HS-002: structural assertion (was: substring 'learnings').
73
+ // Real convention: "N enterprise-candidate (X-LN: title)" bullet.
74
+ expected_markers: {
75
+ 'step-5-review': [
76
+ '\\b\\d+\\s+enterprise[-\\s]candidate',
77
+ ],
78
+ },
79
+ },
80
+ 'pure-config-change': {
81
+ name: 'pure-config-change',
82
+ description: 'Pure declarative metadata story — Step 3a should tag scenario as CONFIG (not BROWSER/API)',
83
+ story_metadata: {
84
+ story_id: 'SYN-003',
85
+ story_type: 'feature_add',
86
+ files_touched: ['force-app/main/default/profiles/Admin.profile-meta.xml'],
87
+ },
88
+ // HS-002: structural assertions (was: bare 'CONFIG' + 'sandbox|deploy').
89
+ // CONFIG must appear in a tagging context (table/list/category), not prose.
90
+ // Strategy must co-locate sandbox-or-metadata with deploy/smoke/round-trip.
91
+ expected_markers: {
92
+ 'step-3-e2e-plan': [
93
+ '(tag|type|category|classify|scenario|test.type).{0,40}\\bCONFIG\\b|\\bCONFIG\\b.{0,40}(tag|type|category|test.type)',
94
+ '(sandbox|metadata\\s*API).{0,80}(deploy|smoke|round.?trip|verify)|(deploy|smoke).{0,80}(sandbox|metadata)',
95
+ ],
96
+ },
97
+ },
98
+ 'agent-prompt-edit-needs-step-5c': {
99
+ name: 'agent-prompt-edit-needs-step-5c',
100
+ description: 'Story editing agent prompt — Step 5c CI Gate Verification must be invoked after PR creation',
101
+ story_metadata: {
102
+ story_id: 'SYN-004',
103
+ story_type: 'feature_add',
104
+ files_touched: ['scripts/seed-agent-prompts.js'],
105
+ },
106
+ // HS-004 RESOLUTION (chicken-and-egg fixed):
107
+ // Per the agent prompt at scripts/seed-agent-prompts.js:775, Step 5c
108
+ // writes its gate matrix to `.github/pipeline/<STORY-ID>/step-5c-ci.md`
109
+ // — a SEPARATE artifact, not step-5-review.md. The previous fixture
110
+ // (HS-001) asserted against the wrong file (step-5-review.md), which
111
+ // is why no shipped artifact ever satisfied it. Per architect plan #121,
112
+ // HS-004: convention establishment + backfill before re-asserting.
113
+ //
114
+ // Backfill: H-076's `.github/pipeline/H-076/step-5c-ci.md` was created
115
+ // (the story that introduced Step 5c per its own convention).
116
+ //
117
+ // Markers (all 3 required, structural):
118
+ // 1. `## Gate matrix` heading (anchored, line-start via /m flag) —
119
+ // the canonical artifact section per the agent-prompt template
120
+ // 2. `gh pr checks` polling command must be referenced
121
+ // 3. Concrete gate result in either word order
122
+ expected_markers: {
123
+ 'step-5c-ci': [
124
+ '^##\\s+Gate matrix', // Canonical artifact heading (anchored via /m flag)
125
+ 'gh pr checks', // Polling command must be referenced
126
+ // Concrete gate result, accepting either word order:
127
+ // "- gate: PASS" / "Regression suite | ✅ PASS" — gate-first
128
+ // "- PASS gates" — result-first (artificial but valid)
129
+ '(PASS|FAIL)\\b.*\\b(gate|gates|check|checks)\\b|\\b(gate|gates|check|checks)\\b.*(PASS|FAIL)\\b',
130
+ ],
131
+ },
132
+ },
133
+ // SA-001 / #137 (architect plan, story 1/2): synthetic story whose implementation
134
+ // deliberately violates a skill assertion. Used by the Step 5b runtime
135
+ // harness regression once Story 2 wires the runner. Today: Story 1 ships
136
+ // the engine only; this fixture is the contract that Story 2 will satisfy.
137
+ //
138
+ // Assertion shape (versioned `version: 1`):
139
+ // - id: NO-CONSOLE-LOG-IN-SRC
140
+ // applies_to: "^src/"
141
+ // forbid_added: "console\\.log"
142
+ //
143
+ // The story metadata's `referenced_skill` lets the runner scope which
144
+ // skill's assertions to evaluate (LC-001 cross-reference field).
145
+ 'story-that-violates-skill': {
146
+ name: 'story-that-violates-skill',
147
+ description: 'Story that violates a referenced skill — Step 5b must emit a BLOCKER finding tied to the skill+assertion id',
148
+ story_metadata: {
149
+ story_id: 'SYN-005',
150
+ story_type: 'feature_add',
151
+ files_touched: ['src/handler.js'],
152
+ referenced_skill: 'error-handling',
153
+ },
154
+ // Markers (all 3 required, structural) — what Story 2's
155
+ // step-5b-skill-audit.md MUST contain when this fixture is run:
156
+ // 1. `## Skill audit` heading (the canonical artifact section)
157
+ // 2. The skill id that was scanned, paired with the assertion id
158
+ // 3. A concrete finding block with severity + matched file path
159
+ expected_markers: {
160
+ 'step-5b-skill-audit': [
161
+ '^##\\s+Skill audit', // Canonical artifact heading
162
+ '\\berror-handling\\b.*\\bNO-CONSOLE-LOG-IN-SRC\\b|\\bNO-CONSOLE-LOG-IN-SRC\\b.*\\berror-handling\\b',
163
+ '(BLOCKER|WARN|INFO)\\b.*\\bsrc/handler\\.js\\b',
164
+ ],
165
+ },
166
+ },
167
+ });
168
+
169
+ /**
170
+ * Generate a fixture for a named scenario. Returns the synthetic story +
171
+ * the expected-markers manifest the verifier will check.
172
+ *
173
+ * RR-003 (architect plan #117 story 3/4): when opts.includeOverlays is true
174
+ * and rule-resolver inputs are provided, adopter overlay rule_ids are added
175
+ * as additional markers in step-5-review (where the agent reports rules
176
+ * applied). Default behavior unchanged — backward compat for all existing
177
+ * callers.
178
+ *
179
+ * @param {string} scenarioName - key from SYNTHETIC_FIXTURES
180
+ * @param {object} [opts]
181
+ * @param {boolean} [opts.includeOverlays=false] - if true, add adopter overlay
182
+ * rule_ids to the fixture's expected_markers (requires repoRoot + fileExists
183
+ * + readFile + overlayPaths)
184
+ * @param {string} [opts.repoRoot]
185
+ * @param {(p:string)=>boolean} [opts.fileExists]
186
+ * @param {(p:string)=>string|null} [opts.readFile]
187
+ * @param {string[]} [opts.overlayPaths]
188
+ * @returns {{ story: object, expectedMarkers: object, description: string,
189
+ * adopterRulesIncluded?: string[] } | null}
190
+ */
191
+ function generateFixture(scenarioName, opts = {}) {
192
+ const fixture = SYNTHETIC_FIXTURES[scenarioName];
193
+ if (!fixture) return null;
194
+
195
+ // Deep-copy expected_markers so the caller can't mutate the frozen table
196
+ const expectedMarkers = {};
197
+ for (const [step, markers] of Object.entries(fixture.expected_markers)) {
198
+ expectedMarkers[step] = [...markers];
199
+ }
200
+
201
+ const result = {
202
+ story: { ...fixture.story_metadata },
203
+ expectedMarkers,
204
+ description: fixture.description,
205
+ };
206
+
207
+ // RR-003: extend with adopter overlay rule_ids if requested
208
+ if (opts.includeOverlays && typeof opts.fileExists === 'function' && typeof opts.readFile === 'function') {
209
+ try {
210
+ const { resolveRules } = require('./rule-resolver');
211
+ const resolved = resolveRules({
212
+ repoRoot: opts.repoRoot,
213
+ fileExists: opts.fileExists,
214
+ readFile: opts.readFile,
215
+ overlayPaths: Array.isArray(opts.overlayPaths) ? opts.overlayPaths : [],
216
+ });
217
+ const adopterRuleIds = resolved.overlays.map((o) => o.ruleId);
218
+ if (adopterRuleIds.length > 0) {
219
+ // Adopter rules should appear in step-5-review when the agent reports
220
+ // which rules it applied. Each rule_id becomes a substring marker.
221
+ const stepKey = 'step-5-review';
222
+ if (!expectedMarkers[stepKey]) expectedMarkers[stepKey] = [];
223
+ for (const ruleId of adopterRuleIds) {
224
+ // Don't duplicate if already present
225
+ if (!expectedMarkers[stepKey].includes(ruleId)) {
226
+ expectedMarkers[stepKey].push(ruleId);
227
+ }
228
+ }
229
+ result.adopterRulesIncluded = adopterRuleIds;
230
+ } else {
231
+ result.adopterRulesIncluded = [];
232
+ }
233
+ } catch {
234
+ // Defensive — resolveRules failure → silent fallback to canonical fixture
235
+ result.adopterRulesIncluded = [];
236
+ }
237
+ }
238
+
239
+ return result;
240
+ }
241
+
242
+ /**
243
+ * Verify that captured outputs contain all expected markers.
244
+ *
245
+ * @param {object} opts
246
+ * @param {object} opts.capturedOutputs - map: step_name → captured text
247
+ * @param {object} opts.expectedMarkers - map: step_name → array of regex/substring patterns
248
+ * @returns {{ passed: boolean, results: Array<{step, marker, found, capturedSample}> }}
249
+ */
250
+ function verifyMarkers(opts = {}) {
251
+ const { capturedOutputs = {}, expectedMarkers = {} } = opts;
252
+ const results = [];
253
+
254
+ for (const [step, markers] of Object.entries(expectedMarkers)) {
255
+ const captured = capturedOutputs[step];
256
+ if (typeof captured !== 'string') {
257
+ // No output captured for this step — every marker counts as not found
258
+ for (const marker of markers) {
259
+ results.push({
260
+ step,
261
+ marker,
262
+ found: false,
263
+ reason: `no captured output for step "${step}"`,
264
+ });
265
+ }
266
+ continue;
267
+ }
268
+ for (const marker of markers) {
269
+ // marker may be a regex string (with | alternation) or a literal substring.
270
+ // Flags: i = case-insensitive; m = ^/$ anchor to line-start/line-end (multiline).
271
+ // The /m flag is REQUIRED for markers like `^##\s+Step 5c` to match a heading
272
+ // that appears on a non-first line of a multiline file. Without /m, ^ anchors
273
+ // to string-start, which only matches if the heading starts at byte 0.
274
+ // Bug fix in PR following PR #120 (which forgot the /m flag).
275
+ let found;
276
+ try {
277
+ const re = new RegExp(marker, 'im');
278
+ found = re.test(captured);
279
+ } catch {
280
+ found = captured.toLowerCase().includes(marker.toLowerCase());
281
+ }
282
+ results.push({
283
+ step,
284
+ marker,
285
+ found,
286
+ capturedSample: found ? '(present)' : captured.slice(0, 100) + '...',
287
+ });
288
+ }
289
+ }
290
+
291
+ const passed = results.every((r) => r.found);
292
+ return { passed, results };
293
+ }
294
+
295
+ module.exports = {
296
+ SYNTHETIC_FIXTURES,
297
+ generateFixture,
298
+ verifyMarkers,
299
+ };