wogiflow 2.26.2 → 2.29.1

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 (169) 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 +149 -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-config-loader.js +3 -2
  70. package/scripts/flow-context-compact/expander.js +1 -1
  71. package/scripts/flow-context-compact/section-extractor.js +2 -2
  72. package/scripts/flow-context-gatherer.js +1 -1
  73. package/scripts/flow-context-generator.js +1 -1
  74. package/scripts/flow-context-scoring.js +1 -1
  75. package/scripts/flow-correct.js +1 -1
  76. package/scripts/flow-correction-detector.js +3 -2
  77. package/scripts/flow-decision-authority.js +66 -15
  78. package/scripts/flow-done.js +33 -1
  79. package/scripts/flow-epic-cascade.js +171 -0
  80. package/scripts/flow-epics.js +2 -7
  81. package/scripts/flow-eval-judge.js +1 -1
  82. package/scripts/flow-eval.js +1 -1
  83. package/scripts/flow-export-scanner.js +2 -6
  84. package/scripts/flow-failure-learning.js +1 -1
  85. package/scripts/flow-feature-dossier.js +787 -0
  86. package/scripts/flow-figma-extract.js +2 -2
  87. package/scripts/flow-figma-generate.js +1 -1
  88. package/scripts/flow-gate-confidence.js +1 -1
  89. package/scripts/flow-health.js +52 -1
  90. package/scripts/flow-hooks.js +1 -1
  91. package/scripts/flow-id.js +19 -3
  92. package/scripts/flow-instruction-richness.js +1 -1
  93. package/scripts/flow-knowledge-router.js +1 -1
  94. package/scripts/flow-knowledge-sync.js +1 -1
  95. package/scripts/flow-logic-adversary.js +76 -1
  96. package/scripts/flow-logic-rules.js +380 -0
  97. package/scripts/flow-long-input.js +5 -5
  98. package/scripts/flow-memory-sync.js +1 -1
  99. package/scripts/flow-memory.js +78 -7
  100. package/scripts/flow-migrate.js +1 -1
  101. package/scripts/flow-model-caller.js +1 -1
  102. package/scripts/flow-models.js +2 -2
  103. package/scripts/flow-morning.js +0 -17
  104. package/scripts/flow-multi-approach.js +1 -1
  105. package/scripts/flow-orchestrate-context.js +4 -4
  106. package/scripts/flow-orchestrate-templates.js +1 -1
  107. package/scripts/flow-orchestrate.js +8 -8
  108. package/scripts/flow-peer-review.js +1 -1
  109. package/scripts/flow-phase.js +9 -0
  110. package/scripts/flow-proactive-compact.js +1 -1
  111. package/scripts/flow-prompt-composer.js +3 -2
  112. package/scripts/flow-prompt-template.js +3 -2
  113. package/scripts/flow-providers.js +1 -1
  114. package/scripts/flow-question-queue.js +255 -0
  115. package/scripts/flow-repo-map.js +312 -0
  116. package/scripts/flow-review-passes/index.js +1 -1
  117. package/scripts/flow-review-passes/integration.js +1 -1
  118. package/scripts/flow-review-passes/structure.js +1 -1
  119. package/scripts/flow-revision-tracker.js +1 -1
  120. package/scripts/flow-section-resolver.js +1 -1
  121. package/scripts/flow-session-end.js +74 -5
  122. package/scripts/flow-session-state.js +103 -1
  123. package/scripts/flow-setup-hooks.js +1 -1
  124. package/scripts/flow-skeptical-evaluator.js +274 -0
  125. package/scripts/flow-skill-generator.js +3 -3
  126. package/scripts/flow-skill-learn.js +3 -6
  127. package/scripts/flow-skill-manage.js +248 -0
  128. package/scripts/flow-spec-verifier.js +1 -1
  129. package/scripts/flow-standards-checker.js +75 -0
  130. package/scripts/flow-standards-gate.js +1 -1
  131. package/scripts/flow-statusline-setup.js +8 -2
  132. package/scripts/flow-step-changelog.js +2 -2
  133. package/scripts/flow-step-coverage.js +1 -1
  134. package/scripts/flow-step-knowledge.js +1 -1
  135. package/scripts/flow-step-regression.js +1 -1
  136. package/scripts/flow-step-simplifier.js +1 -1
  137. package/scripts/flow-task-analyzer.js +1 -1
  138. package/scripts/flow-task-classifier.js +1 -1
  139. package/scripts/flow-task-enforcer.js +1 -1
  140. package/scripts/flow-template-extractor.js +1 -1
  141. package/scripts/flow-trap-zone.js +1 -1
  142. package/scripts/flow-utils.js +4 -0
  143. package/scripts/flow-worker-mcp-strip.js +122 -0
  144. package/scripts/flow-worker-question-classifier.js +51 -5
  145. package/scripts/flow-workspace-migrate-ipc.js +216 -0
  146. package/scripts/flow-workspace-summary.js +256 -0
  147. package/scripts/hooks/adapters/base-adapter.js +2 -2
  148. package/scripts/hooks/core/feature-dossier-gate.js +194 -0
  149. package/scripts/hooks/core/observation-capture.js +24 -0
  150. package/scripts/hooks/core/overdue-dispatches.js +20 -1
  151. package/scripts/hooks/core/phase-gate.js +15 -1
  152. package/scripts/hooks/core/phase-transition-auto-review.js +61 -0
  153. package/scripts/hooks/core/post-compact.js +5 -2
  154. package/scripts/hooks/core/pre-tool-orchestrator.js +21 -0
  155. package/scripts/hooks/core/routing-gate.js +58 -0
  156. package/scripts/hooks/core/session-context.js +108 -0
  157. package/scripts/hooks/core/session-end-memory-proposals.js +65 -0
  158. package/scripts/hooks/core/session-end-skill-proposals.js +58 -0
  159. package/scripts/hooks/core/session-end.js +25 -0
  160. package/scripts/hooks/core/setup-handler.js +1 -1
  161. package/scripts/hooks/core/task-boundary-reset.js +110 -4
  162. package/scripts/hooks/core/worker-boundary-gate.js +71 -0
  163. package/scripts/hooks/core/worker-tool-first-gate.js +275 -0
  164. package/scripts/hooks/entry/claude-code/post-tool-use.js +2 -2
  165. package/scripts/hooks/entry/claude-code/pre-tool-use.js +7 -2
  166. package/scripts/hooks/entry/claude-code/session-start.js +74 -30
  167. package/scripts/hooks/entry/claude-code/stop.js +47 -1
  168. package/scripts/hooks/entry/claude-code/user-prompt-submit.js +17 -0
  169. package/.workflow/templates/partials/user-commands.hbs +0 -20
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Session End: Skill Proposal Surfacing
5
+ *
6
+ * Reads pending skill proposals staged by `flow skill propose|patch|remove`
7
+ * and returns a structured summary for the session-end adapter to display.
8
+ *
9
+ * Agent-proposed changes do NOT auto-apply. The user reviews at session-end
10
+ * and runs `flow skill promote <name>` / `flow skill reject <name>`.
11
+ */
12
+
13
+ const store = require('../../../lib/skill-proposal-store');
14
+
15
+ function summarizePendingProposals() {
16
+ let pending;
17
+ try {
18
+ pending = store.listProposals({ status: 'pending' });
19
+ } catch (_err) {
20
+ return null;
21
+ }
22
+ if (!pending || pending.length === 0) return null;
23
+
24
+ const byAction = { propose: 0, patch: 0, remove: 0 };
25
+ const lines = [];
26
+ for (const p of pending) {
27
+ byAction[p.action] = (byAction[p.action] || 0) + 1;
28
+ const icon = p.action === 'propose' ? '+' : p.action === 'patch' ? '~' : '-';
29
+ const rationale = p.rationale ? ` — ${p.rationale}` : '';
30
+ lines.push(` ${icon} ${p.skillName} (${p.action}, ${p.id})${rationale}`);
31
+ }
32
+
33
+ return {
34
+ count: pending.length,
35
+ byAction,
36
+ proposals: pending,
37
+ lines,
38
+ message: formatMessage(pending.length, byAction, lines),
39
+ };
40
+ }
41
+
42
+ function formatMessage(count, byAction, lines) {
43
+ const plural = count !== 1 ? 's' : '';
44
+ const breakdown = Object.entries(byAction)
45
+ .filter(([, n]) => n > 0)
46
+ .map(([a, n]) => `${n} ${a}`)
47
+ .join(', ');
48
+ return [
49
+ `${count} pending skill proposal${plural} (${breakdown}):`,
50
+ ...lines,
51
+ '',
52
+ 'Review with: flow skill pending',
53
+ 'Approve: flow skill promote <name>',
54
+ 'Discard: flow skill reject <name>',
55
+ ].join('\n');
56
+ }
57
+
58
+ module.exports = { summarizePendingProposals };
@@ -68,6 +68,31 @@ function handleSessionEnd(input) {
68
68
  result.logged = false;
69
69
  }
