wogiflow 2.26.2 → 2.29.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 (164) hide show
  1. package/.claude/commands/wogi-bug.md +30 -0
  2. package/.claude/commands/wogi-debug-hypothesis.md +33 -0
  3. package/.claude/commands/wogi-morning.md +1 -2
  4. package/.claude/commands/wogi-review.md +31 -2
  5. package/.claude/commands/wogi-start.md +32 -0
  6. package/.claude/commands/wogi-statusline-setup.md +12 -0
  7. package/.claude/commands/wogi-story.md +3 -2
  8. package/.claude/docs/claude-code-compatibility.md +40 -0
  9. package/.claude/docs/phases/01-explore.md +2 -1
  10. package/.claude/docs/phases/03-implement.md +4 -0
  11. package/.claude/docs/phases/04-verify.md +45 -0
  12. package/.claude/rules/README.md +36 -0
  13. package/.claude/rules/_internal/worker-tool-first-turn.md +82 -0
  14. package/.claude/rules/alternative-execpolicy-toml-command-policy.md +11 -0
  15. package/.claude/rules/alternative-hand-edit-ready-json-to-register-orpha.md +11 -0
  16. package/.claude/rules/alternative-permission-ruleset-per-phase.md +11 -0
  17. package/.claude/rules/alternative-short-name.md +12 -0
  18. package/.claude/rules/alternative-wogi-flow-as-mcp-client-oauth-manager.md +11 -0
  19. package/.claude/rules/architecture/hook-three-layer.md +68 -0
  20. package/.claude/rules/dual-repo-architecture-2026-02-28.md +18 -0
  21. package/.claude/rules/github-release-workflow-2026-01-30.md +16 -0
  22. package/.claude/settings.json +1 -1
  23. package/.workflow/agents/logic-adversary.md +2 -1
  24. package/.workflow/agents/personas/README.md +48 -0
  25. package/.workflow/agents/personas/platform-rigor.md +38 -0
  26. package/.workflow/agents/personas/scale-skeptic.md +28 -0
  27. package/.workflow/agents/personas/security-hawk.md +34 -0
  28. package/.workflow/agents/personas/simplicity-champion.md +37 -0
  29. package/.workflow/agents/personas/user-advocate.md +36 -0
  30. package/.workflow/bridges/base-bridge.js +46 -23
  31. package/.workflow/templates/claude-md.hbs +44 -122
  32. package/.workflow/templates/partials/feature-dossiers.hbs +33 -0
  33. package/.workflow/templates/partials/intent-grounded-reasoning.hbs +2 -12
  34. package/.workflow/templates/partials/methodology-rules.hbs +85 -79
  35. package/.workflow/templates/tier3-dom-field-inventory.md +102 -0
  36. package/lib/fuzzy-patch.js +251 -0
  37. package/lib/installer.js +8 -0
  38. package/lib/memory-proposal-store.js +458 -0
  39. package/lib/mode-schema.js +255 -0
  40. package/lib/skill-proposal-store.js +432 -0
  41. package/lib/skill-registry.js +1 -1
  42. package/lib/wogi-claude +84 -9
  43. package/lib/wogi-claude-expect.exp +113 -76
  44. package/lib/workspace-channel-server.js +19 -0
  45. package/lib/workspace-contracts.js +1 -1
  46. package/lib/workspace-dispatch-tracking.js +144 -0
  47. package/lib/workspace-gates.js +1 -1
  48. package/lib/workspace-ipc-sqlite.js +550 -0
  49. package/lib/workspace-messages.js +92 -0
  50. package/lib/workspace-routing.js +1 -1
  51. package/lib/workspace-task-injector.js +223 -0
  52. package/lib/workspace.js +23 -0
  53. package/lib/worktree-review.js +315 -0
  54. package/package.json +2 -2
  55. package/scripts/base-workflow-step.js +1 -1
  56. package/scripts/flow +28 -4
  57. package/scripts/flow-ac-scope-preservation.js +238 -0
  58. package/scripts/flow-auto-review-worker.js +75 -0
  59. package/scripts/flow-auto-review.js +102 -0
  60. package/scripts/flow-autonomous-detector.js +118 -0
  61. package/scripts/flow-autonomous-mode.js +153 -0
  62. package/scripts/flow-best-of-n.js +1 -1
  63. package/scripts/flow-bulk-loop.js +1 -1
  64. package/scripts/flow-checkpoint.js +2 -6
  65. package/scripts/flow-community-sync.js +1 -1
  66. package/scripts/flow-completion-summary.js +176 -0
  67. package/scripts/flow-completion-truth-gate.js +343 -4
  68. package/scripts/flow-config-defaults.js +52 -5
  69. package/scripts/flow-context-compact/expander.js +1 -1
  70. package/scripts/flow-context-compact/section-extractor.js +2 -2
  71. package/scripts/flow-context-gatherer.js +1 -1
  72. package/scripts/flow-context-generator.js +1 -1
  73. package/scripts/flow-context-scoring.js +1 -1
  74. package/scripts/flow-correct.js +1 -1
  75. package/scripts/flow-decision-authority.js +66 -15
  76. package/scripts/flow-done.js +33 -1
  77. package/scripts/flow-epic-cascade.js +171 -0
  78. package/scripts/flow-epics.js +2 -7
  79. package/scripts/flow-eval-judge.js +1 -1
  80. package/scripts/flow-eval.js +1 -1
  81. package/scripts/flow-export-scanner.js +2 -6
  82. package/scripts/flow-failure-learning.js +1 -1
  83. package/scripts/flow-feature-dossier.js +787 -0
  84. package/scripts/flow-figma-extract.js +2 -2
  85. package/scripts/flow-figma-generate.js +1 -1
  86. package/scripts/flow-gate-confidence.js +1 -1
  87. package/scripts/flow-health.js +52 -1
  88. package/scripts/flow-hooks.js +1 -1
  89. package/scripts/flow-id.js +19 -3
  90. package/scripts/flow-instruction-richness.js +1 -1
  91. package/scripts/flow-knowledge-router.js +1 -1
  92. package/scripts/flow-knowledge-sync.js +1 -1
  93. package/scripts/flow-logic-adversary.js +76 -1
  94. package/scripts/flow-logic-rules.js +380 -0
  95. package/scripts/flow-long-input.js +5 -5
  96. package/scripts/flow-memory-sync.js +1 -1
  97. package/scripts/flow-memory.js +78 -7
  98. package/scripts/flow-migrate.js +1 -1
  99. package/scripts/flow-model-caller.js +1 -1
  100. package/scripts/flow-models.js +2 -2
  101. package/scripts/flow-morning.js +0 -17
  102. package/scripts/flow-multi-approach.js +1 -1
  103. package/scripts/flow-orchestrate-context.js +4 -4
  104. package/scripts/flow-orchestrate-templates.js +1 -1
  105. package/scripts/flow-orchestrate.js +8 -8
  106. package/scripts/flow-peer-review.js +1 -1
  107. package/scripts/flow-phase.js +9 -0
  108. package/scripts/flow-proactive-compact.js +1 -1
  109. package/scripts/flow-providers.js +1 -1
  110. package/scripts/flow-question-queue.js +255 -0
  111. package/scripts/flow-repo-map.js +312 -0
  112. package/scripts/flow-review-passes/index.js +1 -1
  113. package/scripts/flow-review-passes/integration.js +1 -1
  114. package/scripts/flow-review-passes/structure.js +1 -1
  115. package/scripts/flow-revision-tracker.js +1 -1
  116. package/scripts/flow-section-resolver.js +1 -1
  117. package/scripts/flow-session-end.js +74 -5
  118. package/scripts/flow-session-state.js +103 -1
  119. package/scripts/flow-setup-hooks.js +1 -1
  120. package/scripts/flow-skeptical-evaluator.js +274 -0
  121. package/scripts/flow-skill-generator.js +3 -3
  122. package/scripts/flow-skill-learn.js +3 -6
  123. package/scripts/flow-skill-manage.js +248 -0
  124. package/scripts/flow-spec-verifier.js +1 -1
  125. package/scripts/flow-standards-checker.js +75 -0
  126. package/scripts/flow-standards-gate.js +1 -1
  127. package/scripts/flow-statusline-setup.js +8 -2
  128. package/scripts/flow-step-changelog.js +2 -2
  129. package/scripts/flow-step-coverage.js +1 -1
  130. package/scripts/flow-step-knowledge.js +1 -1
  131. package/scripts/flow-step-regression.js +1 -1
  132. package/scripts/flow-step-simplifier.js +1 -1
  133. package/scripts/flow-task-analyzer.js +1 -1
  134. package/scripts/flow-task-classifier.js +1 -1
  135. package/scripts/flow-task-enforcer.js +1 -1
  136. package/scripts/flow-template-extractor.js +1 -1
  137. package/scripts/flow-trap-zone.js +1 -1
  138. package/scripts/flow-utils.js +4 -0
  139. package/scripts/flow-worker-question-classifier.js +51 -5
  140. package/scripts/flow-workspace-migrate-ipc.js +216 -0
  141. package/scripts/flow-workspace-summary.js +256 -0
  142. package/scripts/hooks/adapters/base-adapter.js +2 -2
  143. package/scripts/hooks/core/feature-dossier-gate.js +194 -0
  144. package/scripts/hooks/core/observation-capture.js +24 -0
  145. package/scripts/hooks/core/overdue-dispatches.js +20 -1
  146. package/scripts/hooks/core/phase-gate.js +15 -1
  147. package/scripts/hooks/core/phase-transition-auto-review.js +61 -0
  148. package/scripts/hooks/core/post-compact.js +5 -2
  149. package/scripts/hooks/core/pre-tool-orchestrator.js +21 -0
  150. package/scripts/hooks/core/routing-gate.js +58 -0
  151. package/scripts/hooks/core/session-context.js +108 -0
  152. package/scripts/hooks/core/session-end-memory-proposals.js +65 -0
  153. package/scripts/hooks/core/session-end-skill-proposals.js +58 -0
  154. package/scripts/hooks/core/session-end.js +25 -0
  155. package/scripts/hooks/core/setup-handler.js +1 -1
  156. package/scripts/hooks/core/task-boundary-reset.js +110 -4
  157. package/scripts/hooks/core/worker-boundary-gate.js +71 -0
  158. package/scripts/hooks/core/worker-tool-first-gate.js +275 -0
  159. package/scripts/hooks/entry/claude-code/post-tool-use.js +2 -2
  160. package/scripts/hooks/entry/claude-code/pre-tool-use.js +7 -2
  161. package/scripts/hooks/entry/claude-code/session-start.js +74 -30
  162. package/scripts/hooks/entry/claude-code/stop.js +47 -1
  163. package/scripts/hooks/entry/claude-code/user-prompt-submit.js +17 -0
  164. package/.workflow/templates/partials/user-commands.hbs +0 -20
