@syntesseraai/opencode-feature-factory 0.1.9 → 0.1.11

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@syntesseraai/opencode-feature-factory",
4
- "version": "0.1.9",
4
+ "version": "0.1.11",
5
5
  "description": "OpenCode plugin for Feature Factory agents - provides planning, implementation, review, testing, and validation agents",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -31,4 +31,4 @@
31
31
  "@types/node": "^22.0.0",
32
32
  "typescript": "^5.0.0"
33
33
  }
34
- }
34
+ }
@@ -22,6 +22,10 @@ interface ExtendedSessionState extends SessionState {
22
22
  qualityGatePassed: boolean;
23
23
  /** Debounce timer for session.idle */
24
24
  idleDebounce: ReturnType<typeof setTimeout> | null;
25
+ /** Parent session ID (if this is a sub-agent session) */
26
+ parentID?: string;
27
+ /** Whether this session is read-only (edit or bash denied). undefined = not yet determined */
28
+ isReadOnly?: boolean;
25
29
  }
26
30
 
27
31
  const sessions = new Map<string, ExtendedSessionState>();
@@ -41,6 +45,28 @@ function getSessionState(sessionId: string): ExtendedSessionState {
41
45
  return state;
42
46
  }
43
47
 
48
+ // ============================================================================
49
+ // Permission Helpers
50
+ // ============================================================================
51
+
52
+ /** Permission rule structure from OpenCode SDK */
53
+ interface PermissionRule {
54
+ permission: string;
55
+ pattern: string;
56
+ action: 'allow' | 'deny' | 'ask';
57
+ }
58
+
59
+ /**
60
+ * Check if a session is read-only based on its permission rules.
61
+ * A session is considered read-only if either 'edit' or 'bash' permission is denied.
62
+ */
63
+ function isSessionReadOnly(permission?: PermissionRule[]): boolean {
64
+ if (!permission) return false;
65
+ return permission.some(
66
+ (rule) => (rule.permission === 'edit' || rule.permission === 'bash') && rule.action === 'deny'
67
+ );
68
+ }
69
+
44
70
  // ============================================================================
45
71
  // Global Execution Lock
46
72
  // ============================================================================
@@ -309,6 +335,40 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
309
335
  const cacheSeconds = qualityGate.cacheSeconds ?? DEFAULT_QUALITY_GATE.cacheSeconds;
310
336
  const cwd = qualityGate.cwd ? `${directory}/${qualityGate.cwd}` : directory;
311
337
 
338
+ /**
339
+ * Ensure session metadata (parentID, isReadOnly) is populated.
340
+ * Fetches from API if not already known (fallback for late-started plugin).
341
+ */
342
+ async function ensureSessionMetadata(
343
+ sessionId: string,
344
+ state: ExtendedSessionState
345
+ ): Promise<void> {
346
+ // Already have metadata
347
+ if (state.isReadOnly !== undefined) return;
348
+
349
+ try {
350
+ const response = await client.session.get({ path: { id: sessionId } });
351
+ if (response.data) {
352
+ state.parentID = response.data.parentID;
353
+ // Cast to access permission field (available in API but not in v1 SDK types)
354
+ const permission = (response.data as { permission?: PermissionRule[] }).permission;
355
+ state.isReadOnly = isSessionReadOnly(permission);
356
+ await log(client, 'debug', 'session.metadata-fetched', {
357
+ sessionId,
358
+ parentID: state.parentID,
359
+ isReadOnly: state.isReadOnly,
360
+ });
361
+ }
362
+ } catch (err) {
363
+ // If fetch fails, default to running quality gate (current behavior)
364
+ state.isReadOnly = false;
365
+ await log(client, 'warn', 'session.metadata-fetch-failed', {
366
+ sessionId,
367
+ error: String(err),
368
+ });
369
+ }
370
+ }
371
+
312
372
  /**
313
373
  * Run the quality gate checks for a session.
314
374
  * Uses a global lock to prevent concurrent executions.
@@ -432,6 +492,25 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
432
492
  sessionId,
433
493
  steps: results.map((r) => r.step),
434
494
  });
495
+
496
+ // Show success feedback to user
497
+ await client.session.prompt({
498
+ path: { id: sessionId },
499
+ body: {
500
+ parts: [
501
+ {
502
+ type: 'text',
503
+ text: `## ✅ Quality gate passed
504
+
505
+ All quality checks completed successfully:
506
+
507
+ ${results.map((r) => `- **${r.step}**: ✓`).join('\n')}
508
+
509
+ Your work meets the quality standards and is ready to proceed.`,
510
+ },
511
+ ],
512
+ },
513
+ });
435
514
  } finally {
436
515
  // Always release the lock and trigger next queued session
437
516
  const nextSession = releaseLock(sessionId);
@@ -460,15 +539,33 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
460
539
  const eventProps = (event as { properties?: { sessionID?: string } }).properties;
461
540
  const sessionId = eventProps?.sessionID;
462
541
 
463
- if (event.type === 'session.created' && sessionId) {
464
- sessions.set(sessionId, {
465
- lastRunAt: 0,
466
- lastResults: [],
467
- dirty: true,
468
- qualityGatePassed: false,
469
- idleDebounce: null,
470
- });
471
- await log(client, 'debug', 'session.created', { sessionId });
542
+ if (event.type === 'session.created') {
543
+ // Extract full session info from the event
544
+ const sessionInfo = (
545
+ event as {
546
+ properties?: {
547
+ info?: { id: string; parentID?: string; permission?: PermissionRule[] };
548
+ };
549
+ }
550
+ ).properties?.info;
551
+ const id = sessionInfo?.id;
552
+ if (id) {
553
+ const isReadOnly = isSessionReadOnly(sessionInfo?.permission);
554
+ sessions.set(id, {
555
+ lastRunAt: 0,
556
+ lastResults: [],
557
+ dirty: true,
558
+ qualityGatePassed: false,
559
+ idleDebounce: null,
560
+ parentID: sessionInfo?.parentID,
561
+ isReadOnly,
562
+ });
563
+ await log(client, 'debug', 'session.created', {
564
+ sessionId: id,
565
+ parentID: sessionInfo?.parentID,
566
+ isReadOnly,
567
+ });
568
+ }
472
569
  }
473
570
 
474
571
  if (event.type === 'session.deleted' && sessionId) {
@@ -490,6 +587,24 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
490
587
  return;
491
588
  }
492
589
 
590
+ // Ensure we have session metadata (fetch if needed for late-started plugin)
591
+ await ensureSessionMetadata(sessionId, state);
592
+
593
+ // Skip quality gate for sub-agent sessions (sessions with a parent)
594
+ if (state.parentID) {
595
+ await log(client, 'debug', 'quality-gate.skipped (sub-agent session)', {
596
+ sessionId,
597
+ parentID: state.parentID,
598
+ });
599
+ return;
600
+ }
601
+
602
+ // Skip quality gate for read-only sessions (edit or bash denied)
603
+ if (state.isReadOnly) {
604
+ await log(client, 'debug', 'quality-gate.skipped (read-only session)', { sessionId });
605
+ return;
606
+ }
607
+
493
608
  // Debounce to avoid running multiple times
494
609
  if (state.idleDebounce) {
495
610
  clearTimeout(state.idleDebounce);