wogiflow 2.26.1 → 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 +134 -18
  43. package/lib/wogi-claude-expect.exp +137 -33
  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,432 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Skill Proposal Store
5
+ *
6
+ * Durable storage for agent-proposed skill changes (new / edit / remove).
7
+ * Proposals are staged until the user reviews them at session-end and approves
8
+ * or rejects. Approved proposals are applied by `promote`; rejected proposals
9
+ * are removed without touching `.claude/skills/`.
10
+ *
11
+ * Storage layout:
12
+ * .workflow/state/skill-proposals.json — proposal records (array)
13
+ * .claude/skills/pending/<name>.md — staged content for propose/patch
14
+ * .claude/skills/archived/<name>.md — destination after approved remove
15
+ *
16
+ * Record schema:
17
+ * {
18
+ * id: "prop-<8hex>",
19
+ * action: "propose" | "patch" | "remove",
20
+ * skillName: string,
21
+ * contentPath: string | null, // repo-relative; null for remove
22
+ * rationale: string,
23
+ * proposedAt: ISO-8601 timestamp,
24
+ * proposedBy: "agent" | "user",
25
+ * status: "pending" | "approved" | "rejected"
26
+ * }
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const crypto = require('node:crypto');
32
+
33
+ const { PATHS, isPathWithinProject } = require('../scripts/flow-paths');
34
+ const { safeJsonParse } = require('../scripts/flow-io');
35
+ const { applyFuzzyPatch, DEFAULT_THRESHOLD } = require('./fuzzy-patch');
36
+
37
+ const PROPOSALS_FILE = path.join(PATHS.state, 'skill-proposals.json');
38
+ const PENDING_DIR = path.join(PATHS.claude, 'skills', 'pending');
39
+ const ARCHIVED_DIR = path.join(PATHS.claude, 'skills', 'archived');
40
+ const ACTIVE_SKILLS_DIR = path.join(PATHS.claude, 'skills');
41
+
42
+ /**
43
+ * Read `skills.fuzzyPatchThreshold` from .workflow/config.json. Falls back to
44
+ * the library default (0.85) on any error. Config read is deferred so test
45
+ * harnesses can point at temp project roots without a stale cache.
46
+ */
47
+ function getFuzzyPatchThreshold() {
48
+ const configPath = path.join(PATHS.root, '.workflow', 'config.json');
49
+ const cfg = safeJsonParse(configPath, null);
50
+ const v = cfg && cfg.skills && cfg.skills.fuzzyPatchThreshold;
51
+ if (typeof v === 'number' && v >= 0 && v <= 1) return v;
52
+ return DEFAULT_THRESHOLD;
53
+ }
54
+
55
+ const VALID_SKILL_NAME = /^[a-z][a-z0-9-]*(?:\/[a-z][a-z0-9-]*)*$/;
56
+ const VALID_ACTIONS = new Set(['propose', 'patch', 'remove']);
57
+
58
+ // ============================================================
59
+ // Low-level helpers
60
+ // ============================================================
61
+
62
+ function ensureDir(dir) {
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ }
65
+
66
+ function readProposals() {
67
+ if (!fs.existsSync(PROPOSALS_FILE)) return [];
68
+ try {
69
+ const raw = fs.readFileSync(PROPOSALS_FILE, 'utf-8');
70
+ const parsed = JSON.parse(raw);
71
+ return Array.isArray(parsed) ? parsed : [];
72
+ } catch (_err) {
73
+ return [];
74
+ }
75
+ }
76
+
77
+ function writeProposals(list) {
78
+ ensureDir(path.dirname(PROPOSALS_FILE));
79
+ fs.writeFileSync(PROPOSALS_FILE, JSON.stringify(list, null, 2) + '\n', 'utf-8');
80
+ }
81
+
82
+ function generateProposalId() {
83
+ return `prop-${crypto.randomBytes(4).toString('hex')}`;
84
+ }
85
+
86
+ function validateSkillName(name) {
87
+ if (!name || typeof name !== 'string') {
88
+ throw new Error('skill name is required');
89
+ }
90
+ if (!VALID_SKILL_NAME.test(name)) {
91
+ throw new Error(
92
+ `invalid skill name '${name}': use lowercase letters, digits, hyphens (kebab-case); forward-slashes allowed for nesting`
93
+ );
94
+ }
95
+ }
96
+
97
+ function skillNameToFile(name) {
98
+ return `${name}.md`;
99
+ }
100
+
101
+ function resolveWithinProject(relOrAbs) {
102
+ const abs = path.isAbsolute(relOrAbs) ? relOrAbs : path.resolve(PATHS.root, relOrAbs);
103
+ if (!isPathWithinProject(abs)) {
104
+ throw new Error(`path escapes project root: ${relOrAbs}`);
105
+ }
106
+ return abs;
107
+ }
108
+
109
+ function toRepoRelative(absPath) {
110
+ return path.relative(PATHS.root, absPath);
111
+ }
112
+
113
+ function activeSkillPath(name) {
114
+ return path.join(ACTIVE_SKILLS_DIR, skillNameToFile(name));
115
+ }
116
+
117
+ function pendingSkillPath(name) {
118
+ return path.join(PENDING_DIR, skillNameToFile(name));
119
+ }
120
+
121
+ function pendingFindPath(name) {
122
+ return path.join(PENDING_DIR, `${name}.find.md`);
123
+ }
124
+
125
+ function pendingReplacePath(name) {
126
+ return path.join(PENDING_DIR, `${name}.replace.md`);
127
+ }
128
+
129
+ function archivedSkillPath(name) {
130
+ return path.join(ARCHIVED_DIR, skillNameToFile(name));
131
+ }
132
+
133
+ // ============================================================
134
+ // Public API
135
+ // ============================================================
136
+
137
+ /**
138
+ * Stage a proposal record. For propose/patch, copies `contentFile` into
139
+ * .claude/skills/pending/<name>.md. For remove, no content is copied.
140
+ */
141
+ function createProposal({
142
+ action,
143
+ skillName,
144
+ contentFile = null,
145
+ findFile = null,
146
+ replaceFile = null,
147
+ rationale = '',
148
+ proposedBy = 'agent',
149
+ }) {
150
+ if (!VALID_ACTIONS.has(action)) {
151
+ throw new Error(`invalid action '${action}': expected propose|patch|remove`);
152
+ }
153
+ validateSkillName(skillName);
154
+
155
+ let contentPath = null;
156
+ let findPath = null;
157
+ let replacePath = null;
158
+ let patchMode = null;
159
+
160
+ if (action === 'propose') {
161
+ if (!contentFile) throw new Error(`--content is required for ${action}`);
162
+ const srcAbs = resolveWithinProject(contentFile);
163
+ if (!fs.existsSync(srcAbs)) throw new Error(`content file not found: ${contentFile}`);
164
+ ensureDir(PENDING_DIR);
165
+ const destAbs = pendingSkillPath(skillName);
166
+ ensureDir(path.dirname(destAbs));
167
+ fs.copyFileSync(srcAbs, destAbs);
168
+ contentPath = toRepoRelative(destAbs);
169
+ } else if (action === 'patch') {
170
+ // Two mutually-exclusive modes:
171
+ // 1. full-replace: --content <file> (legacy / F1 behavior)
172
+ // 2. fuzzy-patch: --find <file> --replace <file> (F3)
173
+ const hasFuzzy = Boolean(findFile || replaceFile);
174
+ const hasFull = Boolean(contentFile);
175
+ if (hasFuzzy && hasFull) {
176
+ throw new Error('patch accepts either --content OR --find/--replace, not both');
177
+ }
178
+ if (!hasFuzzy && !hasFull) {
179
+ throw new Error('patch requires --content, or --find + --replace');
180
+ }
181
+ ensureDir(PENDING_DIR);
182
+
183
+ if (hasFull) {
184
+ patchMode = 'full-replace';
185
+ const srcAbs = resolveWithinProject(contentFile);
186
+ if (!fs.existsSync(srcAbs)) throw new Error(`content file not found: ${contentFile}`);
187
+ const destAbs = pendingSkillPath(skillName);
188
+ ensureDir(path.dirname(destAbs));
189
+ fs.copyFileSync(srcAbs, destAbs);
190
+ contentPath = toRepoRelative(destAbs);
191
+ } else {
192
+ patchMode = 'fuzzy';
193
+ if (!findFile || !replaceFile) {
194
+ throw new Error('fuzzy patch requires both --find and --replace');
195
+ }
196
+ const findAbs = resolveWithinProject(findFile);
197
+ const replAbs = resolveWithinProject(replaceFile);
198
+ if (!fs.existsSync(findAbs)) throw new Error(`find file not found: ${findFile}`);
199
+ if (!fs.existsSync(replAbs)) throw new Error(`replace file not found: ${replaceFile}`);
200
+ // Active skill must exist to fuzzy-patch against.
201
+ const activeAbs = activeSkillPath(skillName);
202
+ if (!fs.existsSync(activeAbs)) {
203
+ throw new Error(`cannot stage fuzzy patch — no active skill at ${toRepoRelative(activeAbs)}`);
204
+ }
205
+ const findDest = pendingFindPath(skillName);
206
+ const replDest = pendingReplacePath(skillName);
207
+ ensureDir(path.dirname(findDest));
208
+ fs.copyFileSync(findAbs, findDest);
209
+ fs.copyFileSync(replAbs, replDest);
210
+ findPath = toRepoRelative(findDest);
211
+ replacePath = toRepoRelative(replDest);
212
+ }
213
+ }
214
+
215
+ if (action === 'remove') {
216
+ const activeAbs = activeSkillPath(skillName);
217
+ if (!fs.existsSync(activeAbs)) {
218
+ throw new Error(`cannot propose remove — no active skill at ${toRepoRelative(activeAbs)}`);
219
+ }
220
+ }
221
+
222
+ const record = {
223
+ id: generateProposalId(),
224
+ action,
225
+ skillName,
226
+ contentPath,
227
+ findPath,
228
+ replacePath,
229
+ patchMode,
230
+ rationale: String(rationale || ''),
231
+ proposedAt: new Date().toISOString(),
232
+ proposedBy: proposedBy === 'user' ? 'user' : 'agent',
233
+ status: 'pending',
234
+ };
235
+
236
+ const list = readProposals();
237
+ list.push(record);
238
+ writeProposals(list);
239
+
240
+ return record;
241
+ }
242
+
243
+ function listProposals(filter = {}) {
244
+ const list = readProposals();
245
+ if (!filter || Object.keys(filter).length === 0) return list;
246
+ return list.filter((r) =>
247
+ Object.entries(filter).every(([k, v]) => r[k] === v)
248
+ );
249
+ }
250
+
251
+ function findProposal({ id = null, skillName = null, action = null, status = 'pending' }) {
252
+ const list = readProposals();
253
+ return list.find((r) =>
254
+ (id ? r.id === id : true) &&
255
+ (skillName ? r.skillName === skillName : true) &&
256
+ (action ? r.action === action : true) &&
257
+ (status ? r.status === status : true)
258
+ );
259
+ }
260
+
261
+ function updateProposalStatus(id, status) {
262
+ const list = readProposals();
263
+ const idx = list.findIndex((r) => r.id === id);
264
+ if (idx < 0) throw new Error(`no proposal with id ${id}`);
265
+ list[idx].status = status;
266
+ list[idx].decidedAt = new Date().toISOString();
267
+ writeProposals(list);
268
+ return list[idx];
269
+ }
270
+
271
+ /**
272
+ * Apply a pending proposal (user-invoked only).
273
+ * propose → move pending/<name>.md → .claude/skills/<name>.md
274
+ * patch → overwrite .claude/skills/<name>.md with pending content
275
+ * remove → move .claude/skills/<name>.md → .claude/skills/archived/<name>.md
276
+ *
277
+ * Selector: `skillName` (finds the most recent pending proposal for that skill)
278
+ * or `id` (exact proposal id). `id` wins when both provided.
279
+ */
280
+ function promoteProposal({ skillName = null, id = null } = {}) {
281
+ if (!skillName && !id) throw new Error('promoteProposal requires skillName or id');
282
+ const proposal = id
283
+ ? findProposal({ id })
284
+ : findProposal({ skillName });
285
+ if (!proposal) {
286
+ throw new Error(`no pending proposal found for ${id || skillName}`);
287
+ }
288
+ if (proposal.status !== 'pending') {
289
+ throw new Error(`proposal ${proposal.id} already ${proposal.status}`);
290
+ }
291
+ validateSkillName(proposal.skillName);
292
+
293
+ const activeAbs = activeSkillPath(proposal.skillName);
294
+ const pendingAbs = pendingSkillPath(proposal.skillName);
295
+ const archivedAbs = archivedSkillPath(proposal.skillName);
296
+
297
+ if (proposal.action === 'propose') {
298
+ if (fs.existsSync(activeAbs)) {
299
+ throw new Error(`cannot promote propose — active skill already exists at ${toRepoRelative(activeAbs)}; use patch instead`);
300
+ }
301
+ if (!fs.existsSync(pendingAbs)) {
302
+ throw new Error(`pending content missing at ${toRepoRelative(pendingAbs)}`);
303
+ }
304
+ ensureDir(path.dirname(activeAbs));
305
+ fs.renameSync(pendingAbs, activeAbs);
306
+ } else if (proposal.action === 'patch') {
307
+ if (!fs.existsSync(activeAbs)) {
308
+ throw new Error(`cannot promote patch — no active skill at ${toRepoRelative(activeAbs)}`);
309
+ }
310
+
311
+ if (proposal.patchMode === 'fuzzy') {
312
+ const findAbs = pendingFindPath(proposal.skillName);
313
+ const replAbs = pendingReplacePath(proposal.skillName);
314
+ if (!fs.existsSync(findAbs) || !fs.existsSync(replAbs)) {
315
+ throw new Error(`pending fuzzy-patch blobs missing for ${proposal.skillName}`);
316
+ }
317
+ const haystack = fs.readFileSync(activeAbs, 'utf-8');
318
+ const find = fs.readFileSync(findAbs, 'utf-8');
319
+ const replace = fs.readFileSync(replAbs, 'utf-8');
320
+ const threshold = getFuzzyPatchThreshold();
321
+ const result = applyFuzzyPatch(haystack, find, replace, { threshold });
322
+ if (!result.applied) {
323
+ // Atomic rejection — leave active skill untouched, leave proposal
324
+ // pending so the user can inspect or reject it manually.
325
+ throw new Error(
326
+ `fuzzy patch rejected for '${proposal.skillName}': ${result.reason} ` +
327
+ `(threshold ${threshold})`
328
+ );
329
+ }
330
+ fs.writeFileSync(activeAbs, result.result, 'utf-8');
331
+ try { fs.unlinkSync(findAbs); } catch (_err) { /* non-critical */ }
332
+ try { fs.unlinkSync(replAbs); } catch (_err) { /* non-critical */ }
333
+ } else {
334
+ // full-replace (legacy / F1 behavior, default when patchMode is absent)
335
+ if (!fs.existsSync(pendingAbs)) {
336
+ throw new Error(`pending content missing at ${toRepoRelative(pendingAbs)}`);
337
+ }
338
+ fs.copyFileSync(pendingAbs, activeAbs);
339
+ fs.unlinkSync(pendingAbs);
340
+ }
341
+ } else if (proposal.action === 'remove') {
342
+ if (!fs.existsSync(activeAbs)) {
343
+ throw new Error(`cannot promote remove — active skill gone at ${toRepoRelative(activeAbs)}`);
344
+ }
345
+ ensureDir(ARCHIVED_DIR);
346
+ ensureDir(path.dirname(archivedAbs));
347
+ fs.renameSync(activeAbs, archivedAbs);
348
+ }
349
+
350
+ return updateProposalStatus(proposal.id, 'approved');
351
+ }
352
+
353
+ function rejectProposal({ skillName = null, id = null } = {}) {
354
+ if (!skillName && !id) throw new Error('rejectProposal requires skillName or id');
355
+ const proposal = id ? findProposal({ id }) : findProposal({ skillName });
356
+ if (!proposal) {
357
+ throw new Error(`no pending proposal found for ${id || skillName}`);
358
+ }
359
+ if (proposal.status !== 'pending') {
360
+ throw new Error(`proposal ${proposal.id} already ${proposal.status}`);
361
+ }
362
+
363
+ // Clean up staged content for propose/patch
364
+ if (proposal.action === 'propose' || proposal.action === 'patch') {
365
+ const pendingAbs = pendingSkillPath(proposal.skillName);
366
+ if (fs.existsSync(pendingAbs)) {
367
+ try { fs.unlinkSync(pendingAbs); } catch (_err) { /* non-critical */ }
368
+ }
369
+ if (proposal.patchMode === 'fuzzy') {
370
+ const findAbs = pendingFindPath(proposal.skillName);
371
+ const replAbs = pendingReplacePath(proposal.skillName);
372
+ if (fs.existsSync(findAbs)) { try { fs.unlinkSync(findAbs); } catch (_err) {} }
373
+ if (fs.existsSync(replAbs)) { try { fs.unlinkSync(replAbs); } catch (_err) {} }
374
+ }
375
+ }
376
+
377
+ return updateProposalStatus(proposal.id, 'rejected');
378
+ }
379
+
380
+ /**
381
+ * Direct archival of an active skill. User-invoked; bypasses proposal staging.
382
+ */
383
+ function archiveSkill(skillName) {
384
+ validateSkillName(skillName);
385
+ const activeAbs = activeSkillPath(skillName);
386
+ if (!fs.existsSync(activeAbs)) {
387
+ throw new Error(`no active skill at ${toRepoRelative(activeAbs)}`);
388
+ }
389
+ const archivedAbs = archivedSkillPath(skillName);
390
+ ensureDir(path.dirname(archivedAbs));
391
+ fs.renameSync(activeAbs, archivedAbs);
392
+ return { skillName, archivedPath: toRepoRelative(archivedAbs) };
393
+ }
394
+
395
+ // ============================================================
396
+ // Exports
397
+ // ============================================================
398
+
399
+ module.exports = {
400
+ // Core API
401
+ createProposal,
402
+ listProposals,
403
+ findProposal,
404
+ promoteProposal,
405
+ rejectProposal,
406
+ archiveSkill,
407
+
408
+ // Path helpers (for tests & hooks)
409
+ pathFor: {
410
+ proposals: PROPOSALS_FILE,
411
+ pending: PENDING_DIR,
412
+ archived: ARCHIVED_DIR,
413
+ active: ACTIVE_SKILLS_DIR,
414
+ activeSkill: activeSkillPath,
415
+ pendingSkill: pendingSkillPath,
416
+ archivedSkill: archivedSkillPath,
417
+ pendingFind: pendingFindPath,
418
+ pendingReplace: pendingReplacePath,
419
+ },
420
+
421
+ // Config access (exposed for tests)
422
+ getFuzzyPatchThreshold,
423
+
424
+ // Low-level helpers (exposed for targeted tests)
425
+ _internal: {
426
+ readProposals,
427
+ writeProposals,
428
+ updateProposalStatus,
429
+ validateSkillName,
430
+ VALID_ACTIONS,
431
+ },
432
+ };
@@ -20,7 +20,7 @@ const {
20
20
  safeReadJson,
21
21
  httpsGet,
22
22
  validatePath,
23
- safeWriteFile
23
+ _safeWriteFile
24
24
  } = require('./utils');