@@ -22,7 +22,7 @@ const {
22
22
  safeJsonParse,
23
23
  writeJson,
24
24
  fileExists: _fileExists,
25
- readFile
25
+ _readFile
26
26
  } = require('./flow-utils')
27
27
  const { color, success, warn, error } = require('./flow-output');;
28
28
 
@@ -29,9 +29,6 @@ const { success: printSuccess, warn: printWarn } = require('./flow-output');
29
29
  const CHECKPOINTS_DIR = PATHS.checkpoints;
30
30
  const CHECKPOINT_LOG = path.join(CHECKPOINTS_DIR, 'checkpoint-log.json');
31
31
 
32
- // Alias getConfig as loadConfig for minimal code changes
33
- const loadConfig = getConfig;
34
-
35
32
  /**
36
33
  * Default checkpoint configuration
37
34
  */
@@ -385,8 +382,7 @@ function formatCheckpointList(checkpoints) {
385
382
  // Module exports
386
383
  module.exports = {
387
384
  Checkpoint,
388
- DEFAULT_CHECKPOINT_CONFIG,
389
- loadConfig
385
+ DEFAULT_CHECKPOINT_CONFIG
390
386
  };
391
387
 
392
388
  // CLI Handler
@@ -394,7 +390,7 @@ if (require.main === module) {
394
390
  const args = process.argv.slice(2);
395
391
  const command = args[0];
396
392
 
397
- const config = loadConfig();
393
+ const config = getConfig();
398
394
  const cp = new Checkpoint(config);
399
395
 
400
396
  switch (command) {
@@ -23,7 +23,7 @@ const {
23
23
  readJson,
24
24
  writeJson,
25
25
  fileExists: _fileExists,
26
- getTodayDate
26
+ _getTodayDate
27
27
  } = require('./flow-utils');
28
28
  const { createUploadPayload } = require('./flow-sync-anonymizer');
29
29
  const { loadStats } = require('./flow-stats-collector');
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow — Autonomous-Run Completion Summary (Story C / wf-d712002e)
5
+ *
6
+ * Renders the end-of-run summary for autonomous walk-away mode in two
7
+ * formats:
8
+ * 1. Human-readable terminal block (always 3 sections: completed,
9
+ * queued, skipped — empty-state placeholders rendered explicitly per
10
+ * decisions.md 2026-04-23 "vanishing-section" rule).
11
+ * 2. Structured JSON payload at
12
+ * .workflow/state/autonomous-run-summary-<runId>.json that Story B can
13
+ * base64-wrap into a single-line channel-dispatch message.
14
+ *
15
+ * Programmatic:
16
+ * const cs = require('./flow-completion-summary');
17
+ * const { terminal, jsonPath } = cs.renderSummary({ runId, ... });
18
+ * cs.writeJsonPayload(payload); // returns the path written
19
+ */
20
+
21
+ const path = require('node:path');
22
+ const { PATHS } = require('./flow-paths');
23
+ const { writeJson } = require('./flow-io');
24
+
25
+ const SEP = '━'.repeat(58);
26
+
27
+ function summaryPath(runId) {
28
+ return path.join(PATHS.state, `autonomous-run-summary-${runId}.json`);
29
+ }
30
+
31
+ function pad2(n) { return String(n).padStart(2, '0'); }
32
+
33
+ function formatDuration(startedAt, endedAt) {
34
+ if (!startedAt || !endedAt) return '0:00';
35
+ const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
36
+ if (!Number.isFinite(ms) || ms < 0) return '0:00';
37
+ const sec = Math.floor(ms / 1000);
38
+ const m = Math.floor(sec / 60);
39
+ const s = sec % 60;
40
+ if (m >= 60) {
41
+ const h = Math.floor(m / 60);
42
+ return `${h}:${pad2(m % 60)}:${pad2(s)}`;
43
+ }
44
+ return `${m}:${pad2(s)}`;
45
+ }
46
+
47
+ /**
48
+ * Build the full payload object — used for both terminal render and
49
+ * persisted JSON. Caller passes raw collected data; this normalizes shape.
50
+ *
51
+ * @param {object} input
52
+ * @param {string} input.runId
53
+ * @param {string} input.startedAt
54
+ * @param {string} [input.endedAt]
55
+ * @param {string} input.trigger
56
+ * @param {Array<{taskId:string,title:string}>} [input.completed]
57
+ * @param {Array<object>} [input.queuedQuestions]
58
+ * @param {Array<object>} [input.skippedTasks]
59
+ * @param {{used:number,cap:number,breakdown?:object}} [input.adversaryInvocations]
60
+ * @param {string} [input.endReason] - queue-drained | user-interrupt | fatal-error
61
+ */
62
+ function buildPayload(input) {
63
+ const endedAt = input.endedAt || new Date().toISOString();
64
+ return {
65
+ runId: input.runId,
66
+ startedAt: input.startedAt,
67
+ endedAt,
68
+ trigger: input.trigger || 'unspecified',
69
+ completed: Array.isArray(input.completed) ? input.completed : [],
70
+ queuedQuestions: Array.isArray(input.queuedQuestions) ? input.queuedQuestions : [],
71
+ skippedTasks: Array.isArray(input.skippedTasks) ? input.skippedTasks : [],
72
+ adversaryInvocations: {
73
+ used: input.adversaryInvocations?.used ?? 0,
74
+ cap: input.adversaryInvocations?.cap ?? 0,
75
+ breakdown: input.adversaryInvocations?.breakdown || {}
76
+ },
77
+ endReason: input.endReason || 'queue-drained'
78
+ };
79
+ }
80
+
81
+ function renderTerminal(payload) {
82
+ const lines = [];
83
+ lines.push(SEP);
84
+ lines.push(`AUTONOMOUS RUN COMPLETE (runId: ${payload.runId}, duration: ${formatDuration(payload.startedAt, payload.endedAt)})`);
85
+ lines.push(SEP);
86
+ lines.push('');
87
+
88
+ lines.push(`✓ Completed (${payload.completed.length} tasks):`);
89
+ if (payload.completed.length === 0) {
90
+ lines.push(' [none]');
91
+ } else {
92
+ for (const t of payload.completed) {
93
+ lines.push(` - ${t.taskId}: ${t.title || '(no title)'}`);
94
+ }
95
+ }
96
+ lines.push('');
97
+
98
+ lines.push(`? Queued questions (${payload.queuedQuestions.length}):`);
99
+ if (payload.queuedQuestions.length === 0) {
100
+ lines.push(' [none]');
101
+ } else {
102
+ for (const q of payload.queuedQuestions) {
103
+ const blockers = Array.isArray(q.dependencies) && q.dependencies.length
104
+ ? ` (blocks: ${q.dependencies.join(', ')})`
105
+ : '';
106
+ lines.push(` - ${q.id}: ${q.text}${blockers}`);
107
+ }
108
+ }
109
+ lines.push('');
110
+
111
+ lines.push(`⊘ Skipped tasks (${payload.skippedTasks.length}):`);
112
+ if (payload.skippedTasks.length === 0) {
113
+ lines.push(' [none]');
114
+ } else {
115
+ for (const s of payload.skippedTasks) {
116
+ const ref = s.blockingQuestionId ? ` (awaiting answer to ${s.blockingQuestionId})` : '';
117
+ lines.push(` - ${s.taskId}: ${s.reason}${ref}`);
118
+ }
119
+ }
120
+ lines.push('');
121
+
122
+ lines.push(`⚡ Adversary invocations: ${payload.adversaryInvocations.used} / ${payload.adversaryInvocations.cap} (cap)`);
123
+ lines.push(` reason: ${payload.endReason}`);
124
+ lines.push(SEP);
125
+ return lines.join('\n');
126
+ }
127
+
128
+ /**
129
+ * Persist the JSON payload to .workflow/state/autonomous-run-summary-<runId>.json.
130
+ * Returns the absolute path written.
131
+ */
132
+ function writeJsonPayload(payload) {
133
+ const p = summaryPath(payload.runId);
134
+ writeJson(p, payload);
135
+ return p;
136
+ }
137
+
138
+ function renderSummary(input, { writeJson: write = true } = {}) {
139
+ const payload = buildPayload(input);
140
+ const terminal = renderTerminal(payload);
141
+ let jsonPath = null;
142
+ if (write) {
143
+ jsonPath = writeJsonPayload(payload);
144
+ }
145
+ return { payload, terminal, jsonPath };
146
+ }
147
+
148
+ module.exports = {
149
+ buildPayload,
150
+ renderTerminal,
151
+ writeJsonPayload,
152
+ renderSummary,
153
+ summaryPath
154
+ };
155
+
156
+ if (require.main === module) {
157
+ const sample = {
158
+ runId: 'sample',
159
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
160
+ trigger: 'go until you finish',
161
+ completed: [
162
+ { taskId: 'wf-aaaaaaaa', title: 'Add team-id to API' },
163
+ { taskId: 'wf-bbbbbbbb', title: 'Update changelog' }
164
+ ],
165
+ queuedQuestions: [
166
+ { id: 'q-12345678', text: 'Should admins be charged?', dependencies: ['wf-cccccccc'] }
167
+ ],
168
+ skippedTasks: [
169
+ { taskId: 'wf-cccccccc', reason: 'awaiting answer', blockingQuestionId: 'q-12345678' }
170
+ ],
171
+ adversaryInvocations: { used: 4, cap: 30 },
172
+ endReason: 'queue-drained'
173
+ };
174
+ const { terminal } = renderSummary(sample, { writeJson: false });
175
+ console.log(terminal);
176
+ }
@@ -52,7 +52,7 @@ const gateTelemetry = require('./flow-gate-telemetry');
52
52
 
53
53
  // Lazy-loaded to keep Story 6 independently testable
54
54
  let _evidenceTiers;
55
- function getEvidenceTiers() {
55
+ function _getEvidenceTiers() {
56
56
  if (_evidenceTiers) return _evidenceTiers;
57
57
  try {
58
58
  _evidenceTiers = require('./flow-runtime-verification').EVIDENCE_TIERS;
@@ -282,7 +282,7 @@ function auditCompletionClaim(taskId, claimedCriteria) {
282
282
  const insufficient = perCriterion.filter((p) => p.claimedDone && !p.sufficient);
283
283
  const sufficient = perCriterion.filter((p) => p.claimedDone && p.sufficient);
284
284
 
285
- return {
285
+ const audit = {
286
286
  perCriterion,
287
287
  blocked: insufficient.length > 0 && shouldBlockOnFalseCompletion(),
288
288
  softModeWarn: insufficient.length > 0 && !shouldBlockOnFalseCompletion(),
@@ -292,6 +292,25 @@ function auditCompletionClaim(taskId, claimedCriteria) {
292
292
  totalClaimed: perCriterion.filter((p) => p.claimedDone).length,
293
293
  evidenceRecordsExisted,
294
294
  };
295
+
296
+ // wf-8d635d0e / E1: fold parallel-worktree auto-review findings into the
297
+ // audit. High-severity findings flip softModeWarn on so downgradeClaim
298
+ // rewrites language. Timeout → informational note only (AC5).
299
+ try {
300
+ const { summarizeFindingsForAudit } = require('../lib/worktree-review');
301
+ const summary = summarizeFindingsForAudit(taskId);
302
+ if (summary.present) {
303
+ audit.autoReview = summary;
304
+ if (summary.highSeverityCount > 0) {
305
+ audit.softModeWarn = true;
306
+ }
307
+ if (summary.timedOut) {
308
+ audit.softModeWarn = audit.softModeWarn || false;
309
+ }
310
+ }
311
+ } catch (_err) { /* fail-open — auto-review is advisory */ }
312
+
313
+ return audit;
295
314
  }
296
315
 
297
316
  function normalizeText(s) {
@@ -325,10 +344,25 @@ function downgradeClaim(originalText, audit) {
325
344
  const re = new RegExp(`\\b(${DONE_WORDS.join('|')})\\b`, 'gi');
326
345
  const rewritten = String(originalText || '').replace(re, downgradedWord);
327
346
 
328
- const banner =
347
+ let banner =
329
348
  `\n\n⚠ Completion Truth Gate: ${sufficientCount}/${totalClaimed} criteria reach the required ${tierName} (≥ Tier ${minTier}) evidence threshold. ` +
330
349
  `${insufficientCount} criteria are implemented but unverified — recommend manual verification before announcing completion.`;
331
350
 
351
+ // Auto-review findings addendum (wf-8d635d0e).
352
+ if (audit.autoReview && audit.autoReview.present) {
353
+ const ar = audit.autoReview;
354
+ if (ar.timedOut) {
355
+ banner += `\n⏱ Auto-review timed out (unverified-review-timeout) — no blocking signal from background review.`;
356
+ } else if (ar.highSeverityCount > 0) {
357
+ banner += `\n🔴 Auto-review: ${ar.highSeverityCount} high-severity finding(s) on changed lines.`;
358
+ for (const f of ar.topHighSeverity) {
359
+ banner += `\n - ${f.file || '?'}:${f.line || 0} — ${f.claim}`;
360
+ }
361
+ } else if ((ar.counts?.medium || 0) + (ar.counts?.low || 0) > 0) {
362
+ banner += `\n📝 Auto-review: ${ar.counts.medium} medium, ${ar.counts.low} low severity (informational).`;
363
+ }
364
+ }
365
+
332
366
  return {
333
367
  text: rewritten + banner,
334
368
  replaced: rewritten !== originalText,
@@ -477,7 +511,7 @@ const NEGATION_PREFIXES = /\b(?:no|zero|0|without(?: any)?|not a single)\s+/i;
477
511
  * not match.
478
512
  */
479
513
  const DISAGREEMENT_WORDS = ['outage', 'outages', 'incident', 'incidents', 'regression', 'regressions', 'rollback', 'rollbacks', 'revert', 'reverts', 'hotfix', 'hotfixes'];
480
- const DISAGREEMENT_RE = new RegExp(`\\b(?:${DISAGREEMENT_WORDS.join('|')})\\b`, 'i');
514
+ const _DISAGREEMENT_RE = new RegExp(`\\b(?:${DISAGREEMENT_WORDS.join('|')})\\b`, 'i');
481
515
 
482
516
  const PARTIAL_STATUSES = new Set(['completed-partial', 'completed_partial', 'partial', 'in-progress', 'in_progress', 'blocked', 'failed']);
483
517
 
@@ -741,6 +775,302 @@ function formatMissingClaimsMessage(result) {
741
775
  return lines.join('\n');
742
776
  }
743
777
 
778
+ // ============================================================
779
+ // Spec-String Bundle Grep (wf-07046456 / B4)
780
+ // ============================================================
781
+
782
+ /**
783
+ * Extract the "string bundle" from a spec: every named artifact the spec promises
784
+ * to produce, consume, or reference. The bundle is the set of concrete strings
785
+ * that MUST appear somewhere in the delivery (diff, changed files, bundle output)
786
+ * for the spec to be honored.
787
+ *
788
+ * Bundles extracted:
789
+ * - Backtick-quoted identifiers: `functionName`, `ConfigKey`, `module/path.js`
790
+ * - Double-quoted string literals: "exact error message", "button label"
791
+ * - File paths with extensions: foo/bar.js, .workflow/state/X.json
792
+ * - ALLCAPS_CONSTANTS
793
+ * - Route/URL paths: /api/v1/users, /dashboard/settings
794
+ *
795
+ * @param {string} specMarkdown
796
+ * @returns {{ backtickIds: string[], quotedStrings: string[], filePaths: string[], constants: string[], routes: string[], all: string[] }}
797
+ */
798
+ function extractSpecStrings(specMarkdown) {
799
+ if (typeof specMarkdown !== 'string' || specMarkdown.length === 0) {
800
+ return { backtickIds: [], quotedStrings: [], filePaths: [], constants: [], routes: [], all: [] };
801
+ }
802
+ // Strip code fences first so we don't double-count bodies of code blocks
803
+ const withoutFences = specMarkdown.replace(/```[\s\S]*?```/g, '');
804
+
805
+ const backtickIds = [...new Set(
806
+ [...withoutFences.matchAll(/`([^`\n]{2,80})`/g)].map((m) => m[1].trim())
807
+ .filter((s) => s.length >= 2 && !/^\s*$/.test(s))
808
+ )];
809
+ const quotedStrings = [...new Set(
810
+ [...withoutFences.matchAll(/"([^"\n]{3,120})"/g)].map((m) => m[1].trim())
811
+ .filter((s) => !/^(TODO|FIXME|XXX)$/i.test(s))
812
+ )];
813
+ const filePaths = [...new Set(
814
+ [...withoutFences.matchAll(/\b([\w./-]+\.(?:js|ts|tsx|jsx|md|json|yaml|yml|py|go|rs|sh|toml|hbs))\b/g)].map((m) => m[1])
815
+ )];
816
+ const constants = [...new Set(
817
+ // Require at least one underscore or digit — excludes bare HTTP verbs (POST/GET) and common all-caps words (JSON/HTML/CSV)
818
+ [...withoutFences.matchAll(/\b([A-Z][A-Z0-9]*_[A-Z0-9_]{1,40}|[A-Z_]{2,}\d+[A-Z0-9_]*)\b/g)].map((m) => m[1])
819
+ .filter((s) => !/^(TODO|FIXME|XXX|NOTE|HACK|TBD|WIP)$/.test(s))
820
+ )];
821
+ const routes = [...new Set(
822
+ [...withoutFences.matchAll(/(?:^|\s|`)(\/[a-z0-9][a-z0-9./_:-]{2,80})(?=[\s`"'.,]|$)/gmi)].map((m) => m[1])
823
+ .filter((s) => !s.startsWith('//') && !s.startsWith('/Users/') && !s.startsWith('/home/'))
824
+ )];
825
+
826
+ const all = [...new Set([...backtickIds, ...quotedStrings, ...filePaths, ...constants, ...routes])];
827
+ return { backtickIds, quotedStrings, filePaths, constants, routes, all };
828
+ }
829
+
830
+ /**
831
+ * Verify spec-string coverage against delivery.
832
+ *
833
+ * @param {object} opts
834
+ * @param {string} opts.specMarkdown
835
+ * @param {string} opts.diffText - git diff
836
+ * @param {string[]} [opts.changedFiles]
837
+ * @param {string} [opts.bundleText] - built-bundle text (minified) if available
838
+ * @param {string[]} [opts.additionalSources] - other text sources (e.g., commit message)
839
+ * @param {object} [opts.categoryMins] - minimum coverage ratio per category (default 0.8)
840
+ * @returns {{ ok: boolean, missingByCategory: object, coverage: object, strict: boolean }}
841
+ */
842
+ function verifySpecBundleCoverage({
843
+ specMarkdown,
844
+ diffText = '',
845
+ changedFiles = [],
846
+ bundleText = '',
847
+ additionalSources = [],
848
+ categoryMins = {},
849
+ }) {
850
+ const bundle = extractSpecStrings(specMarkdown);
851
+ const haystack = [diffText, bundleText, changedFiles.join('\n'), ...additionalSources].join('\n');
852
+ const defaults = { backtickIds: 0.8, quotedStrings: 0.7, filePaths: 1.0, constants: 0.8, routes: 1.0 };
853
+ const mins = { ...defaults, ...categoryMins };
854
+
855
+ const coverage = {};
856
+ const missingByCategory = {};
857
+ for (const cat of Object.keys(mins)) {
858
+ const items = bundle[cat] || [];
859
+ if (items.length === 0) { coverage[cat] = { total: 0, hit: 0, ratio: 1, threshold: mins[cat] }; missingByCategory[cat] = []; continue; }
860
+ const missing = items.filter((s) => !haystack.includes(s));
861
+ const hit = items.length - missing.length;
862
+ coverage[cat] = { total: items.length, hit, ratio: hit / items.length, threshold: mins[cat], missing };
863
+ missingByCategory[cat] = missing;
864
+ }
865
+
866
+ const strict = Object.entries(coverage).every(([, v]) => v.ratio >= v.threshold);
867
+ return { ok: strict, missingByCategory, coverage, strict };
868
+ }
869
+
870
+ /**
871
+ * Format spec-bundle verification as a human-readable report.
872
+ * @param {object} result
873
+ * @returns {string|null}
874
+ */
875
+ function formatSpecBundleResult(result) {
876
+ if (!result) return null;
877
+ const lines = [];
878
+ lines.push(result.ok ? 'Spec-bundle grep OK:' : 'Spec-bundle grep FAIL:');
879
+ for (const [cat, v] of Object.entries(result.coverage)) {
880
+ if (v.total === 0) continue;
881
+ const mark = v.ratio >= v.threshold ? '✓' : '✗';
882
+ lines.push(` ${mark} ${cat}: ${v.hit}/${v.total} (ratio ${v.ratio.toFixed(2)}, needs ${v.threshold.toFixed(2)})`);
883
+ if (v.ratio < v.threshold && v.missing.length > 0) {
884
+ lines.push(` missing: ${v.missing.slice(0, 6).map((s) => `"${s}"`).join(', ')}${v.missing.length > 6 ? ', ...' : ''}`);
885
+ }
886
+ }
887
+ return lines.join('\n');
888
+ }
889
+
890
+ // ============================================================
891
+ // BEL-file grep (wf-10c452f7 / B2) — Bulleted-Expectation List grep
892
+ // ============================================================
893
+
894
+ /**
895
+ * Parse a spec file for BEL (bulleted-expectation list) items. A BEL item is any
896
+ * top-level `- ` or `* ` bullet under an "Acceptance Criteria", "Expectations",
897
+ * "Requirements", or "Success Criteria" heading.
898
+ *
899
+ * @param {string} specMarkdown
900
+ * @returns {Array<{text: string, heading: string}>}
901
+ */
902
+ function parseBELItems(specMarkdown) {
903
+ if (typeof specMarkdown !== 'string' || specMarkdown.length === 0) return [];
904
+ const lines = specMarkdown.split('\n');
905
+ const belHeadingRe = /^(#{1,6})\s+(Acceptance Criteria|Expectations|Requirements|Success Criteria|Acceptance|Definition of Done|Criteria)\b/i;
906
+ const anyHeadingRe = /^#{1,6}\s+/;
907
+ const bulletRe = /^\s*[-*]\s+(.+)$/;
908
+ const items = [];
909
+
910
+ let inSection = false;
911
+ let currentHeading = '';
912
+ for (const line of lines) {
913
+ const belMatch = line.match(belHeadingRe);
914
+ if (belMatch) { inSection = true; currentHeading = belMatch[2]; continue; }
915
+ if (inSection && anyHeadingRe.test(line) && !line.match(belHeadingRe)) { inSection = false; continue; }
916
+ if (!inSection) continue;
917
+ const b = line.match(bulletRe);
918
+ if (b) items.push({ text: b[1].trim(), heading: currentHeading });
919
+ }
920
+ return items;
921
+ }
922
+
923
+ /**
924
+ * Extract keyword tokens from a BEL item for grep-based coverage detection.
925
+ * Reuses the STOPWORDS heuristic used elsewhere.
926
+ * @param {string} text
927
+ * @returns {string[]}
928
+ */
929
+ function _belKeywords(text) {
930
+ const STOPWORDS = new Set([
931
+ 'with', 'from', 'that', 'this', 'have', 'make', 'been', 'were', 'their',
932
+ 'they', 'them', 'will', 'should', 'would', 'could', 'there', 'into',
933
+ 'when', 'then', 'than', 'which', 'what', 'your', 'user', 'users', 'given',
934
+ 'able', 'must', 'shall', 'system', 'application', 'feature',
935
+ ]);
936
+ const tokens = String(text).toLowerCase().match(/\b[a-z][a-z0-9_-]{3,}\b/g) || [];
937
+ return [...new Set(tokens.filter((t) => !STOPWORDS.has(t)))];
938
+ }
939
+
940
+ /**
941
+ * Verify each BEL item's keywords appear somewhere in the delivery haystack
942
+ * (commit message + diff + changed-file paths).
943
+ *
944
+ * @param {object} opts
945
+ * @param {string} opts.specMarkdown - the spec content to parse BEL items from
946
+ * @param {string} opts.diffText - output of `git diff` or equivalent
947
+ * @param {string[]} [opts.changedFiles] - changed-file paths
948
+ * @param {string} [opts.commitMessage] - commit message
949
+ * @param {number} [opts.minKeywordHits=2] - min distinct keyword hits per item for coverage
950
+ * @returns {{ ok: boolean, totalItems: number, coveredItems: Array, uncoveredItems: Array }}
951
+ */
952
+ function verifyBELAgainstDelivery({ specMarkdown, diffText, changedFiles = [], commitMessage = '', minKeywordHits = 2 }) {
953
+ const items = parseBELItems(specMarkdown);
954
+ if (items.length === 0) return { ok: true, totalItems: 0, coveredItems: [], uncoveredItems: [] };
955
+
956
+ const haystack = [diffText || '', commitMessage || '', changedFiles.join(' ')].join('\n').toLowerCase();
957
+ const covered = [];
958
+ const uncovered = [];
959
+
960
+ for (const item of items) {
961
+ const keywords = _belKeywords(item.text);
962
+ if (keywords.length === 0) { covered.push({ ...item, hits: 0, keywords: [] }); continue; }
963
+
964
+ const hits = keywords.filter((k) => haystack.includes(k));
965
+ const threshold = Math.min(minKeywordHits, keywords.length);
966
+ if (hits.length >= threshold) {
967
+ covered.push({ ...item, hits: hits.length, keywords, matchedKeywords: hits });
968
+ } else {
969
+ uncovered.push({ ...item, hits: hits.length, keywords, matchedKeywords: hits, threshold });
970
+ }
971
+ }
972
+
973
+ return {
974
+ ok: uncovered.length === 0,
975
+ totalItems: items.length,
976
+ coveredItems: covered,
977
+ uncoveredItems: uncovered,
978
+ };
979
+ }
980
+
981
+ /**
982
+ * Format BEL verification result as a human-readable report.
983
+ * @param {object} result
984
+ * @param {string} [specPath]
985
+ * @returns {string|null}
986
+ */
987
+ function formatBELResult(result, specPath = '') {
988
+ if (!result || result.totalItems === 0) return null;
989
+ if (result.ok) {
990
+ return `BEL gate OK: all ${result.totalItems} expectation(s) from ${specPath || 'spec'} covered by delivery.`;
991
+ }
992
+ const lines = [`BEL gate FAIL: ${result.uncoveredItems.length}/${result.totalItems} expectation(s) not found in delivery (${specPath || 'spec'}):`];
993
+ for (const u of result.uncoveredItems) {
994
+ lines.push(` ✗ [${u.heading}] "${u.text.slice(0, 80)}"`);
995
+ lines.push(` matched ${u.hits}/${u.keywords.length} keywords (need ${u.threshold}); missing: ${u.keywords.filter((k) => !u.matchedKeywords.includes(k)).slice(0, 5).join(', ')}`);
996
+ }
997
+ lines.push('');
998
+ lines.push('Options: add the missing implementation, update the spec if the expectation was dropped with user approval, or force with --skip-bel.');
999
+ return lines.join('\n');
1000
+ }
1001
+
1002
+ // ============================================================
1003
+ // Confidence-Tier Rubric (95 / 85 / 75)
1004
+ // See .workflow/rubrics/confidence-tiers.md for full rubric.
1005
+ // Reconciled with EVIDENCE_TIERS (0..4). Story: wf-f14dcfeb (A4).
1006
+ // ============================================================
1007
+
1008
+ const CONFIDENCE_TIERS = { HIGH: 95, MEDIUM: 85, LOW: 75 };
1009
+
1010
+ /**
1011
+ * Map (evidenceTier, signal strength) → confidencePct per the rubric.
1012
+ *
1013
+ * @param {Object} opts
1014
+ * @param {number} opts.evidenceTier - 0..4 (see EVIDENCE_TIERS)
1015
+ * @param {number} [opts.hitCount] - grep/glob match count (for tier 1)
1016
+ * @param {number} [opts.fileCount] - distinct files for tier 1 hits
1017
+ * @param {number} [opts.observationCount] - corroborating observations (for tier 2)
1018
+ * @param {boolean} [opts.hasEvidenceNote] - whether a concrete citation was provided
1019
+ * @returns {{ confidencePct: 95|85|75, flagUnverified: boolean, severityCap: 'LOW'|'HIGH'|null, rationale: string }}
1020
+ */
1021
+ function computeConfidenceTier({
1022
+ evidenceTier,
1023
+ hitCount = 0,
1024
+ fileCount = 0,
1025
+ observationCount = 0,
1026
+ hasEvidenceNote = true,
1027
+ } = {}) {
1028
+ const t = typeof evidenceTier === 'number' ? evidenceTier : -1;
1029
+
1030
+ if (t >= 3) {
1031
+ return { confidencePct: 95, flagUnverified: false, severityCap: null, rationale: 'tier >= 3 (interactive/automated)' };
1032
+ }
1033
+ if (t === 2) {
1034
+ if (observationCount >= 2) {
1035
+ return { confidencePct: 95, flagUnverified: false, severityCap: null, rationale: 'tier 2 with 2+ corroborating observations' };
1036
+ }
1037
+ return { confidencePct: 85, flagUnverified: false, severityCap: 'HIGH', rationale: 'tier 2, single observation' };
1038
+ }
1039
+ if (t === 1) {
1040
+ if (hitCount >= 10 && fileCount >= 3) {
1041
+ return { confidencePct: 95, flagUnverified: false, severityCap: null, rationale: 'tier 1 with 10+ hits across 3+ files' };
1042
+ }
1043
+ if (hitCount >= 5) {
1044
+ return { confidencePct: 85, flagUnverified: false, severityCap: 'HIGH', rationale: 'tier 1 with 5-9 hits' };
1045
+ }
1046
+ if (hitCount >= 3 && fileCount >= 2) {
1047
+ return { confidencePct: 85, flagUnverified: false, severityCap: 'HIGH', rationale: 'tier 1 with 3+ hits across 2+ files' };
1048
+ }
1049
+ return { confidencePct: 75, flagUnverified: true, severityCap: 'LOW', rationale: 'tier 1, isolated hits' };
1050
+ }
1051
+ if (t === 0 || !hasEvidenceNote) {
1052
+ return { confidencePct: 75, flagUnverified: true, severityCap: 'LOW', rationale: 'tier 0 (static/source-inference) or no evidenceNote' };
1053
+ }
1054
+ // t === -1: no evidence
1055
+ return { confidencePct: 75, flagUnverified: true, severityCap: 'LOW', rationale: 'no evidence recorded' };
1056
+ }
1057
+
1058
+ /**
1059
+ * Validate a finding carries confidencePct in the allowed set.
1060
+ * @param {Object} finding
1061
+ * @returns {{ ok: boolean, reason?: string }}
1062
+ */
1063
+ function validateConfidencePct(finding) {
1064
+ const allowed = [95, 85, 75];
1065
+ if (!finding || !allowed.includes(finding.confidencePct)) {
1066
+ return { ok: false, reason: `confidencePct must be one of ${allowed.join('|')}; got ${finding?.confidencePct}` };
1067
+ }
1068
+ if (finding.confidencePct === 75 && !finding.flagUnverified) {
1069
+ return { ok: false, reason: 'confidencePct=75 findings MUST set flagUnverified=true' };
1070
+ }
1071
+ return { ok: true };
1072
+ }
1073
+
744
1074
  // ============================================================
745
1075
  // Exports
746
1076
  // ============================================================
@@ -757,6 +1087,15 @@ module.exports = {
757
1087
  parseCommitMessageClaims,
758
1088
  verifyCommitMessageAgainstDiff,
759
1089
  formatMissingClaimsMessage,
1090
+ computeConfidenceTier,
1091
+ validateConfidencePct,
1092
+ CONFIDENCE_TIERS,
1093
+ parseBELItems,
1094
+ verifyBELAgainstDelivery,
1095
+ formatBELResult,
1096
+ extractSpecStrings,
1097
+ verifySpecBundleCoverage,
1098
+ formatSpecBundleResult,
760
1099
  TIER_NAMES,
761
1100
  DONE_WORDS,
762
1101
  DISAGREEMENT_WORDS,