70
70
 
71
+ // Surface pending skill proposals staged by `flow skill propose|patch|remove`.
72
+ // These await user approval (`flow skill promote|reject`) at session end.
73
+ try {
74
+ const { summarizePendingProposals } = require('./session-end-skill-proposals');
75
+ const summary = summarizePendingProposals();
76
+ if (summary) {
77
+ result.pendingSkillProposals = summary;
78
+ }
79
+ } catch (_err) {
80
+ // Non-critical — never block session end
81
+ }
82
+
83
+ // Surface pending IGR-artifact memory proposals staged by `flow memory
84
+ // propose`. These await user approval (`flow memory approve|reject`) at
85
+ // session end. Story: wf-4434851f.
86
+ try {
87
+ const { summarizePendingMemoryProposals } = require('./session-end-memory-proposals');
88
+ const summary = summarizePendingMemoryProposals();
89
+ if (summary) {
90
+ result.pendingMemoryProposals = summary;
91
+ }
92
+ } catch (_err) {
93
+ // Non-critical — never block session end
94
+ }
95
+
71
96
  // Scratch directory cleanup — remove temp files created during session
72
97
  try {
73
98
  const fs = require('node:fs');
@@ -45,7 +45,7 @@ function getSetupConfig() {
45
45
  * @returns {Object} Result: { needsSetup, message, action, context }
46
46
  */
47
47
  function handleSetup(options = {}) {
48
- const { trigger = 'init' } = options;
48
+ const { _trigger = 'init' } = options;
49
49
 
50
50
  if (!isSetupEnabled()) {
51
51
  return {
@@ -51,6 +51,7 @@ const { safeJsonParse } = require('../../flow-io');
51
51
 
52
52
  const PENDING_MARKER_FILE = 'task-just-completed';
53
53
  const LAST_TRIGGERED_FILE = 'task-boundary-last-triggered';
54
+ const CLEAN_COMPLETION_MARKER_FILE = 'task-boundary-clean-completion.json';
54
55
  // Window during which a recentlyCompleted[0] entry is considered "fresh
55
56
  // enough" to retro-mark Phase 1 from the Stop hook. Large enough to cover
56
57
  // a slow quality-gate run; small enough that a session opened hours later
@@ -69,6 +70,56 @@ function getLastTriggeredPath() {
69
70
  return path.join(PATHS.state, LAST_TRIGGERED_FILE);
70
71
  }
71
72
 
73
+ function getCleanCompletionMarkerPath() {
74
+ return path.join(PATHS.state, CLEAN_COMPLETION_MARKER_FILE);
75
+ }
76
+
77
+ /**
78
+ * Write the clean-completion marker when Phase 2 successfully restarts after a
79
+ * cleanly-completed task. The next session's SessionStart hook reads this
80
+ * marker to decide whether to inject an AUTO-PICKUP block (wf-f267ea2a).
81
+ *
82
+ * Best-effort — failure here does NOT abort the restart; auto-pickup just
83
+ * won't fire for this restart.
84
+ */
85
+ function writeCleanCompletionMarker(taskId, taskTitle, options = {}) {
86
+ // Durable write (Story E / wf-e28b6cd8 — Blocker M2 fix).
87
+ //
88
+ // Original failure mode: writeFileSync schedules the data; the kernel buffers
89
+ // it; SIGTERM is dispatched; the process exits before the buffer reaches
90
+ // disk. The restarted session sees no marker → cascade silently fails.
91
+ //
92
+ // Fix: write to a tmp file, fsync the FILE, atomic-rename to final, fsync
93
+ // the DIRECTORY. The directory fsync is what guarantees the rename itself
94
+ // is durable across the SIGTERM/relaunch boundary.
95
+ try {
96
+ const p = getCleanCompletionMarkerPath();
97
+ const dir = path.dirname(p);
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ const payload = {
100
+ version: 1,
101
+ completedTaskId: taskId || null,
102
+ completedTaskTitle: taskTitle || null,
103
+ completedAt: new Date().toISOString(),
104
+ ...(options.nextTaskId ? { nextTaskId: options.nextTaskId } : {})
105
+ };
106
+ const data = JSON.stringify(payload);
107
+ const tmp = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
108
+ const fd = fs.openSync(tmp, 'w');
109
+ try {
110
+ fs.writeSync(fd, data);
111
+ fs.fsyncSync(fd);
112
+ } finally {
113
+ fs.closeSync(fd);
114
+ }
115
+ fs.renameSync(tmp, p);
116
+ try {
117
+ const dfd = fs.openSync(dir, 'r');
118
+ try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
119
+ } catch (_err) { /* directory fsync is best-effort */ }
120
+ } catch (_err) { /* best effort — auto-pickup just won't fire if this fails */ }
121
+ }
122
+
72
123
  function readLastTriggered() {
73
124
  try {
74
125
  return safeJsonParse(getLastTriggeredPath(), null);
@@ -155,9 +206,13 @@ function checkPreconditions() {
155
206
  * Returns a result object for diagnostics; never throws. If something goes
156
207
  * wrong, the Stop hook should continue with its normal flow.
157
208
  *
158
- * @returns {{ triggered: boolean, reason?: string, flagPath?: string, parentPid?: number }}
209
+ * @param {Object} [opts]
210
+ * @param {string} [opts.transcriptPath] - Claude Code transcript path, used by
211
+ * the main-mode question classifier safety net. When absent, the classifier
212
+ * step is skipped (fail-open).
213
+ * @returns {Promise<{ triggered: boolean, reason?: string, flagPath?: string, parentPid?: number }>}
159
214
  */
160
- function consumeAndTriggerRestart() {
215
+ async function consumeAndTriggerRestart(opts = {}) {
161
216
  const markerPath = getPendingMarkerPath();
162
217
  if (!fs.existsSync(markerPath)) {
163
218
  return { triggered: false, reason: 'no-pending-marker' };
@@ -173,6 +228,42 @@ function consumeAndTriggerRestart() {
173
228
  }
174
229
  } catch (_err) { /* flow-ask may not be present in older installs; degrade open */ }
175
230
 
231
+ // Main-mode question classifier safety net (wf-191d5f6e). Catches the case
232
+ // where the AI ends a turn with an open user-facing question but forgot to
233
+ // call `flow ask` first. On a YES classification, auto-write the
234
+ // pending-question marker and defer the restart. Fail-open throughout —
235
+ // any error or skip falls through to normal restart logic.
236
+ try {
237
+ const isWorker = process.env.WOGI_WORKSPACE_ROOT &&
238
+ process.env.WOGI_REPO_NAME &&
239
+ process.env.WOGI_REPO_NAME !== 'manager';
240
+ if (!isWorker) {
241
+ const config = getConfig();
242
+ const clf = config.mainModeQuestionClassifier;
243
+ const enabled = clf?.enabled !== false; // default true
244
+ if (enabled && opts.transcriptPath) {
245
+ const { classifyQuestion } = require('../../flow-worker-question-classifier');
246
+ const result = await classifyQuestion({
247
+ mode: 'main',
248
+ transcriptPath: opts.transcriptPath,
249
+ minConfidence: Number.isFinite(clf?.minConfidence) ? clf.minConfidence : 70,
250
+ model: typeof clf?.model === 'string' ? clf.model : undefined
251
+ });
252
+ if (result?.blocked) {
253
+ try {
254
+ const { markQuestionPending } = require('../../flow-ask');
255
+ markQuestionPending(`auto-deferred: ${String(result.reason || 'classifier detected open question').slice(0, 500)}`);
256
+ } catch (_err) { /* best effort — marker write failure falls through to restart */ }
257
+ return { triggered: false, reason: 'auto-deferred-question-detected' };
258
+ }
259
+ }
260
+ }
261
+ } catch (err) {
262
+ if (process.env.DEBUG) {
263
+ console.error(`[task-boundary-reset] main-mode classifier error (fail-open): ${err.message}`);
264
+ }
265
+ }
266
+
176
267
  const pre = checkPreconditions();
177
268
  if (!pre.ready) {
178
269
  if (process.env.DEBUG) {
@@ -210,6 +301,13 @@ function consumeAndTriggerRestart() {
210
301
  return { triggered: false, reason: `flag-write-failed: ${err.message}` };
211
302
  }
212
303
 
304
+ // Write the clean-completion marker so the NEXT session's SessionStart
305
+ // hook can inject the AUTO-PICKUP block (wf-f267ea2a). This is gated on
306
+ // config.taskBoundaryReset.autoPickupNextTask in session-context.js — the
307
+ // marker is always written here when Phase 2 fires (cheap, harmless), and
308
+ // session-context decides whether to act on it.
309
+ writeCleanCompletionMarker(markerPayload?.taskId, markerPayload?.taskTitle);
310
+
213
311
  // SIGTERM our parent (claude). The wrapper sees the flag on claude's exit
214
312
  // and restarts. If SIGTERM turns out to not shut claude down cleanly in
215
313
  // real testing, try SIGHUP or SIGINT as fallbacks (see spec wf-39e9dc09).
@@ -329,6 +427,8 @@ module.exports = {
329
427
  checkPreconditions,
330
428
  hasPendingMarker,
331
429
  getPendingMarkerPath,
430
+ getCleanCompletionMarkerPath,
431
+ writeCleanCompletionMarker,
332
432
 
333
433
  // Back-compat: earlier code calls this name. Route it to Phase 1 so existing
334
434
  // wiring in task-completed.js still does the right thing (mark the marker,
@@ -352,8 +452,14 @@ if (require.main === module) {
352
452
  process.exit(0);
353
453
  }
354
454
  if (arg === 'consume') {
355
- console.log(JSON.stringify(consumeAndTriggerRestart(), null, 2));
356
- process.exit(0);
455
+ consumeAndTriggerRestart().then((r) => {
456
+ console.log(JSON.stringify(r, null, 2));
457
+ process.exit(0);
458
+ }).catch((err) => {
459
+ console.error(err.message);
460
+ process.exit(1);
461
+ });
462
+ return;
357
463
  }
358
464
  console.log('Usage: node task-boundary-reset.js <check|has-pending|mark|consume>');
359
465
  process.exit(2);
@@ -99,7 +99,78 @@ function isWorkspaceWorker() {
99
99
  return true;
100
100
  }
101
101
 
102
+ /**
103
+ * Path-discipline gate (Story B / wf-ab59f0e4 — Phase 4.5).
104
+ *
105
+ * Single-writer invariant: workers NEVER write into the workspace-manager
106
+ * tree (`<workspace>/.workspace/**`); the manager NEVER writes into worker
107
+ * member-repo `.workflow/state/**` paths. Cross-process state coordination
108
+ * happens exclusively via the channel-dispatch HTTP bus.
109
+ *
110
+ * Without this check, a confused worker (or hostile prompt-injection
111
+ * payload that talked the worker into editing manager state) could corrupt
112
+ * `dispatched-tasks.json` for ALL workers in the workspace. The check
113
+ * fails LOUD (block + clear error) so the boundary violation is impossible
114
+ * to ignore — silent corruption is the worst-case alternative.
115
+ *
116
+ * Returns the same `{ blocked, reason?, message? }` shape as
117
+ * `checkWorkerBoundary` so the caller can compose the two checks.
118
+ *
119
+ * @param {string} toolName
120
+ * @param {Object} toolInput - { file_path } for Edit/Write
121
+ * @returns {{ blocked: boolean, reason?: string, message?: string }}
122
+ */
123
+ function checkPathDiscipline(toolName, toolInput) {
124
+ if (!process.env.WOGI_WORKSPACE_ROOT) return { blocked: false };
125
+ const writeTools = new Set(['Edit', 'Write', 'NotebookEdit']);
126
+ if (!writeTools.has(toolName)) return { blocked: false };
127
+ const filePath = toolInput && (toolInput.file_path || toolInput.notebook_path);
128
+ if (typeof filePath !== 'string' || !filePath) return { blocked: false };
129
+
130
+ const repo = process.env.WOGI_REPO_NAME || '';
131
+ const root = process.env.WOGI_WORKSPACE_ROOT;
132
+ const managerStateDir = `${root.replace(/\/+$/, '')}/.workspace/`;
133
+ const memberStateDirRegex = /\/members?\/[^/]+\/\.workflow\/state\//;
134
+
135
+ if (repo && repo !== 'manager') {
136
+ if (filePath.startsWith(managerStateDir)) {
137
+ return {
138
+ blocked: true,
139
+ reason: 'path-discipline-worker',
140
+ message: [
141
+ `PATH DISCIPLINE: workers MUST NOT write to manager-owned files.`,
142
+ ``,
143
+ `Blocked: ${filePath}`,
144
+ ``,
145
+ `${managerStateDir}** is owned by the manager process.`,
146
+ `Use the channel-dispatch HTTP bus to communicate with the manager;`,
147
+ `never edit shared workspace state directly. See Story B (wf-ab59f0e4)`,
148
+ `Phase 4.5 in .workflow/changes/wf-ab59f0e4.md.`
149
+ ].join('\n')
150
+ };
151
+ }
152
+ }
153
+
154
+ if (repo === 'manager' && memberStateDirRegex.test(filePath)) {
155
+ return {
156
+ blocked: true,
157
+ reason: 'path-discipline-manager',
158
+ message: [
159
+ `PATH DISCIPLINE: manager MUST NOT write to worker member-repo state.`,
160
+ ``,
161
+ `Blocked: ${filePath}`,
162
+ ``,
163
+ `Worker member-repos own their own .workflow/state/. Send a channel`,
164
+ `dispatch to the worker if state changes are needed there.`
165
+ ].join('\n')
166
+ };
167
+ }
168
+
169
+ return { blocked: false };
170
+ }
171
+
102
172
  module.exports = {
103
173
  checkWorkerBoundary,
174
+ checkPathDiscipline,
104
175
  isWorkspaceWorker
105
176
  };
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Worker Tool-First Turn Gate — G1 + G4 + G6 (epic wf-34290000, Workstream G)
5
+ *
6
+ * In workspace worker mode, every turn that follows a UserPromptSubmit
7
+ * (channel dispatch from the manager) MUST contain at least one tool call,
8
+ * and — in strict mode — the first assistant content block MUST be a tool
9
+ * call, not text.
10
+ *
11
+ * Why this exists
12
+ * ----------------
13
+ * Workers communicate with the manager via tool calls (channel dispatches,
14
+ * file edits, test runs) and structured `## Results` payloads. A pure-text
15
+ * response from a worker is invisible to the user (who only sees the manager
16
+ * terminal) and disqualifies the worker from the three-state end-of-turn
17
+ * contract (ACTION | ESCALATION | IDLE — see CLAUDE.md rule "Workspace
18
+ * Autonomous-Mode Action-After-Completion Contract").
19
+ *
20
+ * Three violations this gate detects
21
+ * ----------------------------------
22
+ * G1 — silent halt: zero tool_use blocks in the turn
23
+ * G4 — text-before-tool-call: first content block is text, not tool_use
24
+ * (strict mode only)
25
+ * G6 — documented contract: the named "worker-tool-first-turn" rule
26
+ * referenced in block messages, so the worker
27
+ * sees one coherent contract, not three gates.
28
+ *
29
+ * Fail-open throughout: missing transcript, parse errors, config errors all
30
+ * return `{ blocked: false }`. Silent-halt false-negatives are recoverable;
31
+ * blocking legitimate stops on a gate bug is not.
32
+ *
33
+ * Scope: worker mode only. Main-mode (non-workspace) turns are unaffected —
34
+ * the caller must gate on `isWorkerMode()` before invoking this check.
35
+ */
36
+
37
+ const fs = require('node:fs');
38
+
39
+ const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024; // 4MB cap — large transcripts read last-N lines only
40
+
41
+ /**
42
+ * Inspect the current turn in a worker transcript and determine whether it
43
+ * violates the tool-first contract.
44
+ *
45
+ * @param {Object} opts
46
+ * @param {string} opts.transcriptPath - absolute path to Claude Code JSONL transcript
47
+ * @param {boolean} [opts.strict=true] - when true, also flag text-before-tool-call
48
+ * @returns {{ blocked: boolean, reason?: string, violation?: 'silent-halt'|'text-before-tool-call', ruleId?: string }}
49
+ */
50
+ function checkWorkerToolFirstTurn(opts) {
51
+ const { transcriptPath, strict = true } = opts || {};
52
+ if (!transcriptPath || typeof transcriptPath !== 'string') {
53
+ return { blocked: false, reason: 'no-transcript-path' };
54
+ }
55
+
56
+ const events = readTranscript(transcriptPath);
57
+ if (!events) {
58
+ return { blocked: false, reason: 'transcript-unreadable' };
59
+ }
60
+
61
+ const turn = extractCurrentTurn(events);
62
+ if (!turn) {
63
+ return { blocked: false, reason: 'no-current-turn' };
64
+ }
65
+
66
+ // G1 — zero tool_use blocks across the entire turn.
67
+ if (turn.toolUseCount === 0) {
68
+ return {
69
+ blocked: true,
70
+ violation: 'silent-halt',
71
+ ruleId: 'worker-tool-first-turn',
72
+ reason: 'worker turn after UserPromptSubmit had zero tool calls (silent-halt / text-only response)'
73
+ };
74
+ }
75
+
76
+ // G4 — first content block is text, not tool_use (strict mode only).
77
+ if (strict && turn.firstBlockType === 'text') {
78
+ return {
79
+ blocked: true,
80
+ violation: 'text-before-tool-call',
81
+ ruleId: 'worker-tool-first-turn',
82
+ reason: 'worker turn began with a text block before any tool call (text-before-tool-call)'
83
+ };
84
+ }
85
+
86
+ return { blocked: false };
87
+ }
88
+
89
+ /**
90
+ * Read a JSONL transcript and return parsed event objects. Large transcripts
91
+ * are truncated to the last MAX_TRANSCRIPT_BYTES by reading the file size
92
+ * and, if oversized, reading only the tail. This bounds per-Stop overhead.
93
+ *
94
+ * Returns null on any IO failure (fail-open signal to the caller).
95
+ */
96
+ function readTranscript(p) {
97
+ let raw;
98
+ try {
99
+ const stat = fs.statSync(p);
100
+ if (stat.size > MAX_TRANSCRIPT_BYTES) {
101
+ // Read the last MAX_TRANSCRIPT_BYTES — the first partial line will be
102
+ // dropped by JSON.parse failure (we skip unparseable lines).
103
+ const fd = fs.openSync(p, 'r');
104
+ try {
105
+ const buf = Buffer.alloc(MAX_TRANSCRIPT_BYTES);
106
+ fs.readSync(fd, buf, 0, MAX_TRANSCRIPT_BYTES, stat.size - MAX_TRANSCRIPT_BYTES);
107
+ raw = buf.toString('utf-8');
108
+ } finally {
109
+ fs.closeSync(fd);
110
+ }
111
+ } else {
112
+ raw = fs.readFileSync(p, 'utf-8');
113
+ }
114
+ } catch (_err) {
115
+ return null;
116
+ }
117
+ if (!raw) return [];
118
+
119
+ const events = [];
120
+ const lines = raw.split('\n');
121
+ for (const line of lines) {
122
+ const trimmed = line.trim();
123
+ if (!trimmed) continue;
124
+ try {
125
+ events.push(JSON.parse(trimmed));
126
+ } catch (_err) {
127
+ // Unparseable line (likely the truncated first line when we tailed the
128
+ // file, or a mid-write line) — skip.
129
+ }
130
+ }
131
+ return events;
132
+ }
133
+
134
+ /**
135
+ * From a list of transcript events, isolate the current turn: everything
136
+ * AFTER the most recent user message. Returns turn-level summary:
137
+ *
138
+ * {
139
+ * toolUseCount: number, // total tool_use blocks in the turn
140
+ * firstBlockType: 'text'|'tool_use'|null, // first assistant block type
141
+ * assistantEventCount: number
142
+ * }
143
+ *
144
+ * Returns null if no user message is found (pre-dispatch — not a worker turn
145
+ * we should gate).
146
+ */
147
+ function extractCurrentTurn(events) {
148
+ if (!Array.isArray(events) || events.length === 0) return null;
149
+
150
+ // Find the last user message index. Scan from end.
151
+ let lastUserIdx = -1;
152
+ for (let i = events.length - 1; i >= 0; i--) {
153
+ if (isUserEvent(events[i])) {
154
+ lastUserIdx = i;
155
+ break;
156
+ }
157
+ }
158
+ if (lastUserIdx === -1) return null;
159
+
160
+ // Collect assistant blocks after the last user message, in order.
161
+ let toolUseCount = 0;
162
+ let firstBlockType = null;
163
+ let assistantEventCount = 0;
164
+
165
+ for (let i = lastUserIdx + 1; i < events.length; i++) {
166
+ const entry = events[i];
167
+ if (!isAssistantEvent(entry)) continue;
168
+ assistantEventCount++;
169
+ const blocks = extractContentBlocks(entry);
170
+ for (const block of blocks) {
171
+ if (!block || typeof block !== 'object') continue;
172
+ const t = block.type;
173
+ if (!firstBlockType && (t === 'text' || t === 'tool_use')) {
174
+ firstBlockType = t;
175
+ }
176
+ if (t === 'tool_use') {
177
+ toolUseCount++;
178
+ }
179
+ }
180
+ }
181
+
182
+ return { toolUseCount, firstBlockType, assistantEventCount };
183
+ }
184
+
185
+ function isUserEvent(entry) {
186
+ if (!entry || typeof entry !== 'object') return false;
187
+ return (
188
+ entry.role === 'user' ||
189
+ entry.type === 'user' ||
190
+ (entry.message && entry.message.role === 'user')
191
+ );
192
+ }
193
+
194
+ function isAssistantEvent(entry) {
195
+ if (!entry || typeof entry !== 'object') return false;
196
+ return (
197
+ entry.role === 'assistant' ||
198
+ entry.type === 'assistant' ||
199
+ (entry.message && entry.message.role === 'assistant')
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Extract the content-blocks array from a transcript event. Claude Code's
205
+ * transcript format has evolved, so accept any of:
206
+ * { content: [ {...} ] }
207
+ * { message: { content: [ {...} ] } }
208
+ * { content: 'string' } → [{ type: 'text', text: 'string' }]
209
+ * { message: { content: '...' } } same
210
+ */
211
+ function extractContentBlocks(entry) {
212
+ const content = entry.content ?? entry.message?.content;
213
+ if (typeof content === 'string') {
214
+ return [{ type: 'text', text: content }];
215
+ }
216
+ if (Array.isArray(content)) {
217
+ return content;
218
+ }
219
+ return [];
220
+ }
221
+
222
+ /**
223
+ * Render the standard block message the Stop hook returns when a violation
224
+ * is detected. Centralised so G6 (contract name) stays consistent across
225
+ * violations.
226
+ */
227
+ function renderBlockMessage({ violation, reason }) {
228
+ const port = process.env.WOGI_MANAGER_PORT || '8800';
229
+ const repo = process.env.WOGI_REPO_NAME || '<worker>';
230
+ const head = violation === 'text-before-tool-call'
231
+ ? 'WORKER CONTRACT VIOLATION: text-before-tool-call'
232
+ : 'WORKER CONTRACT VIOLATION: silent-halt (zero tool calls)';
233
+ return [
234
+ head,
235
+ '',
236
+ `Rule: worker-tool-first-turn`,
237
+ `Why: ${reason}`,
238
+ '',
239
+ 'The worker start-of-turn contract requires every turn after a UserPromptSubmit to',
240
+ 'have at least one tool call, with the first content block being a tool call in',
241
+ 'strict mode. Pure-text responses are invisible to the user and disqualify the',
242
+ 'three-state end-of-turn contract (ACTION | ESCALATION | IDLE).',
243
+ '',
244
+ 'Do ONE of these NOW:',
245
+ ' (a) ACTION — invoke the tool you intended to use',
246
+ ' (b) ESCALATE — channel-dispatch a "## QUESTION:" to the manager:',
247
+ ` curl -s -X POST http://127.0.0.1:${port} \\`,
248
+ ` -H "X-Wogi-From: ${repo}" \\`,
249
+ ` --data-binary "## QUESTION: <your question>"`,
250
+ ' (c) REPLY with ## Results — channel-dispatch the structured task reply to manager',
251
+ '',
252
+ 'Then end the turn.'
253
+ ].join('\n');
254
+ }
255
+
256
+ /**
257
+ * Convenience: determine worker mode from env. Exported so callers don\'t
258
+ * have to duplicate the env-check pattern.
259
+ */
260
+ function isWorkerMode() {
261
+ return Boolean(
262
+ process.env.WOGI_WORKSPACE_ROOT &&
263
+ process.env.WOGI_REPO_NAME &&
264
+ process.env.WOGI_REPO_NAME !== 'manager'
265
+ );
266
+ }
267
+
268
+ module.exports = {
269
+ checkWorkerToolFirstTurn,
270
+ renderBlockMessage,
271
+ isWorkerMode,
272
+ // Exported for tests
273
+ extractCurrentTurn,
274
+ readTranscript
275
+ };
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  const { runValidation } = require('../../core/validation');
14
- const { captureObservation } = require('../../core/observation-capture');
14
+ const { captureObservation, selectDuration } = require('../../core/observation-capture');
15
15
  const { runHook } = require('../shared/hook-runner');
16
16
 
17
17
  function extractErrorMessage(toolResponse) {
@@ -47,7 +47,7 @@ runHook('PostToolUse', async ({ parsedInput }) => {
47
47
  toolName,
48
48
  toolInput,
49
49
  toolResponse,
50
- duration: Date.now() - startTime,
50
+ duration: selectDuration(parsedInput, Date.now() - startTime),
51
51
  explorationStatus: toolFailed ? 'rejected' : undefined,
52
52
  rejectionReason: toolFailed ? extractErrorMessage(toolResponse) : undefined
53
53
  });
@@ -63,7 +63,12 @@ try { checkGitSafety = require('../../core/git-safety-gate').checkGitSafety; } c
63
63
  let checkManagerBoundary = _noop;
64
64
  try { checkManagerBoundary = require('../../core/manager-boundary-gate').checkManagerBoundary; } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Manager boundary gate not loaded: ${_err.message}`); }
65
65
  let checkWorkerBoundary = _noop;
66
- try { checkWorkerBoundary = require('../../core/worker-boundary-gate').checkWorkerBoundary; } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Worker boundary gate not loaded: ${_err.message}`); }
66
+ let checkPathDiscipline = _noop;
67
+ try {
68
+ const wbg = require('../../core/worker-boundary-gate');
69
+ checkWorkerBoundary = wbg.checkWorkerBoundary;
70
+ checkPathDiscipline = wbg.checkPathDiscipline;
71
+ } catch (_err) { if (process.env.DEBUG) console.error(`[Hook] Worker boundary gate not loaded: ${_err.message}`); }
67
72
 
68
73
  const { claudeCodeAdapter } = require('../../adapters/claude-code');
69
74
  const { markSkillPending } = require('../../../flow-durable-session');
@@ -101,7 +106,7 @@ runHook('PreToolUse', async ({ input, parsedInput }) => {
101
106
  recordEvidenceRead, checkSpecWriteGate, clearResearchEvidence,
102
107
  checkDeployGate, checkWriteBlock,
103
108
  checkStrikeGate, checkBugfixScope, checkScopeMutation,
104
- checkGitSafety, checkManagerBoundary, checkWorkerBoundary,
109
+ checkGitSafety, checkManagerBoundary, checkWorkerBoundary, checkPathDiscipline,
105
110
  // Side-effect helpers
106
111
  markSkillPending,
107
112
  // Config + runtime