25
25
 
26
26
  // Registry configuration
package/lib/wogi-claude CHANGED
@@ -41,36 +41,151 @@ set -u
41
41
  WOGI_CLAUDE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
42
42
  WOGI_EXPECT_SCRIPT="$WOGI_CLAUDE_DIR/wogi-claude-expect.exp"
43
43
 
44
- # Detect whether to use the expect wrapper (v2.22.4: OPT-IN only).
45
- # Four conditions must all hold:
46
- # 1. WOGI_USE_EXPECT=1 is explicitly set (opt-in)
47
- # 2. WOGI_NO_EXPECT is NOT set (legacy escape hatch still honored)
48
- # 3. `expect` is on PATH and the wogi-claude-expect.exp script exists
49
- # 4. The args include --dangerously-load-development-channels (the only
50
- # flag that triggers the dialog we want to auto-dismiss)
44
+ # Detect whether to use the expect wrapper for auto-dismissing the
45
+ # --dangerously-load-development-channels modal.
51
46
  #
52
- # 2.22.3 tried opt-out by default; in practice, expect's text match can miss
53
- # Ink's ANSI-fragmented output, which deadlocks the dialog (user keystrokes
54
- # get held in expect's buffer instead of reaching claude). 2.22.4 flips to
55
- # opt-in so the default UX is predictable.
47
+ # Precedence (highest to lowest):
48
+ # 1. WOGI_NO_EXPECT=1 always OFF (kill switch)
49
+ # 2. Workspace worker mode ON automatically (headless, cannot Enter by hand)
50
+ # 3. WOGI_USE_EXPECT=1 → ON (explicit opt-in for interactive users)
51
+ # 4. Default → OFF (interactive users get the native Claude Code dialog)
52
+ #
53
+ # Worker auto-enable (v2.26.2): WOGI_WORKSPACE_ROOT + WOGI_REPO_NAME (worker
54
+ # side) are set by `flow workspace start` before spawning this wrapper, so
55
+ # detection here is reliable. Interactive users never set these vars, so their
56
+ # default remains opt-in — the v2.22.3 regression (expect's text match miss on
57
+ # Ink ANSI output) is bounded to users who explicitly asked for expect.
58
+ #
59
+ # v2.26.3: the expect script now uses `interact -o -re` from second zero —
60
+ # stdin flows to claude throughout, so user keystrokes are NEVER captured
61
+ # by expect. The v2.26.2 approach (rolling buffer + ANSI strip in an expect
62
+ # watch block) had a worse failure mode: on match miss, expect owned stdin
63
+ # for 30s and user keystrokes went into its buffer, making the dialog
64
+ # appear frozen. The new interact approach falls back gracefully — mismatch
65
+ # just means the user dismisses the dialog themselves, no black hole.
66
+
67
+ __wogi_is_worker=0
68
+ if [ -n "${WOGI_WORKSPACE_ROOT:-}" ] && [ -n "${WOGI_REPO_NAME:-}" ] && \
69
+ [ "${WOGI_REPO_NAME}" != "manager" ]; then
70
+ __wogi_is_worker=1
71
+ fi
72
+
73
+ # --- wf-8294d960 (Story A): worker MCP-stripping + init banner ---
74
+ #
75
+ # Root cause (measured 2026-04-24): cold-boot of claude --print in a project
76
+ # with claude.ai OAuth + 7 integrations takes 10-20s; claude --bare --print is
77
+ # <1s. The ~10-19s gap is Claude Code's init (OAuth + remote MCP handshakes
78
+ # + LSP + plugin sync + CLAUDE.md discovery + background prefetches).
79
+ # WogiFlow's SessionStart hook is ~128ms — <1% of the problem. See
80
+ # .workflow/scratch/wf-8294d960-investigation/root-cause.md for full data.
81
+ #
82
+ # Workers are specialized autonomous code executors; they typically do NOT
83
+ # need Gmail/Slack/Atlassian/etc. integrations for refactoring code. Stripping
84
+ # claude.ai MCP from worker boot saves ~3-7s per restart. Users who genuinely
85
+ # need these integrations per-worker can opt in via the config key below.
86
+ #
87
+ # Opt-in: set config.workspace.inheritClaudeAiMcpIntegrations to true in
88
+ # .workflow/config.json (or env var WOGI_WORKER_INHERIT_MCP=1) to keep
89
+ # claude.ai MCP integrations active in worker mode. Default: strip (fast).
90
+ __wogi_strip_mcp=0
91
+ if [ "$__wogi_is_worker" -eq 1 ]; then
92
+ __wogi_strip_mcp=1
93
+ # Env override wins
94
+ if [ "${WOGI_WORKER_INHERIT_MCP:-}" = "1" ]; then
95
+ __wogi_strip_mcp=0
96
+ elif command -v node >/dev/null 2>&1; then
97
+ # Check config.json; fail-open (strip on any error, matching spec non-negotiable)
98
+ __wogi_config_inherit="$(node -e '
99
+ try {
100
+ const cfg = require(process.cwd() + "/.workflow/config.json");
101
+ process.stdout.write(String(!!(cfg.workspace && cfg.workspace.inheritClaudeAiMcpIntegrations)));
102
+ } catch (_e) { process.stdout.write("false"); }
103
+ ' 2>/dev/null)"
104
+ if [ "$__wogi_config_inherit" = "true" ]; then
105
+ __wogi_strip_mcp=0
106
+ fi
107
+ fi
108
+ fi
109
+
110
+ # Resolve the empty-MCP config used for stripping. Persistent path so we don't
111
+ # regenerate a tmpfile on every restart; living in .workflow/state/ keeps it
112
+ # alongside other worker state.
113
+ __wogi_empty_mcp_config=""
114
+ if [ "$__wogi_strip_mcp" -eq 1 ]; then
115
+ __wogi_empty_mcp_config="${WOGI_WORKSPACE_ROOT:-$(pwd)}/.workflow/state/worker-empty-mcp.json"
116
+ if [ ! -f "$__wogi_empty_mcp_config" ]; then
117
+ mkdir -p "$(dirname "$__wogi_empty_mcp_config")" 2>/dev/null
118
+ printf '{"mcpServers":{}}\n' > "$__wogi_empty_mcp_config" 2>/dev/null || __wogi_strip_mcp=0
119
+ fi
120
+ fi
121
+
122
+ # Init banner (C-2): workers take ~5-10s even with MCP stripped; tell the user
123
+ # it's not frozen. Interactive solo users don't see this (only workers).
124
+ if [ "$__wogi_is_worker" -eq 1 ] && [ -t 2 ]; then
125
+ __wogi_banner_msg="[wogi-claude] worker '${WOGI_REPO_NAME}' initializing"
126
+ if [ "$__wogi_strip_mcp" -eq 1 ]; then
127
+ __wogi_banner_msg="$__wogi_banner_msg (claude.ai MCP integrations stripped for speed)"
128
+ else
129
+ __wogi_banner_msg="$__wogi_banner_msg (claude.ai MCP inherited — expect +3-7s boot)"
130
+ fi
131
+ echo "$__wogi_banner_msg — expected boot ~5-10s, please wait..." >&2
132
+ fi
133
+
134
+ __wogi_wants_expect=0
135
+ if [ -z "${WOGI_NO_EXPECT:-}" ]; then
136
+ if [ "$__wogi_is_worker" -eq 1 ] || [ "${WOGI_USE_EXPECT:-}" = "1" ]; then
137
+ __wogi_wants_expect=1
138
+ fi
139
+ fi
140
+
56
141
  __wogi_use_expect=0
