@syntesseraai/opencode-feature-factory 0.1.9 → 0.1.10

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.10",
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.
@@ -460,15 +520,33 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
460
520
  const eventProps = (event as { properties?: { sessionID?: string } }).properties;
461
521
  const sessionId = eventProps?.sessionID;
462
522
 
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 });
523
+ if (event.type === 'session.created') {
524
+ // Extract full session info from the event
525
+ const sessionInfo = (
526
+ event as {
527
+ properties?: {
528
+ info?: { id: string; parentID?: string; permission?: PermissionRule[] };
529
+ };
530
+ }
531
+ ).properties?.info;
532
+ const id = sessionInfo?.id;
533
+ if (id) {
534
+ const isReadOnly = isSessionReadOnly(sessionInfo?.permission);
535
+ sessions.set(id, {
536
+ lastRunAt: 0,
537
+ lastResults: [],
538
+ dirty: true,
539
+ qualityGatePassed: false,
540
+ idleDebounce: null,
541
+ parentID: sessionInfo?.parentID,
542
+ isReadOnly,
543
+ });
544
+ await log(client, 'debug', 'session.created', {
545
+ sessionId: id,
546
+ parentID: sessionInfo?.parentID,
547
+ isReadOnly,
548
+ });
549
+ }
472
550
  }
473
551
 
474
552
  if (event.type === 'session.deleted' && sessionId) {
@@ -490,6 +568,24 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
490
568
  return;
491
569
  }
492
570
 
571
+ // Ensure we have session metadata (fetch if needed for late-started plugin)
572
+ await ensureSessionMetadata(sessionId, state);
573
+
574
+ // Skip quality gate for sub-agent sessions (sessions with a parent)
575
+ if (state.parentID) {
576
+ await log(client, 'debug', 'quality-gate.skipped (sub-agent session)', {
577
+ sessionId,
578
+ parentID: state.parentID,
579
+ });
580
+ return;
581
+ }
582
+
583
+ // Skip quality gate for read-only sessions (edit or bash denied)
584
+ if (state.isReadOnly) {
585
+ await log(client, 'debug', 'quality-gate.skipped (read-only session)', { sessionId });
586
+ return;
587
+ }
588
+
493
589
  // Debounce to avoid running multiple times
494
590
  if (state.idleDebounce) {
495
591
  clearTimeout(state.idleDebounce);