wogiflow 2.8.0 → 2.9.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.
@@ -63,6 +63,12 @@ const WORKSPACE_GATES = [
63
63
  description: 'Verify integration map is up-to-date',
64
64
  phase: 'pre',
65
65
  severity: 'warning'
66
+ },
67
+ {
68
+ name: 'deploymentReadiness',
69
+ description: 'Verify changes are committed and pushed before handoff to downstream workers',
70
+ phase: 'post',
71
+ severity: 'error'
66
72
  }
67
73
  ];
68
74
 
@@ -434,6 +440,84 @@ function broadcastPostChange(workspaceRoot, fromRepo, context, options = {}) {
434
440
  * @param {Object} [taskMeta] — { taskId, taskTitle, changedFiles, impactAssessed }
435
441
  * @returns {{ passed: boolean, message: string, severity: string }}
436
442
  */
443
+ /**
444
+ * Deployment readiness gate — verifies changes are committed and pushed
445
+ * before allowing handoff to downstream workers.
446
+ *
447
+ * In workspace mode, when backend completes and frontend needs to start,
448
+ * the backend's changes MUST be committed and pushed first. Otherwise the
449
+ * frontend worker will build against stale code.
450
+ *
451
+ * Checks:
452
+ * 1. No uncommitted changes in the current repo (git status clean)
453
+ * 2. Local branch is not ahead of remote (changes are pushed)
454
+ *
455
+ * @param {string} workspaceRoot
456
+ * @param {Object} context
457
+ * @param {Object} taskMeta
458
+ * @returns {{ passed: boolean, message: string, severity: string }}
459
+ */
460
+ function gateDeploymentReadiness(workspaceRoot, context, taskMeta) {
461
+ const { execFileSync } = require('node:child_process');
462
+
463
+ try {
464
+ // Check 1: No uncommitted changes
465
+ const statusOutput = execFileSync('git', ['status', '--porcelain'], {
466
+ encoding: 'utf-8',
467
+ timeout: 5000,
468
+ stdio: ['pipe', 'pipe', 'pipe'],
469
+ cwd: workspaceRoot || process.cwd()
470
+ }).trim();
471
+
472
+ if (statusOutput) {
473
+ const lineCount = statusOutput.split('\n').filter(Boolean).length;
474
+ return {
475
+ passed: false,
476
+ message: `${lineCount} uncommitted change(s). Commit and push before handoff to downstream workers.`,
477
+ severity: 'error'
478
+ };
479
+ }
480
+
481
+ // Check 2: Not ahead of remote (changes pushed)
482
+ try {
483
+ const aheadOutput = execFileSync('git', ['rev-list', '--count', '@{upstream}..HEAD'], {
484
+ encoding: 'utf-8',
485
+ timeout: 5000,
486
+ stdio: ['pipe', 'pipe', 'pipe'],
487
+ cwd: workspaceRoot || process.cwd()
488
+ }).trim();
489
+
490
+ const aheadCount = parseInt(aheadOutput, 10);
491
+ if (aheadCount > 0) {
492
+ return {
493
+ passed: false,
494
+ message: `${aheadCount} commit(s) not pushed to remote. Push before handoff to downstream workers.`,
495
+ severity: 'error'
496
+ };
497
+ }
498
+ } catch (_err) {
499
+ // No upstream configured — skip push check but warn
500
+ return {
501
+ passed: true,
502
+ message: 'No upstream branch configured — push check skipped',
503
+ severity: 'warning'
504
+ };
505
+ }
506
+
507
+ return {
508
+ passed: true,
509
+ message: 'Changes committed and pushed — ready for downstream handoff',
510
+ severity: 'info'
511
+ };
512
+ } catch (err) {
513
+ return {
514
+ passed: true,
515
+ message: `Deployment readiness check failed (${err.message}) — degraded to manual`,
516
+ severity: 'warning'
517
+ };
518
+ }
519
+ }
520
+
437
521
  function runWorkspaceGate(gateName, workspaceRoot, context, taskMeta = {}) {
438
522
  switch (gateName) {
439
523
  case 'crossRepoImpactCheck':
@@ -451,6 +535,9 @@ function runWorkspaceGate(gateName, workspaceRoot, context, taskMeta = {}) {
451
535
  case 'integrationMapFreshness':
452
536
  return gateIntegrationMapFreshness(workspaceRoot);
453
537
 
538
+ case 'deploymentReadiness':
539
+ return gateDeploymentReadiness(workspaceRoot, context, taskMeta);
540
+
454
541
  default:
455
542
  return { passed: true, message: `Unknown gate: ${gateName}`, severity: 'warning' };
456
543
  }
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Manager Session Handoff
5
+ *
6
+ * The workspace manager doesn't have WogiFlow installed locally
7
+ * (it orchestrates, doesn't code). So /wogi-session-end fails.
8
+ *
9
+ * This module provides workspace-aware session management:
10
+ * - saveManagerHandoff() — captures session state for next session
11
+ * - loadManagerHandoff() — restores state at session start
12
+ *
13
+ * The handoff document (.workspace/state/manager-session.json) includes:
14
+ * - Dispatched tasks summary (what was sent to which worker)
15
+ * - Pending/completed workspace tasks
16
+ * - Unread worker messages
17
+ * - Active locks
18
+ * - Last sync timestamp
19
+ * - Session notes (what was discussed, decisions made)
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+
27
+ const { WORKSPACE_CONFIG_FILE, WORKSPACE_DIR } = require('./workspace');
28
+
29
+ // ============================================================
30
+ // Manager Detection
31
+ // ============================================================
32
+
33
+ /**
34
+ * Detect if the current session is a workspace manager.
35
+ * The manager is the session running in the workspace root directory
36
+ * (where wogi-workspace.json lives), NOT in a member repo.
37
+ *
38
+ * @param {string} [cwd]
39
+ * @returns {{ isManager: boolean, workspaceRoot: string|null }}
40
+ */
41
+ function isWorkspaceManager(cwd) {
42
+ const dir = cwd || process.cwd();
43
+ const configPath = path.join(dir, WORKSPACE_CONFIG_FILE);
44
+
45
+ if (fs.existsSync(configPath)) {
46
+ // Check this is the root, not a member repo that happens to have the config
47
+ // The manager's cwd IS the workspace root
48
+ return { isManager: true, workspaceRoot: dir };
49
+ }
50
+
51
+ return { isManager: false, workspaceRoot: null };
52
+ }
53
+
54
+ // ============================================================
55
+ // Session Handoff — Save
56
+ // ============================================================
57
+
58
+ /**
59
+ * Save a manager session handoff document.
60
+ * Called when the manager session ends (or user says "wrap up").
61
+ *
62
+ * @param {string} workspaceRoot
63
+ * @param {Object} [options]
64
+ * @param {string} [options.sessionNotes] — free-text notes about what was discussed
65
+ * @param {string[]} [options.decisionsM made] — decisions made during this session
66
+ * @returns {Object} the saved handoff document
67
+ */
68
+ function saveManagerHandoff(workspaceRoot, options = {}) {
69
+ const handoff = {
70
+ savedAt: new Date().toISOString(),
71
+ workspaceName: '',
72
+ members: {},
73
+ dispatched: [],
74
+ pendingTasks: [],
75
+ completedTasks: [],
76
+ unreadMessages: [],
77
+ activeLocks: [],
78
+ contractDrifts: [],
79
+ lastSyncAt: null,
80
+ sessionNotes: options.sessionNotes || '',
81
+ decisions: options.decisions || []
82
+ };
83
+
84
+ // Read workspace config
85
+ const configPath = path.join(workspaceRoot, WORKSPACE_CONFIG_FILE);
86
+ try {
87
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
88
+ handoff.workspaceName = config.name || '';
89
+
90
+ // Read each member's task status
91
+ for (const [name, memberConfig] of Object.entries(config.members || {})) {
92
+ const memberPath = path.resolve(workspaceRoot, memberConfig.path);
93
+ const readyPath = path.join(memberPath, '.workflow', 'state', 'ready.json');
94
+
95
+ const memberStatus = {
96
+ role: memberConfig.role,
97
+ inProgress: [],
98
+ ready: [],
99
+ recentlyCompleted: []
100
+ };
101
+
102
+ try {
103
+ if (fs.existsSync(readyPath)) {
104
+ const ready = JSON.parse(fs.readFileSync(readyPath, 'utf-8'));
105
+ memberStatus.inProgress = (ready.inProgress || []).map(t => ({ id: t.id, title: t.title }));
106
+ memberStatus.ready = (ready.ready || []).map(t => ({ id: t.id, title: t.title }));
107
+ memberStatus.recentlyCompleted = (ready.recentlyCompleted || []).slice(0, 5).map(t => ({ id: t.id, title: t.title }));
108
+ }
109
+ } catch (_err) {
110
+ // Non-critical
111
+ }
112
+
113
+ handoff.members[name] = memberStatus;
114
+ }
115
+ } catch (_err) {
116
+ // Config read failure — save what we can
117
+ }
118
+
119
+ // Read workspace-level tasks
120
+ const wsReadyPath = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'ready.json');
121
+ try {
122
+ if (fs.existsSync(wsReadyPath)) {
123
+ const wsReady = JSON.parse(fs.readFileSync(wsReadyPath, 'utf-8'));
124
+ handoff.pendingTasks = (wsReady.ready || []).map(t => ({ id: t.id, title: t.title }));
125
+ handoff.completedTasks = (wsReady.recentlyCompleted || []).slice(0, 10).map(t => ({ id: t.id, title: t.title }));
126
+ }
127
+ } catch (_err) {
128
+ // Non-critical
129
+ }
130
+
131
+ // Read unread messages
132
+ try {
133
+ const messagesDir = path.join(workspaceRoot, WORKSPACE_DIR, 'messages');
134
+ if (fs.existsSync(messagesDir)) {
135
+ const files = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json'));
136
+ for (const file of files) {
137
+ try {
138
+ const msg = JSON.parse(fs.readFileSync(path.join(messagesDir, file), 'utf-8'));
139
+ if (msg.status === 'pending') {
140
+ handoff.unreadMessages.push({
141
+ id: msg.id,
142
+ from: msg.from,
143
+ type: msg.type,
144
+ subject: msg.subject,
145
+ timestamp: msg.timestamp
146
+ });
147
+ }
148
+ } catch (_err) {
149
+ // Skip malformed
150
+ }
151
+ }
152
+ }
153
+ } catch (_err) {
154
+ // Non-critical
155
+ }
156
+
157
+ // Read active locks
158
+ try {
159
+ const locksDir = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'locks');
160
+ if (fs.existsSync(locksDir)) {
161
+ const now = Date.now();
162
+ const files = fs.readdirSync(locksDir).filter(f => f.endsWith('.json'));
163
+ for (const file of files) {
164
+ try {
165
+ const lock = JSON.parse(fs.readFileSync(path.join(locksDir, file), 'utf-8'));
166
+ if (new Date(lock.expiresAt).getTime() > now) {
167
+ handoff.activeLocks.push({
168
+ interface: lock.interface,
169
+ owner: lock.owner,
170
+ expiresAt: lock.expiresAt
171
+ });
172
+ }
173
+ } catch (_err) {
174
+ // Skip
175
+ }
176
+ }
177
+ }
178
+ } catch (_err) {
179
+ // Non-critical
180
+ }
181
+
182
+ // Check manifest freshness
183
+ const manifestPath = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'workspace-manifest.json');
184
+ try {
185
+ if (fs.existsSync(manifestPath)) {
186
+ const stat = fs.statSync(manifestPath);
187
+ handoff.lastSyncAt = stat.mtime.toISOString();
188
+ }
189
+ } catch (_err) {
190
+ // Non-critical
191
+ }
192
+
193
+ // Write the handoff document
194
+ const handoffPath = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'manager-session.json');
195
+ fs.mkdirSync(path.dirname(handoffPath), { recursive: true });
196
+ fs.writeFileSync(handoffPath, JSON.stringify(handoff, null, 2));
197
+
198
+ return handoff;
199
+ }
200
+
201
+ // ============================================================
202
+ // Session Handoff — Load (for session start)
203
+ // ============================================================
204
+
205
+ /**
206
+ * Load the previous manager session handoff.
207
+ * Called at session start to restore context.
208
+ *
209
+ * @param {string} workspaceRoot
210
+ * @returns {Object|null} handoff document or null if none exists
211
+ */
212
+ function loadManagerHandoff(workspaceRoot) {
213
+ const handoffPath = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'manager-session.json');
214
+
215
+ try {
216
+ if (fs.existsSync(handoffPath)) {
217
+ return JSON.parse(fs.readFileSync(handoffPath, 'utf-8'));
218
+ }
219
+ } catch (_err) {
220
+ // Non-critical
221
+ }
222
+
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Format the handoff document as a readable session start briefing.
228
+ *
229
+ * @param {Object} handoff — from loadManagerHandoff()
230
+ * @returns {string} formatted briefing
231
+ */
232
+ function formatHandoffBriefing(handoff) {
233
+ if (!handoff) return 'No previous session handoff found.';
234
+
235
+ const lines = [];
236
+ const age = Math.round((Date.now() - new Date(handoff.savedAt).getTime()) / (60 * 60 * 1000));
237
+
238
+ lines.push('Previous Session Handoff');
239
+ lines.push('━'.repeat(40));
240
+ lines.push(`Saved: ${handoff.savedAt} (${age}h ago)`);
241
+ lines.push(`Workspace: ${handoff.workspaceName}`);
242
+ lines.push('');
243
+
244
+ // Member status
245
+ lines.push('Member Status:');
246
+ for (const [name, status] of Object.entries(handoff.members || {})) {
247
+ const inProgress = status.inProgress?.length || 0;
248
+ const ready = status.ready?.length || 0;
249
+ lines.push(` ${name} (${status.role}): ${inProgress} in-progress, ${ready} ready`);
250
+ for (const t of status.inProgress || []) {
251
+ lines.push(` >> ${t.id}: ${t.title}`);
252
+ }
253
+ }
254
+ lines.push('');
255
+
256
+ // Unread messages
257
+ if (handoff.unreadMessages?.length > 0) {
258
+ lines.push(`Unread Messages (${handoff.unreadMessages.length}):`);
259
+ for (const msg of handoff.unreadMessages) {
260
+ lines.push(` [${msg.type}] from ${msg.from}: ${msg.subject}`);
261
+ }
262
+ lines.push('');
263
+ }
264
+
265
+ // Active locks
266
+ if (handoff.activeLocks?.length > 0) {
267
+ lines.push(`Active Locks (${handoff.activeLocks.length}):`);
268
+ for (const lock of handoff.activeLocks) {
269
+ lines.push(` ${lock.interface} — held by ${lock.owner} (expires: ${lock.expiresAt})`);
270
+ }
271
+ lines.push('');
272
+ }
273
+
274
+ // Session notes
275
+ if (handoff.sessionNotes) {
276
+ lines.push('Session Notes:');
277
+ lines.push(` ${handoff.sessionNotes}`);
278
+ lines.push('');
279
+ }
280
+
281
+ // Decisions
282
+ if (handoff.decisions?.length > 0) {
283
+ lines.push('Decisions Made:');
284
+ for (const d of handoff.decisions) {
285
+ lines.push(` - ${d}`);
286
+ }
287
+ lines.push('');
288
+ }
289
+
290
+ // Manifest freshness
291
+ if (handoff.lastSyncAt) {
292
+ const syncAge = Math.round((Date.now() - new Date(handoff.lastSyncAt).getTime()) / (60 * 60 * 1000));
293
+ lines.push(`Last sync: ${syncAge}h ago${syncAge > 24 ? ' (STALE — run flow workspace sync)' : ''}`);
294
+ }
295
+
296
+ return lines.join('\n');
297
+ }
298
+
299
+ // ============================================================
300
+ // Exports
301
+ // ============================================================
302
+
303
+ module.exports = {
304
+ isWorkspaceManager,
305
+ saveManagerHandoff,
306
+ loadManagerHandoff,
307
+ formatHandoffBriefing
308
+ };
package/lib/workspace.js CHANGED
@@ -575,9 +575,45 @@ ${memberLines.join('\n')}
575
575
  ## Session Startup Checklist