57
- if [ "${WOGI_USE_EXPECT:-}" = "1" ] && [ -z "${WOGI_NO_EXPECT:-}" ] && \
58
- command -v expect >/dev/null 2>&1 && [ -x "$WOGI_EXPECT_SCRIPT" ]; then
142
+ if [ "$__wogi_wants_expect" -eq 1 ]; then
143
+ # The dialog only fires when --dangerously-load-development-channels is in
144
+ # argv; skip the expect dance otherwise.
145
+ __wogi_has_flag=0
59
146
  for arg in "$@"; do
60
147
  if [ "$arg" = "--dangerously-load-development-channels" ]; then
61
- __wogi_use_expect=1
148
+ __wogi_has_flag=1
62
149
  break
63
150
  fi
64
151
  done
152
+ if [ "$__wogi_has_flag" -eq 1 ]; then
153
+ if command -v expect >/dev/null 2>&1 && [ -x "$WOGI_EXPECT_SCRIPT" ]; then
154
+ __wogi_use_expect=1
155
+ if [ "$__wogi_is_worker" -eq 1 ]; then
156
+ echo "[wogi-claude] worker mode detected — auto-enabled expect-based dialog dismissal" >&2
157
+ fi
158
+ elif [ "$__wogi_is_worker" -eq 1 ]; then
159
+ # Headless worker + missing expect = the dialog WILL deadlock this
160
+ # worker on restart. Warn loudly so the operator can install expect,
161
+ # but still start claude (better than failing the worker outright).
162
+ echo "[wogi-claude] WARNING: worker mode detected (repo '${WOGI_REPO_NAME}') but 'expect' is not installed." >&2
163
+ echo "[wogi-claude] The --dangerously-load-development-channels dialog will block this worker on the next restart." >&2
164
+ echo "[wogi-claude] Install expect to enable headless auto-dismiss:" >&2
165
+ echo "[wogi-claude] macOS: brew install expect" >&2
166
+ echo "[wogi-claude] Debian/Ubuntu: apt install expect" >&2
167
+ fi
168
+ fi
65
169
  fi
