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,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
@@ -56,11 +56,13 @@ WOGI_EXPECT_SCRIPT="$WOGI_CLAUDE_DIR/wogi-claude-expect.exp"
56
56
  # default remains opt-in — the v2.22.3 regression (expect's text match miss on
57
57
  # Ink ANSI output) is bounded to users who explicitly asked for expect.
58
58
  #
59
- # The rewritten wogi-claude-expect.exp (v2.26.2) replaces the old brittle
60
- # per-chunk text match with: rolling buffer + ANSI strip + bounded elapsed-time
61
- # window. Misses fall back to the same failure mode as running claude without
62
- # the wrapper (dialog stays up until someone presses Enter) no unsafe blind
63
- # keystrokes injected into server-mode Claude.
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.
64
66
 
65
67
  __wogi_is_worker=0
66
68
  if [ -n "${WOGI_WORKSPACE_ROOT:-}" ] && [ -n "${WOGI_REPO_NAME:-}" ] && \
@@ -68,6 +70,67 @@ if [ -n "${WOGI_WORKSPACE_ROOT:-}" ] && [ -n "${WOGI_REPO_NAME:-}" ] && \
68
70
  __wogi_is_worker=1
69
71
  fi
70
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
+
71
134
  __wogi_wants_expect=0
72
135
  if [ -z "${WOGI_NO_EXPECT:-}" ]; then
73
136
  if [ "$__wogi_is_worker" -eq 1 ] || [ "${WOGI_USE_EXPECT:-}" = "1" ]; then
@@ -105,13 +168,24 @@ if [ "$__wogi_wants_expect" -eq 1 ]; then
105
168
  fi
106
169
  fi
107
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
+
108
181
  # run_claude — invoke claude, routing through expect when we can auto-dismiss
109
182
  # the dev-channels dialog. Preserves stdin/stdout/stderr exactly.
110
183
  run_claude() {
184
+ __wogi_build_argv "$@"
111
185
  if [ "$__wogi_use_expect" -eq 1 ]; then
112
- expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "$@"
186
+ expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
113
187
  else
114
- "$CLAUDE_BIN" "$@"
188
+ "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
115
189
  fi
116
190
  }
117
191
 
@@ -122,10 +196,11 @@ for arg in "$@"; do
122
196
  filtered=()
123
197
  for a in "$@"; do [ "$a" = "--no-wogi-restart" ] || filtered+=("$a"); done
124
198
  CLAUDE_BIN="${WOGI_CLAUDE_BIN:-claude}"
199
+ __wogi_build_argv "${filtered[@]}"
125
200
  if [ "$__wogi_use_expect" -eq 1 ]; then
126
- exec expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "${filtered[@]}"
201
+ exec expect "$WOGI_EXPECT_SCRIPT" "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
127
202
  else
128
- exec "$CLAUDE_BIN" "${filtered[@]}"
203
+ exec "$CLAUDE_BIN" "${__wogi_claude_argv[@]+"${__wogi_claude_argv[@]}"}"
129
204
  fi
130
205
  fi
131
206
  done