576
576
 
577
577
  When you start a session, do this FIRST:
578
- 1. Read \`.workspace/state/workspace-manifest.json\` — understand the current integration map
579
- 2. Check \`.workspace/messages/\` for unread messages (status: "pending") show them to the user
580
- 3. Read each member's \`.workflow/state/ready.json\`know what tasks are in progress
578
+ 1. **Check for previous session handoff**: Read \`.workspace/state/manager-session.json\` if it exists this tells you what happened in the last session, what's pending, and what the user was working on. Display the briefing to the user.
579
+ 2. Read \`.workspace/state/workspace-manifest.json\`understand the current integration map
580
+ 3. Check \`.workspace/messages/\` for unread messages (status: "pending") show them to the user
581
+ 4. Read each member's \`.workflow/state/ready.json\` — know what tasks are in progress
582
+ 5. If manifest is stale (>24h): suggest \`flow workspace sync\`
583
+
584
+ ## Session End (Manager Handoff)
585
+
586
+ When the user says "wrap up", "session end", "that's all":
587
+
588
+ **You are the workspace manager — you don't have /wogi-session-end.** Instead, save a handoff document:
589
+
590
+ 1. **Collect session state**:
591
+ - What tasks were dispatched to which workers (and their status)
592
+ - Current state of each member's ready.json (in-progress, ready, completed)
593
+ - Unread messages from workers
594
+ - Active locks on shared interfaces
595
+ - Manifest freshness (when was last sync?)
596
+
597
+ 2. **Ask the user**: "Any session notes or decisions to carry forward?"
598
+
599
+ 3. **Save handoff** to \`.workspace/state/manager-session.json\`:
600
+ \`\`\`bash
601
+ # Read current workspace state and save
602
+ cat .workspace/state/workspace-manifest.json | head -5 # Check freshness
603
+ ls -t .workspace/messages/*.json 2>/dev/null | head -10 # Unread messages
604
+ \`\`\`
605
+
606
+ 4. **Write the JSON handoff** with: savedAt, members (per-repo task status), unreadMessages, activeLocks, sessionNotes, decisions, lastSyncAt
607
+
608
+ 5. **Display session summary**:
609
+ \`\`\`
610
+ Session Summary:
611
+ Dispatched: N tasks to M repos
612
+ Completed: N tasks
613
+ Pending: N messages, N locks
614
+ Handoff saved to: .workspace/state/manager-session.json
615
+ Next session will resume from this point.
616
+ \`\`\`
581
617
 
582
618
  ## How to Route Tasks
583
619
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -161,6 +161,7 @@ const RESEARCH_TRIGGERS = {
161
161
  const CONFIG_DEFAULTS = {
162
162
  // --- Core ---
163
163
  version: '2.0.0',
164
+ _configVersion: 2, // Tracks config schema version for migrations (see flow-config-migrate.js)
164
165
  projectName: '',
165
166
  cli: {
166
167
  type: 'claude-code',
@@ -172,6 +173,7 @@ const CONFIG_DEFAULTS = {
172
173
  enforcement: {
173
174
  strictMode: true,
174
175
  requireTaskForImplementation: true,
176
+ requireGateLatch: true, // Gate latch: quality gates must pass before TaskCompleted allows completion
175
177
  requirePatternCitation: false,
176
178
  citationFormat: '// Pattern: {pattern}',
177
179
  blockAutoTask: true,
@@ -300,11 +302,11 @@ const CONFIG_DEFAULTS = {
300
302
  qualityGates: {
301
303
  preTaskBaseline: { enabled: false },
302
304
  feature: {
303
- require: ['loopComplete', 'tests', 'generatedTestsPass', 'uiVerification', 'apiVerification', 'registryUpdate', 'requestLogEntry', 'integrationWiring', 'standardsCompliance'],
305
+ require: ['loopComplete', 'tests', 'generatedTestsPass', 'uiVerification', 'apiVerification', 'verificationProof', 'registryUpdate', 'requestLogEntry', 'integrationWiring', 'standardsCompliance'],
304
306
  optional: ['review', 'docs', 'webmcpVerification']
305
307
  },
306
308
  bugfix: {
307
- require: ['loopComplete', 'tests', 'generatedTestsPass', 'requestLogEntry', 'standardsCompliance'],
309
+ require: ['loopComplete', 'tests', 'generatedTestsPass', 'verificationProof', 'requestLogEntry', 'standardsCompliance'],
308
310
  optional: ['learningEnforcement', 'resolutionPopulated', 'review', 'webmcpVerification']
309
311
  },
310
312
  refactor: {
@@ -327,7 +329,7 @@ const CONFIG_DEFAULTS = {
327
329
 
328
330
  // --- Standards & Compliance ---
329
331
  standardsCompliance: {
330
- enabled: false,
332
+ enabled: true,
331
333
  mode: 'block',
332
334
  scopeByTaskType: true,
333
335
  alwaysCheck: ['naming', 'security'],
@@ -337,6 +339,27 @@ const CONFIG_DEFAULTS = {
337
339
  checkpoint: { enabled: false },
338
340
  regressionTesting: { enabled: false },
339
341
 
342
+ // --- Runtime Verification (CC 2.1.89+ enforcement) ---
343
+ // Ensures agents actually test their work before marking done.
344
+ // Without this, agents claim "done" based on static evidence only.
345
+ runtimeVerification: {
346
+ enabled: true,
347
+ autoGenerateTests: true,
348
+ blockOnFailure: true,
349
+ frontend: {
350
+ method: 'webmcp',
351
+ fallback: ['playwright', 'checklist'],
352
+ devServerUrl: 'http://localhost:5173'
353
+ },
354
+ backend: {
355
+ method: 'api-test',
356
+ fallback: ['curl', 'checklist'],
357
+ baseUrl: 'http://localhost:3000'
358
+ },
359
+ testOutput: 'tests/verification',
360
+ persistTests: true
361
+ },
362
+
340
363
  // --- Detection (Project Type Awareness) ---
341
364
  detection: {
342
365
  _comment: "Weighted scoring for project type detection. Overrides take precedence over scoring.",
@@ -402,7 +425,7 @@ const CONFIG_DEFAULTS = {
402
425
  threshold: 30,
403
426
  allRegistries: true,
404
427
  aiAsJudge: true,
405
- blockOnSimilar: false,
428
+ blockOnSimilar: true,
406
429
  injectContext: true,
407
430
  preferVariants: true,
408
431
  requireAppMapEntry: true,