66
170
 
171
+ # __wogi_claude_args — emit argv with MCP-stripping flags prepended when needed.
172
+ # Uses bash global array so callers expand it with "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}".
173
+ __wogi_build_argv() {
174
+ __wogi_claude_argv=()
175
+ if [ "$__wogi_strip_mcp" -eq 1 ] && [ -n "$__wogi_empty_mcp_config" ]; then
176
+ __wogi_claude_argv+=(--strict-mcp-config --mcp-config "$__wogi_empty_mcp_config")
177
+ fi
178
+ __wogi_claude_argv+=("$@")
179
+ }
180
+
67
181
  # run_claude — invoke claude, routing through expect when we can auto-dismiss
68
182
  # the dev-channels dialog. Preserves stdin/stdout/stderr exactly.
69
183
  run_claude() {
184
+ __wogi_build_argv "$@"
70
185
  if [ "$__wogi_use_expect" -eq 1 ]; then
71
- expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "$@"
186
+ expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
72
187
  else
73
- "$CLAUDE_BIN" "$@"
188
+ "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
74
189
  fi
75
190
  }
76
191
 
@@ -81,10 +196,11 @@ for arg in "$@"; do
81
196
  filtered=()
82
197
  for a in "$@"; do [ "$a" = "--no-wogi-restart" ] || filtered+=("$a"); done
83
198
  CLAUDE_BIN="${WOGI_CLAUDE_BIN:-claude}"
199
+ __wogi_build_argv "${filtered[@]}"
84
200
  if [ "$__wogi_use_expect" -eq 1 ]; then
85
- exec expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "${filtered[@]}"
201
+ exec expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
86
202
  else
87
- exec "$CLAUDE_BIN" "${filtered[@]}"
203
+ exec "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
88
204
  fi
89
205
  fi
90
206
  done