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
@@ -0,0 +1,787 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Feature Dossier System
5
+ *
6
+ * Per-feature canonical knowledge docs with mechanical auto-injection.
7
+ *
8
+ * Problem solved (from the 2026-04-24 workspace failure catalog):
9
+ * - Feature/workflow knowledge gets lost between sessions
10
+ * - Claude doesn't proactively fetch context — even when told to look, it
11
+ * grabs one thing and ignores the rest
12
+ * - Owner corrections ("remove the contact block") stop being remembered
13
+ * - Small stuff has maps (app-map, function-map); multi-part logic has nothing
14
+ *
15
+ * How it works:
16
+ * - Each user-facing feature has a dossier at <dossierDir>/<slug>.md
17
+ * - Dossiers capture: canonical summary, match patterns, contracts,
18
+ * rejected alternatives, removed elements, known bugs, append-only log
19
+ * - .workflow/dossiers/index.json maps {route, file, component, keyword}
20
+ * patterns to dossier slugs
21
+ * - At phase transitions, matchFeatures() scans the active task for
22
+ * touched features and injects their canonical headers into phase context
23
+ * - validateSpecAgainstDossier() greps spec text for contradictions with
24
+ * canonical claims (Rejected Alternatives, Removed Elements)
25
+ * - detectDrift() greps the codebase for things dossier claims were
26
+ * removed (the contact-person case)
27
+ *
28
+ * Workspace-mode path resolution:
29
+ * - Cross-repo features: WOGI_WORKSPACE_ROOT/.workspace/dossiers/
30
+ * - Per-repo features: <repo>/.workflow/dossiers/
31
+ * - At match time, both roots are scanned; workspace dossiers shadow per-repo
32
+ * on slug collision (workspace is the shared truth).
33
+ */
34
+
35
+ const fs = require('node:fs');
36
+ const path = require('node:path');
37
+ const { execSync } = require('node:child_process');
38
+ const { PATHS, safeJsonParse } = require('./flow-utils');
39
+
40
+ const DOSSIER_DIRNAME = 'dossiers';
41
+ const INDEX_FILENAME = 'index.json';
42
+
43
+ const RESERVED_SLUGS = new Set(['_template', '_logic-rules', 'README', 'index']);
44
+
45
+ function getDossierRoots() {
46
+ const roots = [];
47
+ if (process.env.WOGI_WORKSPACE_ROOT) {
48
+ const wsRoot = path.join(process.env.WOGI_WORKSPACE_ROOT, '.workspace', DOSSIER_DIRNAME);
49
+ roots.push({ kind: 'workspace', dir: wsRoot });
50
+ }
51
+ roots.push({ kind: 'repo', dir: path.join(PATHS.workflow, DOSSIER_DIRNAME) });
52
+ return roots;
53
+ }
54
+
55
+ function getPrimaryDossierDir() {
56
+ return path.join(PATHS.workflow, DOSSIER_DIRNAME);
57
+ }
58
+
59
+ function ensureDir(p) {
60
+ try { fs.mkdirSync(p, { recursive: true }); } catch (_err) { /* noop */ }
61
+ }
62
+
63
+ function loadIndex() {
64
+ const merged = { patterns: [], slugs: {}, version: '1.0.0' };
65
+ for (const root of getDossierRoots()) {
66
+ const idxPath = path.join(root.dir, INDEX_FILENAME);
67
+ if (!fs.existsSync(idxPath)) continue;
68
+ const idx = safeJsonParse(idxPath, null);
69
+ if (!idx) continue;
70
+ if (Array.isArray(idx.patterns)) {
71
+ for (const p of idx.patterns) merged.patterns.push({ ...p, _root: root.kind });
72
+ }
73
+ if (idx.slugs && typeof idx.slugs === 'object') {
74
+ for (const [slug, meta] of Object.entries(idx.slugs)) {
75
+ if (!merged.slugs[slug] || root.kind === 'workspace') {
76
+ merged.slugs[slug] = { ...meta, _root: root.kind };
77
+ }
78
+ }
79
+ }
80
+ }
81
+ return merged;
82
+ }
83
+
84
+ function saveIndex(index, rootKind = 'repo') {
85
+ const target = rootKind === 'workspace' && process.env.WOGI_WORKSPACE_ROOT
86
+ ? path.join(process.env.WOGI_WORKSPACE_ROOT, '.workspace', DOSSIER_DIRNAME)
87
+ : getPrimaryDossierDir();
88
+ ensureDir(target);
89
+ const idxPath = path.join(target, INDEX_FILENAME);
90
+ const out = { version: index.version || '1.0.0', patterns: [], slugs: {} };
91
+ for (const p of index.patterns || []) {
92
+ const { _root: _ignored, ...rest } = p;
93
+ out.patterns.push(rest);
94
+ }
95
+ for (const [slug, meta] of Object.entries(index.slugs || {})) {
96
+ const { _root: _ignored, ...rest } = meta;
97
+ out.slugs[slug] = rest;
98
+ }
99
+ out.lastUpdated = new Date().toISOString();
100
+ fs.writeFileSync(idxPath, JSON.stringify(out, null, 2));
101
+ }
102
+
103
+ function resolveDossierPath(slug) {
104
+ for (const root of getDossierRoots()) {
105
+ const candidate = path.join(root.dir, `${slug}.md`);
106
+ if (fs.existsSync(candidate)) return { path: candidate, root: root.kind };
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function listFeatures() {
112
+ const index = loadIndex();
113
+ const slugs = new Set(Object.keys(index.slugs));
114
+ for (const root of getDossierRoots()) {
115
+ if (!fs.existsSync(root.dir)) continue;
116
+ for (const f of fs.readdirSync(root.dir)) {
117
+ if (!f.endsWith('.md')) continue;
118
+ const slug = f.replace(/\.md$/, '');
119
+ if (RESERVED_SLUGS.has(slug)) continue;
120
+ slugs.add(slug);
121
+ }
122
+ }
123
+ return Array.from(slugs).sort();
124
+ }
125
+
126
+ function parseDossier(raw) {
127
+ const result = {
128
+ slug: null, status: null, owners: [], created: null, title: null,
129
+ sections: {}, rawContent: raw
130
+ };
131
+ const slugMatch = raw.match(/<!--\s*slug:\s*([^\s-]+[^>]*)-->/);
132
+ if (slugMatch) result.slug = slugMatch[1].trim();
133
+ const statusMatch = raw.match(/<!--\s*status:\s*([^>]+)-->/);
134
+ if (statusMatch) result.status = statusMatch[1].trim();
135
+ const ownersMatch = raw.match(/<!--\s*owners:\s*([^>]+)-->/);
136
+ if (ownersMatch) result.owners = ownersMatch[1].split(',').map(s => s.trim()).filter(Boolean);
137
+ const createdMatch = raw.match(/<!--\s*created:\s*([^>]+)-->/);
138
+ if (createdMatch) result.created = createdMatch[1].trim();
139
+ const titleMatch = raw.match(/^#\s+(.+)$/m);
140
+ if (titleMatch) result.title = titleMatch[1].trim();
141
+
142
+ const sectionRegex = /\n##\s+([^\n]+)\n([\s\S]*?)(?=\n##\s+|$)/g;
143
+ let m;
144
+ while ((m = sectionRegex.exec(raw)) !== null) {
145
+ const name = m[1].trim();
146
+ result.sections[name] = m[2].trim();
147
+ }
148
+ return result;
149
+ }
150
+
151
+ function loadDossier(slug) {
152
+ const found = resolveDossierPath(slug);
153
+ if (!found) return null;
154
+ let raw;
155
+ try { raw = fs.readFileSync(found.path, 'utf-8'); } catch (_err) { return null; }
156
+ const parsed = parseDossier(raw);
157
+ parsed.path = found.path;
158
+ parsed.root = found.root;
159
+ if (!parsed.slug) parsed.slug = slug;
160
+ return parsed;
161
+ }
162
+
163
+ function normalize(s) {
164
+ return String(s || '').toLowerCase();
165
+ }
166
+
167
+ /**
168
+ * Match candidate features for a task.
169
+ * Scans both the registered index.json patterns and every dossier's
170
+ * "Match Patterns" section (patterns listed inline in the dossier itself).
171
+ *
172
+ * @param {Object} input
173
+ * @param {string} [input.title]
174
+ * @param {string} [input.description]
175
+ * @param {string[]} [input.files]
176
+ * @param {string[]} [input.keywords]
177
+ * @returns {Array<{slug: string, score: number, reasons: string[]}>}
178
+ */
179
+ function matchFeatures(input = {}) {
180
+ const title = normalize(input.title);
181
+ const description = normalize(input.description);
182
+ const haystack = `${title}\n${description}`;
183
+ const files = (input.files || []).map(f => f.toLowerCase());
184
+ const extras = (input.keywords || []).map(normalize);
185
+
186
+ const scores = {};
187
+ const addScore = (slug, amount, reason) => {
188
+ if (!slug) return;
189
+ if (!scores[slug]) scores[slug] = { slug, score: 0, reasons: [] };
190
+ scores[slug].score += amount;
191
+ if (reason && !scores[slug].reasons.includes(reason)) {
192
+ scores[slug].reasons.push(reason);
193
+ }
194
+ };
195
+
196
+ const index = loadIndex();
197
+
198
+ for (const entry of index.patterns || []) {
199
+ if (!entry || !entry.slug) continue;
200
+ const slug = entry.slug;
201
+ if (entry.keyword && haystack.includes(normalize(entry.keyword))) {
202
+ addScore(slug, 1, `keyword: ${entry.keyword}`);
203
+ }
204
+ if (entry.route && haystack.includes(normalize(entry.route))) {
205
+ addScore(slug, 1.5, `route: ${entry.route}`);
206
+ }
207
+ if (entry.component && haystack.includes(normalize(entry.component))) {
208
+ addScore(slug, 1.5, `component: ${entry.component}`);
209
+ }
210
+ if (entry.filePattern && files.length > 0) {
211
+ const re = globToRegex(entry.filePattern);
212
+ for (const f of files) {
213
+ if (re.test(f)) {
214
+ addScore(slug, 2, `file: ${entry.filePattern}`);
215
+ break;
216
+ }
217
+ }
218
+ }
219
+ for (const kw of extras) {
220
+ if (entry.keyword && kw === normalize(entry.keyword)) addScore(slug, 0.5, `kw-extra:${kw}`);
221
+ }
222
+ }
223
+
224
+ for (const slug of listFeatures()) {
225
+ const dossier = loadDossier(slug);
226
+ if (!dossier) continue;
227
+ const patternsSection = dossier.sections['Match Patterns'] || '';
228
+ if (!patternsSection) continue;
229
+ const lines = patternsSection.split('\n').map(l => l.trim()).filter(l => l.startsWith('-'));
230
+ for (const line of lines) {
231
+ const kvMatch = line.match(/^-\s*([a-zA-Z-]+):\s*(.+)$/);
232
+ if (!kvMatch) continue;
233
+ const kind = kvMatch[1].toLowerCase();
234
+ const value = kvMatch[2].trim();
235
+ if (!value) continue;
236
+ if (kind === 'keyword' && haystack.includes(normalize(value))) {
237
+ addScore(slug, 1, `keyword: ${value}`);
238
+ } else if (kind === 'route' && haystack.includes(normalize(value))) {
239
+ addScore(slug, 1.5, `route: ${value}`);
240
+ } else if (kind === 'component' && haystack.includes(normalize(value))) {
241
+ addScore(slug, 1.5, `component: ${value}`);
242
+ } else if ((kind === 'file' || kind === 'filepattern' || kind === 'file-pattern') && files.length > 0) {
243
+ const re = globToRegex(value);
244
+ for (const f of files) {
245
+ if (re.test(f)) {
246
+ addScore(slug, 2, `file: ${value}`);
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ return Object.values(scores).sort((a, b) => b.score - a.score);
255
+ }
256
+
257
+ function globToRegex(pat) {
258
+ const escaped = pat
259
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
260
+ .replace(/\*\*/g, '.__DBLSTAR__')
261
+ .replace(/\*/g, '[^/]*')
262
+ .replace(/\.__DBLSTAR__/g, '.*')
263
+ .replace(/\?/g, '[^/]');
264
+ return new RegExp(`^${escaped}$`, 'i');
265
+ }
266
+
267
+ function scaffoldDossier(slug, meta = {}) {
268
+ if (RESERVED_SLUGS.has(slug)) {
269
+ throw new Error(`Slug "${slug}" is reserved`);
270
+ }
271
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
272
+ throw new Error(`Slug "${slug}" must be kebab-case (lowercase + hyphens)`);
273
+ }
274
+ const existing = resolveDossierPath(slug);
275
+ if (existing) {
276
+ throw new Error(`Dossier already exists at ${existing.path}`);
277
+ }
278
+ const target = meta.root === 'workspace' && process.env.WOGI_WORKSPACE_ROOT
279
+ ? path.join(process.env.WOGI_WORKSPACE_ROOT, '.workspace', DOSSIER_DIRNAME)
280
+ : getPrimaryDossierDir();
281
+ ensureDir(target);
282
+ const now = new Date().toISOString().slice(0, 10);
283
+ const title = meta.title || slug.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
284
+ const owners = (meta.owners || []).join(', ') || 'unset';
285
+ const patterns = (meta.patterns || []).map(p => `- ${p}`).join('\n') || '- keyword: <add match keyword>';
286
+ const body = `# ${title}
287
+
288
+ <!-- slug: ${slug} -->
289
+ <!-- status: active -->
290
+ <!-- owners: ${owners} -->
291
+ <!-- created: ${now} -->
292
+
293
+ ## Canonical Summary
294
+
295
+ ${meta.summary || '<One-paragraph description of what this feature IS today. Replace any time the owner revises scope.>'}
296
+
297
+ ## Match Patterns
298
+
299
+ <!-- Auto-match patterns for loadOnMatch. Any task whose title/description/files match will auto-load this dossier. -->
300
+ ${patterns}
301
+
302
+ ## Contracts
303
+
304
+ <!-- DTO/API contracts, state-flow expectations, cross-repo agreements. One bullet per contract. -->
305
+ - <describe contract>
306
+
307
+ ## Logic Rules
308
+
309
+ <!-- Cross-cutting rules scoped to this feature. For rules that span features, use .workflow/dossiers/_logic-rules.md -->
310
+ - <describe rule>
311
+
312
+ ## Rejected Alternatives
313
+
314
+ <!-- Owner-rejected designs, with date + reason. Any future spec that matches these is a contradiction and will be blocked at spec phase. -->
315
+ - <date>: <alternative name> → REJECTED, reason: <why>
316
+
317
+ ## Removed Elements
318
+
319
+ <!-- Things the owner told us to remove. The drift detector greps the codebase for these — if they reappear, you see drift. -->
320
+ - <date>: <element> → removed, reason: <why>, enforcement-grep: \`<regex>\`
321
+
322
+ ## Known Bugs / Tech Debt
323
+
324
+ <!-- Active bugs or deferred fixes. Link to task IDs. -->
325
+ - <describe bug> — task: wf-xxxxxxxx
326
+
327
+ ## Change Log
328
+
329
+ <!-- Append-only. One row per task that touched this feature. Populated by appendEvent(). -->
330
+
331
+ | Date | Task ID | Event | Note |
332
+ |------|---------|-------|------|
333
+ `;
334
+ const outPath = path.join(target, `${slug}.md`);
335
+ fs.writeFileSync(outPath, body);
336
+
337
+ const index = loadIndex();
338
+ if (!index.slugs[slug]) {
339
+ index.slugs[slug] = { title, created: now, owners: meta.owners || [] };
340
+ saveIndex(index, meta.root === 'workspace' ? 'workspace' : 'repo');
341
+ }
342
+ return outPath;
343
+ }
344
+
345
+ function appendEvent(slug, event = {}) {
346
+ const dossier = loadDossier(slug);
347
+ if (!dossier) throw new Error(`Dossier not found: ${slug}`);
348
+ const date = event.date || new Date().toISOString().slice(0, 10);
349
+ const taskId = event.taskId || '-';
350
+ const type = event.type || 'touched';
351
+ const note = (event.note || '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
352
+ const row = `| ${date} | ${taskId} | ${type} | ${note} |\n`;
353
+
354
+ let raw = fs.readFileSync(dossier.path, 'utf-8');
355
+ const header = '| Date | Task ID | Event | Note |';
356
+ const sep = '|------|---------|-------|------|';
357
+ const idx = raw.indexOf(sep);
358
+ if (idx === -1) {
359
+ raw = raw.trimEnd() + `\n\n${header}\n${sep}\n${row}`;
360
+ } else {
361
+ const insertAt = raw.indexOf('\n', idx) + 1;
362
+ raw = raw.slice(0, insertAt) + row + raw.slice(insertAt);
363
+ }
364
+ fs.writeFileSync(dossier.path, raw);
365
+ return { dossierPath: dossier.path, row: row.trim() };
366
+ }
367
+
368
+ /**
369
+ * Auto-append a Change Log row to every dossier matching a completed task.
370
+ * Called by flow-done.js after a task moves to recentlyCompleted.
371
+ * Fail-safe: never throws — returns {touched:[], skipped?, error?} on any failure.
372
+ * Guards: duplicate-row check (by taskId) and per-file lockfile to prevent
373
+ * concurrent /wogi-done corruption.
374
+ */
375
+ function autoTouchFromTask(taskMeta = {}) {
376
+ try {
377
+ let config = {};
378
+ try {
379
+ const { getConfig } = require('./flow-utils');
380
+ config = getConfig() || {};
381
+ } catch (_err) { /* fail-open */ }
382
+
383
+ const fd = config.featureDossier || {};
384
+ if (fd.enabled === false) return { touched: [], skipped: 'dossier-disabled' };
385
+ if (fd.autoTouchOnDone === false) return { touched: [], skipped: 'auto-touch-disabled' };
386
+ const threshold = fd.autoMatchConfidence ?? 1;
387
+
388
+ const matches = matchFeatures({
389
+ title: taskMeta.title || '',
390
+ description: taskMeta.description || '',
391
+ files: taskMeta.files || []
392
+ });
393
+ const qualifying = matches.filter(m => m.score >= threshold);
394
+ if (qualifying.length === 0) return { touched: [], skipped: 'no-match' };
395
+
396
+ const rawNote = String(taskMeta.title || '').trim();
397
+ const note = rawNote.length > 80 ? rawNote.slice(0, 77) + '...' : rawNote;
398
+ const taskId = taskMeta.taskId || '-';
399
+ const event = {
400
+ taskId,
401
+ type: taskMeta.type || 'feat',
402
+ note,
403
+ date: taskMeta.date
404
+ };
405
+
406
+ const touched = [];
407
+ const skipped = [];
408
+ for (const m of qualifying) {
409
+ const dossier = loadDossier(m.slug);
410
+ if (!dossier) continue;
411
+
412
+ // Duplicate-row guard: skip if any existing row already references this taskId.
413
+ if (taskId !== '-') {
414
+ try {
415
+ const existing = fs.readFileSync(dossier.path, 'utf-8');
416
+ if (existing.includes(`| ${taskId} |`)) {
417
+ skipped.push({ slug: m.slug, reason: 'already-touched' });
418
+ continue;
419
+ }
420
+ } catch (_err) { /* fall through to attempt append */ }
421
+ }
422
+
423
+ // Per-file lockfile (O_EXCL atomic create) — prevents concurrent writers
424
+ // from clobbering each other's rows on same-second task completions.
425
+ const lockPath = `${dossier.path}.lock`;
426
+ let lockFd;
427
+ try {
428
+ lockFd = fs.openSync(lockPath, 'wx');
429
+ } catch (err) {
430
+ if (err.code === 'EEXIST') {
431
+ skipped.push({ slug: m.slug, reason: 'locked' });
432
+ if (process.env.DEBUG) console.error(`[auto-touch] ${m.slug}: locked by another writer, skipping`);
433
+ continue;
434
+ }
435
+ if (process.env.DEBUG) console.error(`[auto-touch] ${m.slug} lock: ${err.message}`);
436
+ continue;
437
+ }
438
+
439
+ try {
440
+ appendEvent(m.slug, event);
441
+ touched.push({ slug: m.slug, score: m.score });
442
+ } catch (err) {
443
+ if (process.env.DEBUG) console.error(`[auto-touch] ${m.slug}: ${err.message}`);
444
+ } finally {
445
+ try { fs.closeSync(lockFd); } catch (_err) { /* noop */ }
446
+ try { fs.unlinkSync(lockPath); } catch (_err) { /* noop */ }
447
+ }
448
+ }
449
+ return { touched, skipped };
450
+ } catch (err) {
451
+ if (process.env.DEBUG) console.error(`[auto-touch] error: ${err.message}`);
452
+ return { touched: [], error: err.message };
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Check a spec against a dossier's canonical claims.
458
+ * Flags contradictions: spec mentions something listed in Rejected Alternatives
459
+ * or reintroduces something listed in Removed Elements.
460
+ */
461
+ function validateSpecAgainstDossier(specContent, dossier) {
462
+ const issues = [];
463
+ const specLower = String(specContent || '').toLowerCase();
464
+
465
+ const rejected = dossier.sections['Rejected Alternatives'] || '';
466
+ for (const line of rejected.split('\n')) {
467
+ const m = line.match(/^-\s*(?:\d{4}-\d{2}-\d{2}:\s*)?([^→\n]+?)(?:\s*→|$)/);
468
+ if (!m) continue;
469
+ const altName = m[1].trim();
470
+ if (!altName || altName.startsWith('<')) continue;
471
+ const cleanAlt = altName.toLowerCase();
472
+ if (cleanAlt.length < 4) continue;
473
+ if (specLower.includes(cleanAlt)) {
474
+ issues.push({
475
+ severity: 'blocker',
476
+ kind: 'rejected-alternative',
477
+ detail: `Spec mentions "${altName}" which was rejected. See dossier § Rejected Alternatives.`
478
+ });
479
+ }
480
+ }
481
+
482
+ const removed = dossier.sections['Removed Elements'] || '';
483
+ for (const line of removed.split('\n')) {
484
+ if (!line.trim().startsWith('-')) continue;
485
+ const gm = line.match(/enforcement-grep:\s*`([^`]+)`/);
486
+ const nameMatch = line.match(/^-\s*(?:\d{4}-\d{2}-\d{2}:\s*)?([^→\n]+?)(?:\s*→|$)/);
487
+ if (nameMatch) {
488
+ const name = nameMatch[1].trim().toLowerCase();
489
+ if (name && name.length >= 4 && !name.startsWith('<') && specLower.includes(name)) {
490
+ issues.push({
491
+ severity: 'blocker',
492
+ kind: 'removed-element',
493
+ detail: `Spec reintroduces "${nameMatch[1].trim()}" which was removed. See dossier § Removed Elements.`
494
+ });
495
+ }
496
+ }
497
+ if (gm) {
498
+ try {
499
+ const re = new RegExp(gm[1], 'i');
500
+ if (re.test(specContent)) {
501
+ issues.push({
502
+ severity: 'blocker',
503
+ kind: 'removed-element-pattern',
504
+ detail: `Spec matches removed-element enforcement pattern /${gm[1]}/. See dossier § Removed Elements.`
505
+ });
506
+ }
507
+ } catch (_err) { /* bad regex, skip */ }
508
+ }
509
+ }
510
+ return issues;
511
+ }
512
+
513
+ /**
514
+ * Drift detector: grep the codebase for patterns the dossier claims were removed.
515
+ * If a match is found, the dossier is out of sync with reality (the contact-person case).
516
+ */
517
+ function detectDrift(slug) {
518
+ const dossier = loadDossier(slug);
519
+ if (!dossier) throw new Error(`Dossier not found: ${slug}`);
520
+ const removed = dossier.sections['Removed Elements'] || '';
521
+ const findings = [];
522
+ const patterns = [];
523
+ for (const line of removed.split('\n')) {
524
+ const gm = line.match(/enforcement-grep:\s*`([^`]+)`/);
525
+ if (gm) patterns.push({ pattern: gm[1], source: line.trim() });
526
+ }
527
+ if (patterns.length === 0) {
528
+ return { slug, patterns: 0, findings: [] };
529
+ }
530
+ for (const { pattern, source } of patterns) {
531
+ try {
532
+ const out = execSync(
533
+ `git grep -nE ${JSON.stringify(pattern)} -- . ':(exclude).workflow' ':(exclude)node_modules' ':(exclude).git' 2>/dev/null || true`,
534
+ { cwd: PATHS.root, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
535
+ );
536
+ const lines = out.split('\n').filter(Boolean).slice(0, 40);
537
+ if (lines.length > 0) {
538
+ findings.push({ pattern, source, hits: lines.length, sample: lines.slice(0, 5) });
539
+ }
540
+ } catch (_err) { /* non-blocking */ }
541
+ }
542
+ return { slug, patterns: patterns.length, findings };
543
+ }
544
+
545
+ function buildPhaseInjection(matches, opts = {}) {
546
+ const max = opts.maxDossiers || 3;
547
+ const top = matches
548
+ .filter(m => m.score >= (opts.minScore || 1))
549
+ .slice(0, max);
550
+ if (top.length === 0) return null;
551
+
552
+ const blocks = [];
553
+ for (const match of top) {
554
+ const dossier = loadDossier(match.slug);
555
+ if (!dossier) continue;
556
+ const title = dossier.title || match.slug;
557
+ const canonical = dossier.sections['Canonical Summary'] || '(no canonical summary)';
558
+ const contracts = dossier.sections['Contracts'] || '';
559
+ const rejected = dossier.sections['Rejected Alternatives'] || '';
560
+ const removed = dossier.sections['Removed Elements'] || '';
561
+ const logicRules = dossier.sections['Logic Rules'] || '';
562
+
563
+ let block = `### Feature Dossier: ${title} (slug: ${match.slug}, score ${match.score.toFixed(1)})\n`;
564
+ block += `**Matched via**: ${match.reasons.join(', ')}\n\n`;
565
+ block += `**Canonical**: ${canonical.split('\n').map(l => l.trim()).filter(Boolean).join(' ').slice(0, 600)}\n`;
566
+ if (contracts && !contracts.startsWith('<')) {
567
+ block += `\n**Contracts**:\n${contracts.split('\n').filter(l => l.trim().startsWith('-')).slice(0, 6).join('\n')}\n`;
568
+ }
569
+ if (logicRules && !logicRules.startsWith('<')) {
570
+ block += `\n**Feature-scoped logic rules**:\n${logicRules.split('\n').filter(l => l.trim().startsWith('-')).slice(0, 6).join('\n')}\n`;
571
+ }
572
+ if (rejected && !rejected.startsWith('<')) {
573
+ block += `\n**Rejected alternatives (do not re-propose)**:\n${rejected.split('\n').filter(l => l.trim().startsWith('-')).slice(0, 6).join('\n')}\n`;
574
+ }
575
+ if (removed && !removed.startsWith('<')) {
576
+ block += `\n**Removed elements (do not reintroduce)**:\n${removed.split('\n').filter(l => l.trim().startsWith('-')).slice(0, 6).join('\n')}\n`;
577
+ }
578
+ block += `\n**Full dossier**: \`${path.relative(PATHS.root, dossier.path)}\`\n`;
579
+ blocks.push(block);
580
+ }
581
+ if (blocks.length === 0) return null;
582
+ return [
583
+ '## Feature Dossier Auto-Load',
584
+ '',
585
+ 'The following feature dossiers match the active task. These capture prior owner decisions, rejected alternatives, and removed elements. Your spec/implementation MUST NOT contradict them.',
586
+ '',
587
+ blocks.join('\n---\n\n')
588
+ ].join('\n');
589
+ }
590
+
591
+ // ============================================================
592
+ // CLI
593
+ // ============================================================
594
+
595
+ function printHelp() {
596
+ console.log(`Usage: flow feature-dossier <command> [args]
597
+
598
+ Commands:
599
+ list List all known dossier slugs
600
+ show <slug> Print a dossier's parsed content
601
+ scaffold <slug> [options] Create a new dossier
602
+ Options: --title "X" --owners "be,fe" --summary "..." --workspace
603
+ match --title "..." [--files "a,b"] [--description "..."]
604
+ Show candidate dossiers for a task
605
+ touch <slug> --task wf-XXX --type <event> [--note "..."]
606
+ Append a change-log event
607
+ drift <slug> Grep codebase for removed-element patterns
608
+ validate <slug> --spec <file>
609
+ Check a spec file against the dossier for contradictions
610
+ inject --title "..." [--files "a,b"]
611
+ Print phase-injection block (for hook use)
612
+ help Show this help
613
+
614
+ Examples:
615
+ flow feature-dossier scaffold services-integrations --title "Services + Integrations" --owners "fe,be"
616
+ flow feature-dossier match --title "merge services and integrations card" --files "src/pages/Services.tsx"
617
+ flow feature-dossier drift services-integrations
618
+ `);
619
+ }
620
+
621
+ function parseArgs(args) {
622
+ const out = { _: [], flags: {} };
623
+ for (let i = 0; i < args.length; i++) {
624
+ const a = args[i];
625
+ if (a.startsWith('--')) {
626
+ const key = a.slice(2);
627
+ const next = args[i + 1];
628
+ if (next !== undefined && !next.startsWith('--')) {
629
+ out.flags[key] = next;
630
+ i++;
631
+ } else {
632
+ out.flags[key] = true;
633
+ }
634
+ } else {
635
+ out._.push(a);
636
+ }
637
+ }
638
+ return out;
639
+ }
640
+
641
+ function cliMain(argv) {
642
+ const [cmd, ...rest] = argv;
643
+ const { _: positional, flags } = parseArgs(rest);
644
+
645
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return printHelp();
646
+
647
+ if (cmd === 'list') {
648
+ const slugs = listFeatures();
649
+ if (slugs.length === 0) {
650
+ console.log('(no dossiers — run `flow feature-dossier scaffold <slug>`)');
651
+ } else {
652
+ for (const s of slugs) console.log(s);
653
+ }
654
+ return;
655
+ }
656
+
657
+ if (cmd === 'show') {
658
+ const slug = positional[0];
659
+ if (!slug) { console.error('slug required'); process.exit(1); }
660
+ const d = loadDossier(slug);
661
+ if (!d) { console.error(`not found: ${slug}`); process.exit(1); }
662
+ console.log(d.rawContent);
663
+ return;
664
+ }
665
+
666
+ if (cmd === 'scaffold') {
667
+ const slug = positional[0];
668
+ if (!slug) { console.error('slug required'); process.exit(1); }
669
+ const meta = {
670
+ title: flags.title,
671
+ owners: flags.owners ? String(flags.owners).split(',').map(s => s.trim()) : [],
672
+ summary: flags.summary,
673
+ patterns: flags.patterns ? String(flags.patterns).split(',').map(s => s.trim()) : [],
674
+ root: flags.workspace ? 'workspace' : 'repo'
675
+ };
676
+ const out = scaffoldDossier(slug, meta);
677
+ console.log(`Created: ${out}`);
678
+ return;
679
+ }
680
+
681
+ if (cmd === 'match') {
682
+ const input = {
683
+ title: flags.title || '',
684
+ description: flags.description || '',
685
+ files: flags.files ? String(flags.files).split(',').map(s => s.trim()) : []
686
+ };
687
+ const matches = matchFeatures(input);
688
+ if (matches.length === 0) {
689
+ console.log('(no matches)');
690
+ return;
691
+ }
692
+ for (const m of matches) {
693
+ console.log(`${m.slug} score=${m.score.toFixed(1)} [${m.reasons.join(', ')}]`);
694
+ }
695
+ return;
696
+ }
697
+
698
+ if (cmd === 'touch') {
699
+ const slug = positional[0];
700
+ if (!slug) { console.error('slug required'); process.exit(1); }
701
+ const result = appendEvent(slug, {
702
+ taskId: flags.task || '-',
703
+ type: flags.type || 'touched',
704
+ note: flags.note || '',
705
+ date: flags.date
706
+ });
707
+ console.log(`Appended to ${result.dossierPath}: ${result.row}`);
708
+ return;
709
+ }
710
+
711
+ if (cmd === 'drift') {
712
+ const slug = positional[0];
713
+ if (!slug) { console.error('slug required'); process.exit(1); }
714
+ const report = detectDrift(slug);
715
+ if (report.findings.length === 0) {
716
+ console.log(`drift: clean (${report.patterns} pattern(s) checked)`);
717
+ } else {
718
+ console.log(`DRIFT DETECTED in ${slug}:`);
719
+ for (const f of report.findings) {
720
+ console.log(`\n pattern: /${f.pattern}/ hits: ${f.hits}`);
721
+ console.log(` source: ${f.source}`);
722
+ for (const s of f.sample) console.log(` ${s}`);
723
+ }
724
+ process.exit(2);
725
+ }
726
+ return;
727
+ }
728
+
729
+ if (cmd === 'validate') {
730
+ const slug = positional[0];
731
+ if (!slug || !flags.spec) { console.error('usage: validate <slug> --spec <file>'); process.exit(1); }
732
+ const d = loadDossier(slug);
733
+ if (!d) { console.error(`not found: ${slug}`); process.exit(1); }
734
+ let spec;
735
+ try { spec = fs.readFileSync(flags.spec, 'utf-8'); }
736
+ catch (err) { console.error(`cannot read spec: ${err.message}`); process.exit(1); }
737
+ const issues = validateSpecAgainstDossier(spec, d);
738
+ if (issues.length === 0) {
739
+ console.log('spec OK vs dossier');
740
+ } else {
741
+ console.log(`${issues.length} contradiction(s):`);
742
+ for (const i of issues) console.log(` [${i.severity}] ${i.kind}: ${i.detail}`);
743
+ process.exit(2);
744
+ }
745
+ return;
746
+ }
747
+
748
+ if (cmd === 'inject') {
749
+ const input = {
750
+ title: flags.title || '',
751
+ description: flags.description || '',
752
+ files: flags.files ? String(flags.files).split(',').map(s => s.trim()) : []
753
+ };
754
+ const matches = matchFeatures(input);
755
+ const block = buildPhaseInjection(matches, { minScore: Number(flags['min-score'] || 1), maxDossiers: Number(flags.max || 3) });
756
+ if (block) console.log(block);
757
+ return;
758
+ }
759
+
760
+ console.error(`unknown command: ${cmd}`);
761
+ printHelp();
762
+ process.exit(1);
763
+ }
764
+
765
+ if (require.main === module) {
766
+ try { cliMain(process.argv.slice(2)); }
767
+ catch (err) { console.error(err.message); process.exit(1); }
768
+ }
769
+
770
+ module.exports = {
771
+ getDossierRoots,
772
+ getPrimaryDossierDir,
773
+ loadIndex,
774
+ saveIndex,
775
+ listFeatures,
776
+ loadDossier,
777
+ parseDossier,
778
+ matchFeatures,
779
+ scaffoldDossier,
780
+ appendEvent,
781
+ autoTouchFromTask,
782
+ validateSpecAgainstDossier,
783
+ detectDrift,
784
+ buildPhaseInjection,
785
+ DOSSIER_DIRNAME,
786
+ RESERVED_SLUGS
787
+ };