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,458 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Memory Proposal Store
5
+ *
6
+ * Durable storage for agent-proposed edits to IGR artifacts (product.md,
7
+ * domain-model.md, user-journeys.md, glossary.md). Proposals are staged until
8
+ * the user approves or rejects them at session-end. Approved proposals are
9
+ * applied to the target artifact + archived; rejected proposals are moved
10
+ * aside without touching the target.
11
+ *
12
+ * Storage layout:
13
+ * .workflow/state/memory-proposals/<id>.json — proposal records
14
+ * .workflow/state/memory-proposals/<id>.content.md — staged content
15
+ * .workflow/state/memory-proposals/applied/ — approved archive
16
+ * .workflow/state/memory-proposals/rejected/ — rejected archive
17
+ *
18
+ * Record schema:
19
+ * {
20
+ * id: "mprop-<8hex>",
21
+ * block: "product" | "domain-model" | "user-journeys" | "glossary",
22
+ * op: "append" | "replace-section" | "replace-all",
23
+ * contentPath: string, // repo-relative path to staged content
24
+ * section: string | null, // heading text for replace-section only
25
+ * rationale: string,
26
+ * proposedAt: ISO-8601 timestamp,
27
+ * proposedBy: "agent" | "user",
28
+ * status: "pending" | "approved" | "rejected",
29
+ * decidedAt?: ISO-8601,
30
+ * reason?: string // reject reason
31
+ * }
32
+ */
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+ const crypto = require('node:crypto');
37
+
38
+ const { PATHS, isPathWithinProject } = require('../scripts/flow-paths');
39
+
40
+ const PROPOSALS_DIR = path.join(PATHS.state, 'memory-proposals');
41
+ const APPLIED_DIR = path.join(PROPOSALS_DIR, 'applied');
42
+ const REJECTED_DIR = path.join(PROPOSALS_DIR, 'rejected');
43
+
44
+ const VALID_BLOCKS = Object.freeze(['product', 'domain-model', 'user-journeys', 'glossary']);
45
+ const VALID_OPS = Object.freeze(['append', 'replace-section', 'replace-all']);
46
+
47
+ // ============================================================
48
+ // Low-level helpers
49
+ // ============================================================
50
+
51
+ function ensureDir(dir) {
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ }
54
+
55
+ function generateProposalId() {
56
+ return `mprop-${crypto.randomBytes(4).toString('hex')}`;
57
+ }
58
+
59
+ function blockArtifactPath(block) {
60
+ return path.join(PATHS.state, `${block}.md`);
61
+ }
62
+
63
+ function proposalRecordPath(id, baseDir = PROPOSALS_DIR) {
64
+ return path.join(baseDir, `${id}.json`);
65
+ }
66
+
67
+ function proposalContentPath(id, baseDir = PROPOSALS_DIR) {
68
+ return path.join(baseDir, `${id}.content.md`);
69
+ }
70
+
71
+ function toRepoRelative(absPath) {
72
+ return path.relative(PATHS.root, absPath);
73
+ }
74
+
75
+ function resolveWithinProject(relOrAbs) {
76
+ const abs = path.isAbsolute(relOrAbs) ? relOrAbs : path.resolve(PATHS.root, relOrAbs);
77
+ if (!isPathWithinProject(abs)) {
78
+ throw new Error(`path escapes project root: ${relOrAbs}`);
79
+ }
80
+ return abs;
81
+ }
82
+
83
+ function readRecord(absPath) {
84
+ try {
85
+ const raw = fs.readFileSync(absPath, 'utf-8');
86
+ const parsed = JSON.parse(raw);
87
+ return parsed && typeof parsed === 'object' ? parsed : null;
88
+ } catch (_err) {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function writeRecord(absPath, record) {
94
+ ensureDir(path.dirname(absPath));
95
+ fs.writeFileSync(absPath, JSON.stringify(record, null, 2) + '\n', 'utf-8');
96
+ }
97
+
98
+ function listRecordsIn(dir) {
99
+ if (!fs.existsSync(dir)) return [];
100
+ let names;
101
+ try {
102
+ names = fs.readdirSync(dir);
103
+ } catch (_err) {
104
+ return [];
105
+ }
106
+ const out = [];
107
+ for (const name of names) {
108
+ if (!/^mprop-[a-f0-9]{8}\.json$/.test(name)) continue;
109
+ const rec = readRecord(path.join(dir, name));
110
+ if (rec) out.push(rec);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ // ============================================================
116
+ // Section boundary (markdown heading) parsing
117
+ // ============================================================
118
+
119
+ /**
120
+ * Parse `##`/`###` headings from markdown text. Returns an array of
121
+ * { level, heading, start, end }
122
+ * where `start` is the index of the heading line's `#` and `end` is the
123
+ * index of the next same-or-higher heading (or EOF) — the section body
124
+ * spans [start, end).
125
+ */
126
+ function parseSections(text) {
127
+ const lines = text.split('\n');
128
+ const sections = [];
129
+ // We need line offsets to compute absolute text indices.
130
+ const lineOffsets = new Array(lines.length);
131
+ {
132
+ let off = 0;
133
+ for (let i = 0; i < lines.length; i++) {
134
+ lineOffsets[i] = off;
135
+ off += lines[i].length + 1; // +1 for the removed '\n'
136
+ }
137
+ }
138
+ const headingRe = /^(#{2,6})\s+(.+?)\s*$/;
139
+ for (let i = 0; i < lines.length; i++) {
140
+ const m = lines[i].match(headingRe);
141
+ if (!m) continue;
142
+ sections.push({
143
+ level: m[1].length,
144
+ heading: m[2].trim(),
145
+ start: lineOffsets[i],
146
+ lineIndex: i,
147
+ end: text.length, // patched below
148
+ });
149
+ }
150
+ // Patch `end` — first section whose level <= current.level ends us.
151
+ for (let i = 0; i < sections.length; i++) {
152
+ const cur = sections[i];
153
+ let end = text.length;
154
+ for (let j = i + 1; j < sections.length; j++) {
155
+ if (sections[j].level <= cur.level) {
156
+ end = sections[j].start;
157
+ break;
158
+ }
159
+ }
160
+ cur.end = end;
161
+ }
162
+ return sections;
163
+ }
164
+
165
+ /**
166
+ * Find the section with the given heading. Returns:
167
+ * { section, error?, matchCount }
168
+ * When multiple sections share the same heading, returns an error — the
169
+ * caller must supply a unique heading to avoid ambiguous replacement.
170
+ */
171
+ function findSectionByHeading(text, heading) {
172
+ const normalize = (s) => String(s || '').trim();
173
+ const target = normalize(heading);
174
+ if (!target) {
175
+ return { section: null, error: 'empty heading', matchCount: 0 };
176
+ }
177
+ const sections = parseSections(text);
178
+ const matches = sections.filter((s) => normalize(s.heading) === target);
179
+ if (matches.length === 0) {
180
+ return { section: null, error: `heading not found: '${heading}'`, matchCount: 0 };
181
+ }
182
+ if (matches.length > 1) {
183
+ return {
184
+ section: null,
185
+ error: `ambiguous heading '${heading}' — matches ${matches.length} sections; make the heading unique or use replace-all`,
186
+ matchCount: matches.length,
187
+ };
188
+ }
189
+ return { section: matches[0], matchCount: 1 };
190
+ }
191
+
192
+ // ============================================================
193
+ // Public API — proposal staging
194
+ // ============================================================
195
+
196
+ /**
197
+ * Stage a proposal.
198
+ *
199
+ * block: one of VALID_BLOCKS
200
+ * op: append | replace-section | replace-all
201
+ * contentFile: repo-relative or absolute path to staged content (required)
202
+ * section: heading text — required for replace-section
203
+ * rationale: string — required for replace-all
204
+ */
205
+ function createProposal({
206
+ block,
207
+ op,
208
+ contentFile = null,
209
+ section = null,
210
+ rationale = '',
211
+ proposedBy = 'agent',
212
+ } = {}) {
213
+ if (!VALID_BLOCKS.includes(block)) {
214
+ throw new Error(`invalid block '${block}': expected ${VALID_BLOCKS.join('|')}`);
215
+ }
216
+ if (!VALID_OPS.includes(op)) {
217
+ throw new Error(`invalid op '${op}': expected ${VALID_OPS.join('|')}`);
218
+ }
219
+ if (!contentFile) {
220
+ throw new Error('--content is required');
221
+ }
222
+ const contentAbs = resolveWithinProject(contentFile);
223
+ if (!fs.existsSync(contentAbs)) {
224
+ throw new Error(`content file not found: ${contentFile}`);
225
+ }
226
+
227
+ const rationaleStr = String(rationale || '').trim();
228
+
229
+ if (op === 'replace-all' && !rationaleStr) {
230
+ throw new Error('replace-all requires --rationale');
231
+ }
232
+
233
+ const sectionStr = section != null ? String(section).trim() : null;
234
+ if (op === 'replace-section') {
235
+ if (!sectionStr) {
236
+ throw new Error('replace-section requires --section <heading>');
237
+ }
238
+ // Validate the heading exists in the current artifact (AC #3).
239
+ // If the artifact is missing, replace-section is nonsensical — error.
240
+ const artifactPath = blockArtifactPath(block);
241
+ if (!fs.existsSync(artifactPath)) {
242
+ throw new Error(`cannot replace-section — artifact missing at ${toRepoRelative(artifactPath)}`);
243
+ }
244
+ const body = fs.readFileSync(artifactPath, 'utf-8');
245
+ const hit = findSectionByHeading(body, sectionStr);
246
+ if (hit.error) {
247
+ throw new Error(`section validation failed: ${hit.error}`);
248
+ }
249
+ }
250
+
251
+ const id = generateProposalId();
252
+ ensureDir(PROPOSALS_DIR);
253
+ const stagedAbs = proposalContentPath(id);
254
+ fs.copyFileSync(contentAbs, stagedAbs);
255
+
256
+ const record = {
257
+ id,
258
+ block,
259
+ op,
260
+ contentPath: toRepoRelative(stagedAbs),
261
+ section: sectionStr,
262
+ rationale: rationaleStr,
263
+ proposedAt: new Date().toISOString(),
264
+ proposedBy: proposedBy === 'user' ? 'user' : 'agent',
265
+ status: 'pending',
266
+ };
267
+ writeRecord(proposalRecordPath(id), record);
268
+ return record;
269
+ }
270
+
271
+ // ============================================================
272
+ // Public API — lookup
273
+ // ============================================================
274
+
275
+ function listProposals(filter = {}) {
276
+ const status = filter.status || null;
277
+ const buckets = [];
278
+ if (!status || status === 'pending') buckets.push(...listRecordsIn(PROPOSALS_DIR));
279
+ if (!status || status === 'approved') buckets.push(...listRecordsIn(APPLIED_DIR));
280
+ if (!status || status === 'rejected') buckets.push(...listRecordsIn(REJECTED_DIR));
281
+
282
+ const want = Object.entries(filter).filter(([k]) => k !== 'status');
283
+ return buckets.filter((r) => want.every(([k, v]) => r[k] === v));
284
+ }
285
+
286
+ function findProposal({ id = null, block = null, status = 'pending' } = {}) {
287
+ const candidates = listProposals({ status });
288
+ return candidates.find((r) =>
289
+ (id ? r.id === id : true) &&
290
+ (block ? r.block === block : true)
291
+ ) || null;
292
+ }
293
+
294
+ function readStagedContent(record) {
295
+ const abs = path.resolve(PATHS.root, record.contentPath);
296
+ if (!fs.existsSync(abs)) {
297
+ throw new Error(`staged content missing at ${record.contentPath}`);
298
+ }
299
+ return fs.readFileSync(abs, 'utf-8');
300
+ }
301
+
302
+ // ============================================================
303
+ // Public API — approve / reject
304
+ // ============================================================
305
+
306
+ /**
307
+ * Apply op to artifact, archive proposal to applied/.
308
+ * Returns the updated record.
309
+ */
310
+ function approveProposal({ id }) {
311
+ if (!id) throw new Error('approveProposal requires id');
312
+ const recordPath = proposalRecordPath(id);
313
+ const record = readRecord(recordPath);
314
+ if (!record) throw new Error(`no pending proposal with id ${id}`);
315
+ if (record.status !== 'pending') {
316
+ throw new Error(`proposal ${id} already ${record.status}`);
317
+ }
318
+
319
+ const artifactAbs = blockArtifactPath(record.block);
320
+ const newContent = readStagedContent(record);
321
+
322
+ let updatedBody;
323
+ if (record.op === 'append') {
324
+ const existing = fs.existsSync(artifactAbs) ? fs.readFileSync(artifactAbs, 'utf-8') : '';
325
+ const sep = existing.length === 0 ? '' : (existing.endsWith('\n') ? '\n' : '\n\n');
326
+ updatedBody = existing + sep + newContent;
327
+ if (!updatedBody.endsWith('\n')) updatedBody += '\n';
328
+ } else if (record.op === 'replace-all') {
329
+ updatedBody = newContent.endsWith('\n') ? newContent : newContent + '\n';
330
+ } else if (record.op === 'replace-section') {
331
+ if (!fs.existsSync(artifactAbs)) {
332
+ throw new Error(`cannot replace-section — artifact missing at ${toRepoRelative(artifactAbs)}`);
333
+ }
334
+ const existing = fs.readFileSync(artifactAbs, 'utf-8');
335
+ const hit = findSectionByHeading(existing, record.section);
336
+ if (hit.error) {
337
+ throw new Error(`section validation failed at approval: ${hit.error}`);
338
+ }
339
+ const s = hit.section;
340
+ const replacement = newContent.endsWith('\n') ? newContent : newContent + '\n';
341
+ updatedBody = existing.slice(0, s.start) + replacement + existing.slice(s.end);
342
+ } else {
343
+ throw new Error(`unknown op: ${record.op}`);
344
+ }
345
+
346
+ ensureDir(path.dirname(artifactAbs));
347
+ fs.writeFileSync(artifactAbs, updatedBody, 'utf-8');
348
+
349
+ // Archive the record + content to applied/
350
+ ensureDir(APPLIED_DIR);
351
+ const applied = {
352
+ ...record,
353
+ status: 'approved',
354
+ decidedAt: new Date().toISOString(),
355
+ };
356
+ writeRecord(proposalRecordPath(id, APPLIED_DIR), applied);
357
+ // Move staged content
358
+ const stagedAbs = proposalContentPath(id);
359
+ const archivedContentAbs = proposalContentPath(id, APPLIED_DIR);
360
+ if (fs.existsSync(stagedAbs)) {
361
+ fs.renameSync(stagedAbs, archivedContentAbs);
362
+ }
363
+ // Remove the pending record
364
+ try { fs.unlinkSync(recordPath); } catch (_err) { /* non-critical */ }
365
+
366
+ return applied;
367
+ }
368
+
369
+ function rejectProposal({ id, reason = '' } = {}) {
370
+ if (!id) throw new Error('rejectProposal requires id');
371
+ const recordPath = proposalRecordPath(id);
372
+ const record = readRecord(recordPath);
373
+ if (!record) throw new Error(`no pending proposal with id ${id}`);
374
+ if (record.status !== 'pending') {
375
+ throw new Error(`proposal ${id} already ${record.status}`);
376
+ }
377
+
378
+ ensureDir(REJECTED_DIR);
379
+ const rejected = {
380
+ ...record,
381
+ status: 'rejected',
382
+ decidedAt: new Date().toISOString(),
383
+ reason: String(reason || '').trim(),
384
+ };
385
+ writeRecord(proposalRecordPath(id, REJECTED_DIR), rejected);
386
+ // Move staged content
387
+ const stagedAbs = proposalContentPath(id);
388
+ const archivedContentAbs = proposalContentPath(id, REJECTED_DIR);
389
+ if (fs.existsSync(stagedAbs)) {
390
+ fs.renameSync(stagedAbs, archivedContentAbs);
391
+ }
392
+ try { fs.unlinkSync(recordPath); } catch (_err) { /* non-critical */ }
393
+
394
+ return rejected;
395
+ }
396
+
397
+ // ============================================================
398
+ // Public API — diff preview (for session-end surfacing, AC #6)
399
+ // ============================================================
400
+
401
+ /**
402
+ * Render a short preview of what a pending proposal would do. Returns a
403
+ * multi-line string suitable for terminal display. Non-throwing.
404
+ */
405
+ function previewProposal(record) {
406
+ const block = record.block;
407
+ const artifactAbs = blockArtifactPath(block);
408
+ const artifactExists = fs.existsSync(artifactAbs);
409
+ let staged = '';
410
+ try { staged = readStagedContent(record); } catch (_err) { staged = '<missing staged content>'; }
411
+ const stagedHead = staged.split('\n').slice(0, 6).join('\n');
412
+ const icon = record.op === 'append' ? '+' : record.op === 'replace-section' ? '~' : '!';
413
+
414
+ const lines = [
415
+ ` ${icon} ${block} [${record.op}] (${record.id})`,
416
+ ` proposedAt: ${record.proposedAt} by: ${record.proposedBy}`,
417
+ ];
418
+ if (record.section) lines.push(` section: ${record.section}`);
419
+ if (record.rationale) lines.push(` rationale: ${record.rationale}`);
420
+ lines.push(` artifact: ${toRepoRelative(artifactAbs)}${artifactExists ? '' : ' (new)'}`);
421
+ lines.push(` preview (first 6 lines of staged content):`);
422
+ for (const l of stagedHead.split('\n')) {
423
+ lines.push(` ${l}`);
424
+ }
425
+ return lines.join('\n');
426
+ }
427
+
428
+ // ============================================================
429
+ // Exports
430
+ // ============================================================
431
+
432
+ module.exports = {
433
+ // Core API
434
+ createProposal,
435
+ listProposals,
436
+ findProposal,
437
+ approveProposal,
438
+ rejectProposal,
439
+ previewProposal,
440
+
441
+ // Section parsing (exposed for tests and CLI)
442
+ parseSections,
443
+ findSectionByHeading,
444
+
445
+ // Path helpers
446
+ pathFor: {
447
+ proposals: PROPOSALS_DIR,
448
+ applied: APPLIED_DIR,
449
+ rejected: REJECTED_DIR,
450
+ artifact: blockArtifactPath,
451
+ record: proposalRecordPath,
452
+ content: proposalContentPath,
453
+ },
454
+
455
+ // Constants
456
+ VALID_BLOCKS,
457
+ VALID_OPS,
458
+ };
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const MODES_DIR = path.join(process.cwd(), '.workflow', 'modes');
7
+
8
+ const VALID_MODE_NAMES = new Set([
9
+ 'exploring',
10
+ 'spec_review',
11
+ 'coding',
12
+ 'validating',
13
+ 'completing'
14
+ ]);
15
+
16
+ const REQUIRED_FIELDS = ['name', 'roleDefinition', 'whenToUse'];
17
+ const OPTIONAL_FIELDS = ['customInstructions', 'allowedToolGroups'];
18
+ const ALL_FIELDS = new Set([...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]);
19
+
20
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
21
+
22
+ function parseModeYaml(content, sourceLabel = '<inline>') {
23
+ const result = Object.create(null);
24
+ const lines = content.split('\n');
25
+
26
+ let i = 0;
27
+ while (i < lines.length) {
28
+ const raw = lines[i];
29
+ const line = raw.replace(/\s+$/, '');
30
+ const trimmed = line.trim();
31
+
32
+ if (!trimmed || trimmed.startsWith('#')) {
33
+ i += 1;
34
+ continue;
35
+ }
36
+
37
+ const indent = raw.length - raw.trimStart().length;
38
+ if (indent !== 0) {
39
+ throw new Error(
40
+ `${sourceLabel}: line ${i + 1}: top-level keys must start at column 0 (got indent ${indent})`
41
+ );
42
+ }
43
+
44
+ const colonIdx = trimmed.indexOf(':');
45
+ if (colonIdx === -1) {
46
+ throw new Error(`${sourceLabel}: line ${i + 1}: expected "key: value" (no colon found)`);
47
+ }
48
+
49
+ const key = trimmed.slice(0, colonIdx).trim();
50
+ if (!key) {
51
+ throw new Error(`${sourceLabel}: line ${i + 1}: empty key`);
52
+ }
53
+ if (DANGEROUS_KEYS.has(key)) {
54
+ throw new Error(`${sourceLabel}: line ${i + 1}: forbidden key "${key}"`);
55
+ }
56
+ if (Object.prototype.hasOwnProperty.call(result, key)) {
57
+ throw new Error(`${sourceLabel}: line ${i + 1}: duplicate key "${key}"`);
58
+ }
59
+
60
+ const valueRaw = trimmed.slice(colonIdx + 1).trim();
61
+
62
+ if (valueRaw === '|') {
63
+ const { text, nextIndex } = readBlockScalar(lines, i + 1, sourceLabel);
64
+ result[key] = text;
65
+ i = nextIndex;
66
+ continue;
67
+ }
68
+
69
+ if (valueRaw === '') {
70
+ const { items, nextIndex } = readList(lines, i + 1, sourceLabel);
71
+ if (items === null) {
72
+ throw new Error(
73
+ `${sourceLabel}: line ${i + 1}: key "${key}" has no value, scalar block, or list`
74
+ );
75
+ }
76
+ result[key] = items;
77
+ i = nextIndex;
78
+ continue;
79
+ }
80
+
81
+ result[key] = unquoteScalar(valueRaw);
82
+ i += 1;
83
+ }
84
+
85
+ return result;
86
+ }
87
+
88
+ function readBlockScalar(lines, startIndex, sourceLabel) {
89
+ const collected = [];
90
+ let baseIndent = null;
91
+ let i = startIndex;
92
+
93
+ while (i < lines.length) {
94
+ const raw = lines[i];
95
+ if (raw.trim() === '') {
96
+ collected.push('');
97
+ i += 1;
98
+ continue;
99
+ }
100
+ const indent = raw.length - raw.trimStart().length;
101
+ if (indent === 0) break;
102
+ if (baseIndent === null) baseIndent = indent;
103
+ if (indent < baseIndent) break;
104
+ collected.push(raw.slice(baseIndent));
105
+ i += 1;
106
+ }
107
+
108
+ if (baseIndent === null) {
109
+ throw new Error(`${sourceLabel}: line ${startIndex}: block scalar (|) has no indented content`);
110
+ }
111
+
112
+ while (collected.length > 0 && collected[collected.length - 1] === '') {
113
+ collected.pop();
114
+ }
115
+
116
+ return { text: collected.join('\n'), nextIndex: i };
117
+ }
118
+
119
+ function readList(lines, startIndex, sourceLabel) {
120
+ const items = [];
121
+ let i = startIndex;
122
+ let sawListItem = false;
123
+
124
+ while (i < lines.length) {
125
+ const raw = lines[i];
126
+ const trimmed = raw.trim();
127
+ if (!trimmed || trimmed.startsWith('#')) {
128
+ i += 1;
129
+ continue;
130
+ }
131
+ const indent = raw.length - raw.trimStart().length;
132
+ if (indent === 0) break;
133
+ if (!trimmed.startsWith('- ')) {
134
+ throw new Error(
135
+ `${sourceLabel}: line ${i + 1}: expected list item starting with "- "`
136
+ );
137
+ }
138
+ sawListItem = true;
139
+ const value = trimmed.slice(2).trim();
140
+ if (!value) {
141
+ throw new Error(`${sourceLabel}: line ${i + 1}: empty list item`);
142
+ }
143
+ items.push(unquoteScalar(value));
144
+ i += 1;
145
+ }
146
+
147
+ if (!sawListItem) {
148
+ return { items: null, nextIndex: startIndex };
149
+ }
150
+ return { items, nextIndex: i };
151
+ }
152
+
153
+ function unquoteScalar(value) {
154
+ if (value.length >= 2) {
155
+ const first = value[0];
156
+ const last = value[value.length - 1];
157
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
158
+ return value.slice(1, -1);
159
+ }
160
+ }
161
+ return value;
162
+ }
163
+
164
+ function validateMode(obj, sourceLabel = '<object>') {
165
+ if (obj === null || typeof obj !== 'object') {
166
+ throw new Error(`${sourceLabel}: mode must be an object, got ${typeof obj}`);
167
+ }
168
+
169
+ for (const field of REQUIRED_FIELDS) {
170
+ if (!Object.prototype.hasOwnProperty.call(obj, field)) {
171
+ throw new Error(`${sourceLabel}: missing required field "${field}"`);
172
+ }
173
+ if (typeof obj[field] !== 'string' || obj[field].trim() === '') {
174
+ throw new Error(`${sourceLabel}: field "${field}" must be a non-empty string`);
175
+ }
176
+ }
177
+
178
+ if (!VALID_MODE_NAMES.has(obj.name)) {
179
+ throw new Error(
180
+ `${sourceLabel}: field "name" must be one of ${[...VALID_MODE_NAMES].join(', ')} (got "${obj.name}")`
181
+ );
182
+ }
183
+
184
+ if (Object.prototype.hasOwnProperty.call(obj, 'customInstructions')) {
185
+ if (typeof obj.customInstructions !== 'string') {
186
+ throw new Error(`${sourceLabel}: field "customInstructions" must be a string`);
187
+ }
188
+ }
189
+
190
+ if (Object.prototype.hasOwnProperty.call(obj, 'allowedToolGroups')) {
191
+ const groups = obj.allowedToolGroups;
192
+ if (!Array.isArray(groups)) {
193
+ throw new Error(`${sourceLabel}: field "allowedToolGroups" must be an array`);
194
+ }
195
+ for (let i = 0; i < groups.length; i += 1) {
196
+ if (typeof groups[i] !== 'string' || groups[i].trim() === '') {
197
+ throw new Error(
198
+ `${sourceLabel}: field "allowedToolGroups[${i}]" must be a non-empty string`
199
+ );
200
+ }
201
+ }
202
+ }
203
+
204
+ for (const key of Object.keys(obj)) {
205
+ if (!ALL_FIELDS.has(key)) {
206
+ throw new Error(`${sourceLabel}: unknown field "${key}"`);
207
+ }
208
+ }
209
+
210
+ return true;
211
+ }
212
+
213
+ function loadMode(name, options = {}) {
214
+ if (typeof name !== 'string' || !name) {
215
+ throw new Error('loadMode: mode name must be a non-empty string');
216
+ }
217
+ if (!VALID_MODE_NAMES.has(name)) {
218
+ throw new Error(
219
+ `loadMode: unknown mode "${name}" (valid: ${[...VALID_MODE_NAMES].join(', ')})`
220
+ );
221
+ }
222
+
223
+ const dir = options.modesDir || MODES_DIR;
224
+ const filePath = path.join(dir, `${name}.yaml`);
225
+
226
+ let content;
227
+ try {
228
+ content = fs.readFileSync(filePath, 'utf-8');
229
+ } catch (err) {
230
+ throw new Error(`loadMode: failed to read ${filePath}: ${err.message}`);
231
+ }
232
+
233
+ const parsed = parseModeYaml(content, filePath);
234
+ validateMode(parsed, filePath);
235
+ return parsed;
236
+ }
237
+
238
+ function listModeFiles(dir = MODES_DIR) {
239
+ if (!fs.existsSync(dir)) return [];
240
+ return fs
241
+ .readdirSync(dir)
242
+ .filter((f) => f.endsWith('.yaml'))
243
+ .map((f) => f.replace(/\.yaml$/, ''));
244
+ }
245
+
246
+ module.exports = {
247
+ loadMode,
248
+ validateMode,
249
+ parseModeYaml,
250
+ listModeFiles,
251
+ VALID_MODE_NAMES,
252
+ REQUIRED_FIELDS,
253
+ OPTIONAL_FIELDS,
254
+ MODES_DIR
255
+ };