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.
- package/.claude/commands/wogi-bug.md +30 -0
- package/.claude/commands/wogi-debug-hypothesis.md +33 -0
- package/.claude/commands/wogi-morning.md +1 -2
- package/.claude/commands/wogi-review.md +31 -2
- package/.claude/commands/wogi-start.md +32 -0
- package/.claude/commands/wogi-statusline-setup.md +12 -0
- package/.claude/commands/wogi-story.md +3 -2
- package/.claude/docs/claude-code-compatibility.md +40 -0
- package/.claude/docs/phases/01-explore.md +2 -1
- package/.claude/docs/phases/03-implement.md +4 -0
- package/.claude/docs/phases/04-verify.md +45 -0
- package/.claude/rules/README.md +36 -0
- package/.claude/rules/_internal/worker-tool-first-turn.md +82 -0
- package/.claude/rules/alternative-execpolicy-toml-command-policy.md +11 -0
- package/.claude/rules/alternative-hand-edit-ready-json-to-register-orpha.md +11 -0
- package/.claude/rules/alternative-permission-ruleset-per-phase.md +11 -0
- package/.claude/rules/alternative-short-name.md +12 -0
- package/.claude/rules/alternative-wogi-flow-as-mcp-client-oauth-manager.md +11 -0
- package/.claude/rules/architecture/hook-three-layer.md +68 -0
- package/.claude/rules/dual-repo-architecture-2026-02-28.md +18 -0
- package/.claude/rules/github-release-workflow-2026-01-30.md +16 -0
- package/.claude/settings.json +1 -1
- package/.workflow/agents/logic-adversary.md +2 -1
- package/.workflow/agents/personas/README.md +48 -0
- package/.workflow/agents/personas/platform-rigor.md +38 -0
- package/.workflow/agents/personas/scale-skeptic.md +28 -0
- package/.workflow/agents/personas/security-hawk.md +34 -0
- package/.workflow/agents/personas/simplicity-champion.md +37 -0
- package/.workflow/agents/personas/user-advocate.md +36 -0
- package/.workflow/bridges/base-bridge.js +46 -23
- package/.workflow/templates/claude-md.hbs +44 -122
- package/.workflow/templates/partials/feature-dossiers.hbs +33 -0
- package/.workflow/templates/partials/intent-grounded-reasoning.hbs +2 -12
- package/.workflow/templates/partials/methodology-rules.hbs +85 -79
- package/.workflow/templates/tier3-dom-field-inventory.md +102 -0
- package/lib/fuzzy-patch.js +251 -0
- package/lib/installer.js +8 -0
- package/lib/memory-proposal-store.js +458 -0
- package/lib/mode-schema.js +255 -0
- package/lib/skill-proposal-store.js +432 -0
- package/lib/skill-registry.js +1 -1
- package/lib/wogi-claude +149 -9
- package/lib/wogi-claude-expect.exp +113 -76
- package/lib/workspace-channel-server.js +19 -0
- package/lib/workspace-contracts.js +1 -1
- package/lib/workspace-dispatch-tracking.js +144 -0
- package/lib/workspace-gates.js +1 -1
- package/lib/workspace-ipc-sqlite.js +550 -0
- package/lib/workspace-messages.js +92 -0
- package/lib/workspace-routing.js +1 -1
- package/lib/workspace-task-injector.js +223 -0
- package/lib/workspace.js +23 -0
- package/lib/worktree-review.js +315 -0
- package/package.json +2 -2
- package/scripts/base-workflow-step.js +1 -1
- package/scripts/flow +28 -4
- package/scripts/flow-ac-scope-preservation.js +238 -0
- package/scripts/flow-auto-review-worker.js +75 -0
- package/scripts/flow-auto-review.js +102 -0
- package/scripts/flow-autonomous-detector.js +118 -0
- package/scripts/flow-autonomous-mode.js +153 -0
- package/scripts/flow-best-of-n.js +1 -1
- package/scripts/flow-bulk-loop.js +1 -1
- package/scripts/flow-checkpoint.js +2 -6
- package/scripts/flow-community-sync.js +1 -1
- package/scripts/flow-completion-summary.js +176 -0
- package/scripts/flow-completion-truth-gate.js +343 -4
- package/scripts/flow-config-defaults.js +52 -5
- package/scripts/flow-config-loader.js +3 -2
- package/scripts/flow-context-compact/expander.js +1 -1
- package/scripts/flow-context-compact/section-extractor.js +2 -2
- package/scripts/flow-context-gatherer.js +1 -1
- package/scripts/flow-context-generator.js +1 -1
- package/scripts/flow-context-scoring.js +1 -1
- package/scripts/flow-correct.js +1 -1
- package/scripts/flow-correction-detector.js +3 -2
- package/scripts/flow-decision-authority.js +66 -15
- package/scripts/flow-done.js +33 -1
- package/scripts/flow-epic-cascade.js +171 -0
- package/scripts/flow-epics.js +2 -7
- package/scripts/flow-eval-judge.js +1 -1
- package/scripts/flow-eval.js +1 -1
- package/scripts/flow-export-scanner.js +2 -6
- package/scripts/flow-failure-learning.js +1 -1
- package/scripts/flow-feature-dossier.js +787 -0
- package/scripts/flow-figma-extract.js +2 -2
- package/scripts/flow-figma-generate.js +1 -1
- package/scripts/flow-gate-confidence.js +1 -1
- package/scripts/flow-health.js +52 -1
- package/scripts/flow-hooks.js +1 -1
- package/scripts/flow-id.js +19 -3
- package/scripts/flow-instruction-richness.js +1 -1
- package/scripts/flow-knowledge-router.js +1 -1
- package/scripts/flow-knowledge-sync.js +1 -1
- package/scripts/flow-logic-adversary.js +76 -1
- package/scripts/flow-logic-rules.js +380 -0
- package/scripts/flow-long-input.js +5 -5
- package/scripts/flow-memory-sync.js +1 -1
- package/scripts/flow-memory.js +78 -7
- package/scripts/flow-migrate.js +1 -1
- package/scripts/flow-model-caller.js +1 -1
- package/scripts/flow-models.js +2 -2
- package/scripts/flow-morning.js +0 -17
- package/scripts/flow-multi-approach.js +1 -1
- package/scripts/flow-orchestrate-context.js +4 -4
- package/scripts/flow-orchestrate-templates.js +1 -1
- package/scripts/flow-orchestrate.js +8 -8
- package/scripts/flow-peer-review.js +1 -1
- package/scripts/flow-phase.js +9 -0
- package/scripts/flow-proactive-compact.js +1 -1
- package/scripts/flow-prompt-composer.js +3 -2
- package/scripts/flow-prompt-template.js +3 -2
- package/scripts/flow-providers.js +1 -1
- package/scripts/flow-question-queue.js +255 -0
- package/scripts/flow-repo-map.js +312 -0
- package/scripts/flow-review-passes/index.js +1 -1
- package/scripts/flow-review-passes/integration.js +1 -1
- package/scripts/flow-review-passes/structure.js +1 -1
- package/scripts/flow-revision-tracker.js +1 -1
- package/scripts/flow-section-resolver.js +1 -1
- package/scripts/flow-session-end.js +74 -5
- package/scripts/flow-session-state.js +103 -1
- package/scripts/flow-setup-hooks.js +1 -1
- package/scripts/flow-skeptical-evaluator.js +274 -0
- package/scripts/flow-skill-generator.js +3 -3
- package/scripts/flow-skill-learn.js +3 -6
- package/scripts/flow-skill-manage.js +248 -0
- package/scripts/flow-spec-verifier.js +1 -1
- package/scripts/flow-standards-checker.js +75 -0
- package/scripts/flow-standards-gate.js +1 -1
- package/scripts/flow-statusline-setup.js +8 -2
- package/scripts/flow-step-changelog.js +2 -2
- package/scripts/flow-step-coverage.js +1 -1
- package/scripts/flow-step-knowledge.js +1 -1
- package/scripts/flow-step-regression.js +1 -1
- package/scripts/flow-step-simplifier.js +1 -1
- package/scripts/flow-task-analyzer.js +1 -1
- package/scripts/flow-task-classifier.js +1 -1
- package/scripts/flow-task-enforcer.js +1 -1
- package/scripts/flow-template-extractor.js +1 -1
- package/scripts/flow-trap-zone.js +1 -1
- package/scripts/flow-utils.js +4 -0
- package/scripts/flow-worker-mcp-strip.js +122 -0
- package/scripts/flow-worker-question-classifier.js +51 -5
- package/scripts/flow-workspace-migrate-ipc.js +216 -0
- package/scripts/flow-workspace-summary.js +256 -0
- package/scripts/hooks/adapters/base-adapter.js +2 -2
- package/scripts/hooks/core/feature-dossier-gate.js +194 -0
- package/scripts/hooks/core/observation-capture.js +24 -0
- package/scripts/hooks/core/overdue-dispatches.js +20 -1
- package/scripts/hooks/core/phase-gate.js +15 -1
- package/scripts/hooks/core/phase-transition-auto-review.js +61 -0
- package/scripts/hooks/core/post-compact.js +5 -2
- package/scripts/hooks/core/pre-tool-orchestrator.js +21 -0
- package/scripts/hooks/core/routing-gate.js +58 -0
- package/scripts/hooks/core/session-context.js +108 -0
- package/scripts/hooks/core/session-end-memory-proposals.js +65 -0
- package/scripts/hooks/core/session-end-skill-proposals.js +58 -0
- package/scripts/hooks/core/session-end.js +25 -0
- package/scripts/hooks/core/setup-handler.js +1 -1
- package/scripts/hooks/core/task-boundary-reset.js +110 -4
- package/scripts/hooks/core/worker-boundary-gate.js +71 -0
- package/scripts/hooks/core/worker-tool-first-gate.js +275 -0
- package/scripts/hooks/entry/claude-code/post-tool-use.js +2 -2
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +7 -2
- package/scripts/hooks/entry/claude-code/session-start.js +74 -30
- package/scripts/hooks/entry/claude-code/stop.js +47 -1
- package/scripts/hooks/entry/claude-code/user-prompt-submit.js +17 -0
- 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 {
|
|
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
|
-
* @
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|