agent-ecosystem/docs/team-management/member-work-sync-runtime-stop-hook-plan.md
2026-04-29 22:43:19 +03:00

53 KiB

Member Work Sync Runtime Stop Hook Plan

Status: design ready, not implemented Scope: member-work-sync, Claude runtime hook integration, future Codex hook adapter Primary repo: claude_team Secondary dependency: agent_teams_orchestrator runtime hook payload contract Feature name: member-work-sync Recommended cut: provider-neutral turn-settled control plane with Claude Stop hook adapter first


1. Summary

Add a provider-neutral runtime turn-settled signal for member-work-sync.

The goal is not to ping agents directly from a hook. The hook only records that a runtime turn ended. The app then performs the same level-triggered reconcile that member-work-sync already owns:

runtime Stop hook
-> durable raw event spool
-> app-side drain and provider normalization
-> team/member resolver
-> MemberWorkSyncEventQueue
-> MemberWorkSyncReconciler
-> optional durable nudge outbox, if policy allows

Recommended approach:

Provider-neutral Stop hook event pipeline, Claude adapter first 🎯 9 🛡️ 9 🧠 6, roughly 650-1000 LOC.

Why this is the safest direction:

  • Stop is a useful "turn settled" signal, but not proof that work is complete.
  • TeammateIdle is not used as the base because it is Claude/team-specific and does not generalize to future Codex runtime hooks.
  • Hook execution must be fast, non-blocking, and fail-open.
  • The existing member-work-sync agenda fingerprint, lease, cooldown, busy signal, and watchdog separation remain authoritative.
  • Codex can be added later by implementing a second provider adapter that emits the same normalized event contract.

Architecture checkpoint:

  • No blocker question is required before implementation. The safest defaults are clear.
  • The only intentionally deferred decision is production Codex installation. Codex receives a tested adapter seam, but no production launch behavior until its hook payload/config contract is verified.
  • The implementation should be done as an extension of member-work-sync, not as ad hoc logic inside TeamProvisioningService.
  • The hook pipeline is an input signal. It must not become a second watchdog, a second delivery ledger, or a runtime liveness detector.

Implementation summary:

Launch path asks member-work-sync for provider settings patch
-> settings patch appends one Stop hook command
-> Stop hook writes raw provider payload to spool
-> app drainer normalizes payload
-> resolver proves active team/member
-> router enqueues turn_settled reconcile
-> existing reconciler/outbox policy decides whether to do nothing or nudge

Architecture answer:

  • The durable Stop hook ingestion belongs in claude_team, not in agent_teams_orchestrator, because member-work-sync agenda, leases, nudge outbox, cooldowns, and watchdog separation are app-owned policy.
  • The orchestrator should only remain a runtime launcher/bridge. It can help with --settings materialization, but it should not decide member agenda sync.
  • Frontend changes are not needed in v1. This is a control-plane signal, not a new UI workflow.
  • The only cross-repo risk is --settings merge behavior. If claude_team can guarantee a single already-merged inline settings object, no orchestrator change is required. If multiple app-owned inline --settings values can still cross the boundary, the orchestrator merge helper must become hook-aware too.

2. Key Design Decisions

2.1 Use Stop Only As A Wake-Up Signal

The Stop hook must not:

  • decide whether the agent is done;
  • write tasks or comments;
  • send inbox nudges directly;
  • call the app HTTP API;
  • block the runtime with decision: "block";
  • inject more prompt context.

The Stop hook only writes raw event payload to durable storage and exits 0.

This keeps the model turn lifecycle independent from app policy. It also prevents expensive or looping "keep working" prompts from hook code.

2.2 Do Not Mutate Project .claude/settings.local.json

Do not install this hook by editing a user's project-local Claude settings.

Reasons:

  • Project settings may already contain user hooks.
  • Invalid JSON in project settings should not become our problem.
  • Worktrees would each need careful mutation and cleanup.
  • A team launch should not permanently alter a customer repo.
  • Future Codex support needs a provider adapter, not a Claude-only project settings hack.

Preferred installation is a managed --settings fragment added at launch time.

2.3 Hook Command Must Be Generic

Do not bake teamName, memberName, or runId into the settings hook command.

Reason: Claude teammate subprocesses inherit --settings from the parent. A hook setting created for the lead can run inside a teammate process. A hook setting created for one teammate can also be copied through restart paths. Identity must therefore be resolved after the event is recorded.

Allowed identity hints:

  • process environment values, if present;
  • hook payload session_id;
  • hook payload transcript_path;
  • hook payload cwd;
  • runtime launch state and log source attribution.

Hints are never accepted as final truth until validated against active team/member state.

2.4 Use A POSIX Shell Writer, Not Node

The hook command should invoke a tiny app-owned POSIX shell script:

/bin/sh "<app-data>/member-work-sync/hooks/bin/turn-settled-hook-v1.sh" "<spool-dir>" "claude"

Why shell is better than Node here:

  • No dependency on node being available in the user's shell PATH.
  • No dependency on Electron internals from an external hook process.
  • Very small failure surface.
  • Works for Claude now and can be reused for Codex later.

The shell script should not parse JSON. It writes raw stdin to an atomic event file. The app process parses and validates later.

2.5 Spool Files Instead Of Shared JSONL Append

Use one atomic file per hook event, not one append-only JSONL file.

Reason: many agents can stop at nearly the same time. Atomic file writes avoid append interleaving, file lock contention, and partial JSONL line corruption.

Recommended layout:

<teamsBase>/.member-work-sync/runtime-hooks/
  bin/
    turn-settled-hook-v1.sh
  incoming/
    20260429T120102Z-12345-a1b2c3.json
  processing/
  processed/
  invalid/

The hook script writes a hidden temp file under incoming/, then mvs it to a final .json filename.

2.6 Drain In The App Process

The Electron main process owns:

  • reading incoming hook files;
  • parsing raw payloads;
  • normalizing provider-specific payloads;
  • resolving team/member;
  • emitting member-turn-settled events to member-work-sync.

The hook process must stay dumb.

2.7 No New Feature Flag For Claude Stop Hook

Do not add a user-facing feature flag for the Claude Stop hook in v1.

Reason:

  • The hook is fail-open and only enqueues an existing member-work-sync reconcile.
  • It does not directly send nudges.
  • Existing leases, fingerprints, cooldowns, and watchdog separation still decide behavior.
  • A flag would add another state combination without reducing the main risk, which is attribution correctness.

If an emergency kill switch is needed later, prefer an internal config/env-only disable around hook settings generation, not branching inside the core policy.


3. Alternatives Considered

Option 1: Provider-Neutral Stop Hook Spool

🎯 9 🛡️ 9 🧠 6, roughly 650-1000 LOC.

Add provider-neutral infrastructure now. Implement Claude adapter first. Add Codex adapter later by plugging into the same raw-event contract.

Pros:

  • clean provider boundary;
  • minimal model interruption;
  • no user settings mutation;
  • safe under high agent count;
  • aligns with existing member-work-sync control plane.

Cons:

  • more code than direct hook-to-HTTP;
  • requires resolver tests for session and transcript attribution.

Decision: choose this.

Option 2: Claude-Only Stop Hook Directly Enqueues Member

🎯 7 🛡️ 7 🧠 4, roughly 350-600 LOC.

Install a Claude Stop hook that writes { teamName, memberName } directly and bypasses provider-neutral normalization.

Pros:

  • faster to build;
  • simpler tests.

Cons:

  • hard to extend to Codex;
  • unsafe if --settings is inherited across processes;
  • more likely to misattribute lead vs teammate;
  • encourages Claude-specific logic inside the feature.

Decision: reject.

Option 3: Gastown-Style Blocking Stop Hook

🎯 5 🛡️ 5 🧠 6, roughly 400-750 LOC.

Return decision: "block" from the Stop hook when the app thinks there is work.

Pros:

  • can force an immediate continuation.

Cons:

  • risks loops;
  • risks repeated acknowledgement-only turns;
  • increases token usage;
  • bypasses durable outbox and cooldown policy;
  • conflicts with watchdog and member-work-sync separation.

Decision: reject for this app.

Option 4: Use TeammateIdle

🎯 6 🛡️ 7 🧠 4, roughly 250-450 LOC.

Use Claude Code's team-specific TeammateIdle event.

Pros:

  • gives direct team_name and teammate_name for Claude teammates;
  • simpler resolver.

Cons:

  • not provider-neutral;
  • not available for Codex;
  • does not cover lead/non-team sessions;
  • makes future runtime support harder.

Decision: do not use as v1 base.


3.1 External Pattern Takeaways

From the prior Gastown/GoClaw comparison and production control-plane patterns:

  • Keep Gastown's strongest idea: use provider Stop hooks as a low-latency wake-up signal.
  • Do not copy Gastown's blocking Stop hook behavior. It is too easy to create loops and token burn in this product.
  • Keep GoClaw-style simplicity only at adapter boundaries. The app still needs durable state because team membership, worktrees, task boards, and provider runtimes are richer here.
  • Use Kubernetes-style level-triggered reconciliation: the event only says "recheck now", the app recomputes current work state.
  • Use SQS/BullMQ-style lease semantics already present in member-work-sync: a report/lease is temporary and tied to current agenda fingerprint.
  • Use GitHub Actions-style concurrency key behavior: one in-flight reconcile/nudge per team/member/fingerprint, not one per hook event.

This means the architecture should be better suited to this app than a direct Gastown copy: provider hooks are adapters, durable reconciliation remains in the feature core, and watchdog stays a separate slow semantic layer.


4. Clean Architecture Shape

The feature remains under:

src/features/member-work-sync/

New code should follow docs/FEATURE_ARCHITECTURE_STANDARD.md.

4.0 Current Code Integration Map

The implementation should touch these current seams:

src/features/member-work-sync/
  contracts/
    types.ts                                  # add runtime turn-settled diagnostics DTOs only if UI/API needs them
  core/
    domain/
      RuntimeTurnSettledEvent.ts              # normalized event value objects
      RuntimeTurnSettledProvider.ts           # 'claude' | 'codex'
    application/
      RuntimeTurnSettledIngestor.ts           # drain, normalize, resolve, enqueue
      runtimeTurnSettledPorts.ts              # narrow ports
      ports.ts                                # re-export or colocate new ports
  main/
    adapters/
      input/
        MemberWorkSyncTeamChangeRouter.ts     # route member-turn-settled
        RuntimeTurnSettledMemberQueue.ts      # small queue adapter, if keeping router thin
      output/
        TeamRuntimeTurnSettledTargetResolver.ts
    composition/
      createMemberWorkSyncFeature.ts          # wire installer/store/normalizers/scheduler/ingestor
    infrastructure/
      RuntimeTurnSettledSpoolPaths.ts
      ShellRuntimeTurnSettledHookScriptInstaller.ts
      FileRuntimeTurnSettledEventStore.ts
      RuntimeTurnSettledDrainScheduler.ts
      ClaudeStopHookPayloadNormalizer.ts
      CodexStopHookPayloadNormalizer.ts        # tests only first
      runtimeTurnSettledHookSettings.ts        # settings patch builder and hook-aware merge

Launch integration should be intentionally small:

src/main/services/team/TeamProvisioningService.ts
  # call public member-work-sync main helper/facade to get a settings patch
  # pass patch into provider args/settings merge
  # do not import member-work-sync infrastructure internals

src/main/services/runtime/cliSettingsArgs.ts
  # replace or extend current generic --settings JSON merge so hooks arrays are additive

Shared event type touch:

src/shared/types/team.ts
  # add TeamChangeEvent type 'member-turn-settled'

src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts
  # add trigger reason 'turn_settled'

Tests should live with the feature:

test/features/member-work-sync/main/
  RuntimeTurnSettledHookSettings.test.ts
  RuntimeTurnSettledSpool.test.ts
  RuntimeTurnSettledIngestor.test.ts
  RuntimeTurnSettledTargetResolver.test.ts
  createMemberWorkSyncFeature.test.ts

Do not add runtime Stop hook code to:

  • renderer components;
  • TeamTaskStallMonitor;
  • OpenCode delivery ledger;
  • task log stream services;
  • direct IPC handlers outside the feature facade.

Those layers can observe outcomes later, but they should not own ingestion or policy. This keeps SRP clean: runtime hook ingestion is an input adapter, member-work-sync is policy, watchdog is semantic stall policy, and runtime adapters only launch processes.

4.1 Core Domain

core/domain remains provider-agnostic.

Possible additions:

core/domain/RuntimeTurnSettledEvent.ts
core/domain/RuntimeTurnSettledProvider.ts

Domain responsibilities:

  • define normalized event value objects;
  • validate basic invariants;
  • no filesystem;
  • no Electron;
  • no Claude/Codex CLI details.

Example:

export type RuntimeTurnSettledProvider = 'claude' | 'codex';

export interface RuntimeTurnSettledEvent {
  schemaVersion: 1;
  provider: RuntimeTurnSettledProvider;
  hookEventName: 'Stop';
  sourceId: string;
  payloadHash: string;
  recordedAt: string;
  sessionId?: string;
  turnId?: string;
  transcriptPath?: string;
  cwd?: string;
  hints?: {
    teamName?: string;
    memberName?: string;
    runId?: string;
  };
}

sourceId should be deterministic for storage/debugging but not trusted for dedupe alone:

export function buildRuntimeTurnSettledSourceId(input: {
  provider: RuntimeTurnSettledProvider;
  sessionId?: string;
  turnId?: string;
  payloadHash: string;
}): string {
  return [
    'runtime-turn-settled',
    input.provider,
    input.sessionId || 'no-session',
    input.turnId || 'no-turn',
    input.payloadHash,
  ].join(':');
}

Domain invariants:

  • provider is known.
  • hookEventName is Stop.
  • payloadHash is non-empty.
  • recordedAt is valid ISO or normalized by infrastructure before domain construction.
  • sourceId contains no filesystem path and no message text.

4.2 Core Application

core/application owns use cases and ports.

Possible additions:

core/application/RuntimeTurnSettledIngestor.ts
core/application/RuntimeTurnSettledResolver.ts
core/application/runtimeTurnSettledPorts.ts

Ports:

export interface RuntimeTurnSettledEventStorePort {
  claimPending(limit: number): Promise<RuntimeTurnSettledClaim[]>;
  markProcessed(input: { id: string; resolved: boolean; reason?: string }): Promise<void>;
  markInvalid(input: { id: string; reason: string }): Promise<void>;
  release(input: { id: string; reason: string }): Promise<void>;
}

export interface RuntimeTurnSettledNormalizerPort {
  provider: RuntimeTurnSettledProvider;
  normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null;
}

export interface RuntimeTurnSettledTargetResolverPort {
  resolve(event: RuntimeTurnSettledEvent): Promise<
    | { ok: true; teamName: string; memberName: string; runId?: string }
    | { ok: false; reason: string }
  >;
}

export interface RuntimeTurnSettledQueuePort {
  enqueue(input: {
    teamName: string;
    memberName: string;
    triggerReason: 'turn_settled';
    runAfterMs?: number;
  }): void;
}

The use case:

export class RuntimeTurnSettledIngestor {
  constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {}

  async drain(): Promise<RuntimeTurnSettledDrainSummary> {
    const claims = await this.deps.store.claimPending(this.deps.batchSize);
    const summary = createEmptyDrainSummary();

    for (const claim of claims) {
      try {
        const normalizer = this.deps.normalizers.get(claim.provider);
        if (!normalizer) {
          await this.deps.store.markInvalid({
            id: claim.id,
            reason: 'unsupported_provider',
          });
          summary.invalid += 1;
          continue;
        }

        const normalized = normalizer.normalize(claim.raw);
        if (!normalized) {
          await this.deps.store.markInvalid({
            id: claim.id,
            reason: 'invalid_payload',
          });
          summary.invalid += 1;
          continue;
        }

        const target = await this.deps.targetResolver.resolve(normalized);
        if (!target.ok) {
          await this.deps.store.markProcessed({
            id: claim.id,
            resolved: false,
            reason: target.reason,
          });
          summary.unresolved += 1;
          continue;
        }

        this.deps.queue.enqueue({
          teamName: target.teamName,
          memberName: target.memberName,
          triggerReason: 'turn_settled',
          runAfterMs: this.deps.turnSettledDelayMs,
        });
        await this.deps.store.markProcessed({ id: claim.id, resolved: true });
        summary.enqueued += 1;
      } catch (error) {
        await this.deps.store.release({
          id: claim.id,
          reason: error instanceof Error ? error.message : String(error),
        });
        summary.released += 1;
      }
    }

    return summary;
  }
}

The use case depends only on ports. It should not import fs, path, Electron, TeamProvisioningService, or concrete stores.

4.3 Main Infrastructure

main/infrastructure owns technical details.

Possible additions:

main/infrastructure/RuntimeTurnSettledSpoolPaths.ts
main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts
main/infrastructure/FileRuntimeTurnSettledEventStore.ts
main/infrastructure/RuntimeTurnSettledDrainScheduler.ts
main/infrastructure/ClaudeStopHookPayloadNormalizer.ts
main/infrastructure/CodexStopHookPayloadNormalizer.ts

Responsibilities:

  • write shell hook script;
  • manage spool directories;
  • claim event files with atomic rename to processing/;
  • parse JSON with bounded size;
  • move invalid files to invalid/;
  • delete or archive processed files with bounded retention;
  • schedule drain with bounded concurrency and unref() timers.

4.4 Main Adapters

Input adapter:

main/adapters/input/RuntimeTurnSettledMemberWorkSyncAdapter.ts

It translates resolved runtime turn events into MemberWorkSyncEventQueue.enqueue.

Output adapters:

main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts

It resolves team/member through existing team stores and runtime metadata.

4.5 Composition

createMemberWorkSyncFeature() wires:

  • spool paths;
  • hook script installer;
  • event store;
  • provider normalizers;
  • target resolver;
  • drain scheduler;
  • ingestor.

Facade additions:

export interface MemberWorkSyncFeatureFacade {
  buildRuntimeTurnSettledHookSettings(input: {
    provider: RuntimeTurnSettledProvider;
  }): Promise<Record<string, unknown> | null>;

  drainRuntimeTurnSettledEvents(): Promise<RuntimeTurnSettledDrainSummary>;
}

Launch code should call only the facade or a public helper from @features/member-work-sync/main. It should not deep-import infrastructure.


5. Hook Installation Details

5.1 Managed Settings Fragment

For Claude:

const settingsPatch = {
  hooks: {
    Stop: [
      {
        matcher: '',
        hooks: [
          {
            type: 'command',
            command: buildTurnSettledHookCommand({
              scriptPath,
              spoolPath,
              provider: 'claude',
            }),
          },
        ],
      },
    ],
  },
};

The command must be deterministic and idempotent.

5.2 Hook Marker And Deduplication

Claude hook settings do not have a formal id field in the command schema. Deduplication should use a stable command marker:

# agent-teams:member-work-sync-turn-settled:v1

Example command:

/bin/sh '/.../turn-settled-hook-v1.sh' '/.../runtime-hooks' 'claude' # agent-teams:member-work-sync-turn-settled:v1

Settings merge should:

  • preserve existing user hooks;
  • append our hook only if marker is absent;
  • not reorder unrelated hooks;
  • not duplicate our hook across repeated launch/restart.

5.3 Settings Merge Must Be Hook-Aware

Current generic deep merge replaces arrays. That is unsafe for hooks.Stop.

Add a hook-aware merge helper. It can live in src/main/services/runtime/cliSettingsArgs.ts if it must merge all launch --settings, or in src/features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings.ts if the launch path only needs to build a single already-merged fragment. Prefer a shared runtime helper if multiple launch fragments already use --settings.

type JsonObject = Record<string, unknown>;

const MEMBER_WORK_SYNC_HOOK_MARKER = 'agent-teams:member-work-sync-turn-settled:v1';

function isJsonObject(value: unknown): value is JsonObject {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function mergeHooksConfig(target: unknown, source: unknown): unknown {
  if (!isJsonObject(target) && !isJsonObject(source)) {
    return source;
  }

  const merged: JsonObject = isJsonObject(target) ? { ...target } : {};
  if (!isJsonObject(source)) {
    return merged;
  }

  for (const [eventName, sourceEntries] of Object.entries(source)) {
    if (!Array.isArray(sourceEntries)) {
      merged[eventName] = sourceEntries;
      continue;
    }

    const currentEntries = Array.isArray(merged[eventName])
      ? [...(merged[eventName] as unknown[])]
      : [];
    for (const entry of sourceEntries) {
      if (isMemberWorkSyncHookEntry(entry)) {
        const alreadyPresent = currentEntries.some(isMemberWorkSyncHookEntry);
        if (alreadyPresent) {
          continue;
        }
      }
      currentEntries.push(entry);
    }
    merged[eventName] = currentEntries;
  }

  return merged;
}

function isMemberWorkSyncHookEntry(entry: unknown): boolean {
  if (!isJsonObject(entry) || !Array.isArray(entry.hooks)) {
    return false;
  }

  return entry.hooks.some((hook) => {
    if (!isJsonObject(hook) || typeof hook.command !== 'string') {
      return false;
    }
    return hook.command.includes(MEMBER_WORK_SYNC_HOOK_MARKER);
  });
}

export function mergeRuntimeSettingsFragments(fragments: JsonObject[]): JsonObject {
  let merged: JsonObject = {};
  for (const fragment of fragments) {
    merged = mergeRuntimeSettingsObject(merged, fragment);
  }
  return merged;
}

function mergeRuntimeSettingsObject(target: JsonObject, source: JsonObject): JsonObject {
  const output: JsonObject = { ...target };
  for (const [key, value] of Object.entries(source)) {
    if (key === 'hooks') {
      output.hooks = mergeHooksConfig(output.hooks, value);
      continue;
    }

    const current = output[key];
    if (isJsonObject(current) && isJsonObject(value)) {
      output[key] = mergeRuntimeSettingsObject(current, value);
      continue;
    }

    output[key] = value;
  }
  return output;
}

Test cases:

  • fastMode settings + Stop hook settings both survive;
  • two Stop hook fragments dedupe our marker;
  • user Stop hook and our Stop hook both survive;
  • non-hook arrays keep existing generic replace behavior unless explicitly supported.
  • malformed hooks.Stop from another fragment is not "fixed" silently. Preserve it if it is a non-array value and let Claude validation handle it.
  • if any --settings value is a file path, do not try to parse or rewrite it in claude_team; preserve existing behavior and append our own inline settings fragment separately.

Important implementation note:

The current mergeJsonObjectCliFlagValues() in agent_teams_orchestrator also deep-merges --settings values. If multiple inline JSON settings are passed to Claude, arrays can still be overwritten there. Either:

  • upstream the same hook-aware merge into agent_teams_orchestrator/src/utils/cliArgs.ts, or
  • ensure claude_team passes exactly one already-merged inline JSON settings fragment.

Recommended v1: pass exactly one already-merged inline JSON settings fragment from claude_team for app-owned settings, and add an orchestrator test proving fastMode + hooks.Stop survive eager settings load.

5.4 Do Not Fail Team Launch If Hook Install Fails

If hook script installation or settings patch generation fails:

  • log a warning;
  • continue launch without Stop hook;
  • member-work-sync still works from startup scans, task events, inbox events, tool activity, and watchdog.

Reason: Stop hook is an optimization signal, not a hard runtime dependency.

5.5 Launch Integration Shape

Launch code should ask the feature for an optional settings patch and merge it with existing app-owned Claude settings before args are finalized.

Sketch:

const appSettingsFragments: JsonObject[] = [];

const fastModeSettings = buildAnthropicFastModeSettings(...);
if (fastModeSettings) {
  appSettingsFragments.push(fastModeSettings);
}

const turnSettledHookSettings =
  await memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings({
    provider: 'claude',
  }).catch((error) => {
    logger.warn('member-work-sync Stop hook unavailable', { error });
    return null;
  });

if (turnSettledHookSettings) {
  appSettingsFragments.push(turnSettledHookSettings);
}

const appSettings = mergeRuntimeSettingsFragments(appSettingsFragments);
args = mergeJsonSettingsArgs(args, JSON.stringify(appSettings));

Rules:

  • existing user-supplied config/settings stays user-owned and is not rewritten;
  • app-owned settings should be merged before crossing process boundaries;
  • hook settings generation failure logs warning only;
  • tests must prove no duplicate hook command after restart/relaunch.

6. Hook Writer Script

6.1 Requirements

The shell script must:

  • use /bin/sh;
  • accept spoolRoot and provider;
  • create incoming/;
  • read stdin to a temp file with size bounded by shell-independent outer command where possible;
  • atomically rename temp to final .json;
  • add no stdout;
  • keep stderr quiet unless debugging is explicitly enabled;
  • exit 0 even when it cannot write.

6.2 Example Script Shape

#!/bin/sh
set +e

spool_root="$1"
provider="$2"
max_bytes="${3:-262144}"

if [ -z "$spool_root" ] || [ -z "$provider" ]; then
  exit 0
fi

case "$provider" in
  claude|codex) ;;
  *) exit 0 ;;
esac

incoming="$spool_root/incoming"
mkdir -p "$incoming" 2>/dev/null || exit 0

stamp="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo unknown-time)"
tmp="$(mktemp "$incoming/.turn-settled.XXXXXX" 2>/dev/null)" || exit 0
suffix="$(basename "$tmp" | sed 's/^\\.turn-settled\\.//')"
final="$incoming/$stamp-$$-$suffix.$provider.json"

# Use dd to bound the file even if a provider accidentally sends a huge payload.
# dd is POSIX. If it fails, fall back to fail-open cleanup.
dd bs="$max_bytes" count=1 of="$tmp" 2>/dev/null || {
  rm -f "$tmp" 2>/dev/null
  exit 0
}

# Empty payloads are not useful. They are dropped here to reduce invalid spool noise.
if [ ! -s "$tmp" ]; then
  rm -f "$tmp" 2>/dev/null
  exit 0
fi

mv "$tmp" "$final" 2>/dev/null || {
  rm -f "$tmp" 2>/dev/null
  exit 0
}

exit 0

The file content is raw provider hook payload. The provider is also encoded in the filename. The drainer validates both.

6.3 Payload Size Guard

Claude Stop payload can include last_assistant_message. To avoid huge files:

  • the shell writer limits stdin to 256KB by default;
  • app-side reader still refuses files over the same fixed limit;
  • oversized payloads are treated as invalid, never as resolved events.

For v1, app-side read limit is mandatory.

6.4 Hook Command Quoting

Build the hook command with shell-safe single-quote escaping:

const HOOK_MARKER = 'agent-teams:member-work-sync-turn-settled:v1';

function shellQuote(value: string): string {
  return `'${value.replace(/'/g, `'\\''`)}'`;
}

export function buildTurnSettledHookCommand(input: {
  scriptPath: string;
  spoolRoot: string;
  provider: RuntimeTurnSettledProvider;
  maxBytes?: number;
}): string {
  return [
    '/bin/sh',
    shellQuote(input.scriptPath),
    shellQuote(input.spoolRoot),
    shellQuote(input.provider),
    shellQuote(String(input.maxBytes ?? 262_144)),
    '#',
    HOOK_MARKER,
  ].join(' ');
}

Rules:

  • never interpolate unquoted paths;
  • never include team/member identity in the command;
  • never include secrets or auth paths;
  • keep the marker at the end so it is easy to detect without parsing shell syntax.

7. Event Store And Drain Semantics

7.1 Claiming Files

FileRuntimeTurnSettledEventStore.claimPending(limit):

  1. list incoming/*.json;
  2. sort by filename for deterministic order;
  3. for each file, atomic rename to processing/<same-name>;
  4. if rename fails, skip because another process may have claimed it;
  5. return claims with file path and provider inferred from extension.

Code sketch:

export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEventStorePort {
  constructor(private readonly deps: {
    paths: RuntimeTurnSettledSpoolPaths;
    maxBytes: number;
    clock: MemberWorkSyncClockPort;
  }) {}

  async claimPending(limit: number): Promise<RuntimeTurnSettledClaim[]> {
    await this.recoverStaleProcessingFiles();
    const incoming = this.deps.paths.incomingDir();
    const processing = this.deps.paths.processingDir();
    await fs.promises.mkdir(processing, { recursive: true });

    const names = (await fs.promises.readdir(incoming).catch(() => []))
      .filter((name) => providerFromFileName(name) !== null)
      .sort()
      .slice(0, Math.max(0, limit));

    const claims: RuntimeTurnSettledClaim[] = [];
    for (const name of names) {
      const sourcePath = path.join(incoming, name);
      const claimedPath = path.join(processing, name);
      const provider = providerFromFileName(name);
      if (!provider) {
        continue;
      }
      try {
        await fs.promises.rename(sourcePath, claimedPath);
      } catch {
        continue;
      }

      let stat: Awaited<ReturnType<typeof fs.promises.stat>>;
      try {
        stat = await fs.promises.stat(claimedPath);
      } catch (error) {
        claims.push({
          id: name,
          provider,
          raw: null,
          claimedPath,
          transientError: error instanceof Error ? error.message : String(error),
        });
        continue;
      }
      if (stat.size > this.deps.maxBytes) {
        claims.push({
          id: name,
          provider,
          raw: null,
          claimedPath,
          invalidReason: 'payload_too_large',
        });
        continue;
      }

      let raw: string;
      try {
        raw = await fs.promises.readFile(claimedPath, 'utf8');
      } catch (error) {
        claims.push({
          id: name,
          provider,
          raw: null,
          claimedPath,
          transientError: error instanceof Error ? error.message : String(error),
        });
        continue;
      }
      claims.push({
        id: name,
        provider,
        raw,
        claimedPath,
      });
    }
    return claims;
  }
}

claimPending() should not throw for one bad file. Bad files become claims with invalidReason, so the ingestor can quarantine them and continue. Transient read/stat errors become claims with transientError, so the ingestor can release them back for retry.

File name validation is part of the store, not the normalizer:

const EVENT_FILE_PATTERN =
  /^\d{8}T\d{6}Z-\d+-[A-Za-z0-9._-]+\.(claude|codex)\.json$/;

function providerFromFileName(name: string): RuntimeTurnSettledProvider | null {
  const match = EVENT_FILE_PATTERN.exec(name);
  if (!match) {
    return null;
  }
  return match[1] as RuntimeTurnSettledProvider;
}

Rules:

  • ignore hidden temp files;
  • reject names with path separators;
  • reject unknown provider suffixes;
  • never derive any target identity from the filename.

7.2 Processing Outcome

For each claim:

  • processed and resolved: move to processed/ or delete after metrics are recorded;
  • parsed but unresolved: move to processed/ with unresolved metric, not invalid;
  • invalid JSON or too large: move to invalid/;
  • transient read error: release back to incoming/ or leave in processing/ with recovery scan.

Important distinction:

invalid = payload cannot be trusted or parsed
unresolved = payload is valid, but no active team/member target was proven

Unresolved events are not errors. They are expected when a lead or non-team Claude session stops.

7.3 Recovery From Crashes

On app startup:

  • move stale processing/ files older than e.g. 5 minutes back to incoming/;
  • then drain normally.

This prevents losing events if the app crashes mid-drain.

Recovery should run before every scheduled drain as well, not only at startup. This handles app crashes and abrupt process kills without requiring a full app restart.

Recovery should use file age, not process liveness. The app can crash while a process that wrote the file is still alive, and checking arbitrary PIDs here would couple the store to runtime process management.

7.4 Retention

Keep processed/invalid files only for a short developer/debug window:

  • processed: max 1000 files or 24h;
  • invalid: max 100 files or 72h.

These are not user-facing logs.

7.5 Drain Scheduler

The drain scheduler should be lightweight and bounded:

export class RuntimeTurnSettledDrainScheduler {
  private timer: ReturnType<typeof setInterval> | null = null;
  private running = false;

  start(): void {
    if (this.timer) {
      return;
    }
    this.timer = setInterval(() => void this.tick(), 10_000);
    this.timer.unref?.();
    void this.tick();
  }

  private async tick(): Promise<void> {
    if (this.running) {
      return;
    }
    this.running = true;
    try {
      await this.ingestor.drain();
    } finally {
      this.running = false;
    }
  }
}

Rules:

  • one drain at a time;
  • bounded batch size, recommended 50;
  • unref() timer;
  • no UI dependency;
  • dispose() stops timer and waits for current drain;
  • scheduler errors are logged once per tick and never crash the app;
  • if one drain finds a full batch, schedule an immediate follow-up tick to clear backlog without waiting the full interval.

8. Provider Normalizers

8.1 Claude Stop Normalizer

Input fields expected from Claude hook payload:

  • hook_event_name;
  • session_id;
  • transcript_path;
  • cwd;
  • possibly turn_id;
  • possibly last_assistant_message.

Rules:

  • accept only hook_event_name === "Stop";
  • do not persist full last_assistant_message;
  • compute payloadHash from raw payload;
  • trim and normalize paths but do not require them to exist at normalization time;
  • produce RuntimeTurnSettledEvent.

Implementation sketch:

export class ClaudeStopHookPayloadNormalizer implements RuntimeTurnSettledNormalizerPort {
  readonly provider = 'claude' as const;

  normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null {
    if (raw.provider !== 'claude' || typeof raw.text !== 'string') {
      return null;
    }

    const parsed = parseJsonObject(raw.text);
    if (!parsed || parsed.hook_event_name !== 'Stop') {
      return null;
    }

    const payloadHash = sha256(raw.text);
    const sessionId = readOptionalString(parsed.session_id);
    const turnId = readOptionalString(parsed.turn_id);

    return {
      schemaVersion: 1,
      provider: 'claude',
      hookEventName: 'Stop',
      sourceId: buildRuntimeTurnSettledSourceId({
        provider: 'claude',
        sessionId,
        turnId,
        payloadHash,
      }),
      payloadHash,
      recordedAt: raw.recordedAt,
      sessionId,
      turnId,
      transcriptPath: normalizeOptionalPath(parsed.transcript_path),
      cwd: normalizeOptionalPath(parsed.cwd),
    };
  }
}

parseJsonObject() must reject arrays and primitives. Do not pass raw parsed provider payload outside the normalizer.

Normalizer tests should cover:

  • valid Stop payload returns normalized event;
  • non-Stop event returns null;
  • malformed JSON returns null;
  • last_assistant_message is not copied;
  • missing session_id is allowed but resolver may later ignore the event;
  • huge payload never reaches normalizer because the store quarantines it first.

8.2 Codex Stop Normalizer, Future

Codex support should be added by implementing the same interface:

class CodexStopHookPayloadNormalizer implements RuntimeTurnSettledNormalizerPort {
  provider = 'codex' as const;
  normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null {
    // Codex payload mapping
  }
}

Do not add Codex launch behavior until there is a contract test with a captured or synthetic Codex Stop payload.


9. Target Resolution

TeamRuntimeTurnSettledTargetResolver should resolve conservatively.

Resolution order:

  1. Runtime launch state by sessionId.
  2. Transcript path attribution.
  3. Validated explicit hints, if event contains them.
  4. CWD/team metadata match, only if it maps to exactly one active team/member.

Session and transcript evidence should beat hints because hints can be inherited through --settings.

If resolution is ambiguous:

  • do not enqueue;
  • record metric unresolved_ambiguous_target;
  • leave debug diagnostics.

If team is stopped/offline:

  • do not enqueue;
  • record metric ignored_inactive_team.

If member is removed/inactive:

  • do not enqueue;
  • record metric ignored_inactive_member.

Implementation sketch:

export class TeamRuntimeTurnSettledTargetResolver
  implements RuntimeTurnSettledTargetResolverPort {
  constructor(private readonly deps: {
    teamReader: TeamConfigReaderPort;
    launchStateReader: RuntimeLaunchStateReaderPort;
    transcriptAttribution: RuntimeTranscriptAttributionPort;
    teamStatusReader: TeamStatusReaderPort;
  }) {}

  async resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResult> {
    const candidates = compact([
      await this.fromSessionId(event),
      await this.fromTranscriptPath(event),
      await this.fromValidatedHints(event),
      await this.fromUniqueCwd(event),
    ]);

    const active = [];
    for (const candidate of candidates) {
      const verified = await this.verifyCandidate(event, candidate);
      if (verified.ok) {
        active.push(verified.target);
      }
    }

    const unique = uniqueTargets(active);
    if (unique.length === 1) {
      return { ok: true, ...unique[0] };
    }
    if (unique.length > 1) {
      return { ok: false, reason: 'unresolved_ambiguous_target' };
    }
    return { ok: false, reason: 'unresolved_no_target' };
  }
}

verifyCandidate() must check:

  • team exists and is not stopped/cancelled;
  • member exists in current config or members-meta;
  • member is active, not removed;
  • provider matches claude for Claude Stop events;
  • if sessionId exists, it belongs to the same member or is absent from known runtime state;
  • if transcriptPath exists, it is under the expected team/member transcript root or matches known attribution.

Never enqueue based only on cwd when more than one team can match the same project path. Worktrees and restarted teams make CWD ambiguous.

9.1 Explicit Hints Are Not Trusted Alone

If a hook event includes hints:

{
  hints: {
    teamName: 'atlas',
    memberName: 'bob'
  }
}

The resolver must still verify:

  • team exists;
  • member exists in config/meta;
  • member is active;
  • event session/transcript is compatible if those fields are present.

9.2 Lead Sessions

Lead sessions may emit Stop events too.

Policy:

  • Ignore lead Stop for member-specific work sync in v1, because task/inbox events already cover most team-wide changes.
  • Do not fan out a lead Stop to all active members unless a later phase explicitly adds a separate team-level policy with its own anti-spam tests.

Recommended v1 default: ignore unresolved lead events.

If a lead event is strongly resolved as lead, record ignored_lead_turn_settled and do not enqueue all members in v1. Team-wide task/inbox changes already trigger member sync, and fan-out from lead Stop could create noisy false positives.

9.3 OpenCode Sessions

This plan is not for OpenCode. OpenCode runtime already has separate delivery/watchdog mechanisms.

Do not infer OpenCode member work sync from Claude Stop events.


10. Member Work Sync Integration

Add trigger reason:

export type MemberWorkSyncTriggerReason =
  | ...
  | 'turn_settled';

Add event type:

export interface TeamChangeEvent {
  type:
    | ...
    | 'member-turn-settled';
  teamName: string;
  runId?: string;
  detail?: string;
  taskId?: string;
}

Current TeamChangeEvent.detail is a string used broadly across preload, renderer store, and log/task panels. Do not change it to unknown in this cut. Encode a small JSON detail string and validate it at the router boundary:

export interface MemberTurnSettledTeamChangeDetail {
  memberName: string;
  provider: 'claude' | 'codex';
  sourceId: string;
}

function serializeMemberTurnSettledDetail(
  detail: MemberTurnSettledTeamChangeDetail
): string {
  return JSON.stringify(detail);
}

Router behavior:

if (event.type === 'member-turn-settled') {
  const detail = parseMemberTurnSettledDetail(event.detail);
  if (!detail) {
    return;
  }

  queue.enqueue({
    teamName: event.teamName,
    memberName: detail.memberName,
    triggerReason: 'turn_settled',
    runAfterMs: 15_000,
  });
  return;
}

Parsing detail explicitly avoids fragile string conventions and gives future Codex/Claude diagnostics without changing the queue contract again. sourceId is intentionally not added to the queue item in v1, because queue coalescing is keyed by team/member. Keep sourceId in drain/router debug logs only.

parseMemberTurnSettledDetail() should reject missing JSON, non-object JSON, empty memberName, and unknown provider values. Invalid detail is ignored, not routed as team-wide.

Why 15_000 instead of immediate:

  • allows logs/task writes from the just-finished turn to land;
  • coalesces repeated hook events;
  • avoids racing with task/comment writes emitted near turn end.

The existing queue still applies normal quiet/coalescing behavior.


11. Watchdog Interaction

Stop hook events must not conflict with TeamTaskStallMonitor.

Rules:

  • Stop hook event is a fast consistency trigger.
  • It does not count as progress proof.
  • It does not suppress watchdog by itself.
  • If member-work-sync sends a nudge, the existing watchdog cooldown adapter can suppress near-duplicate task-stall nudges.
  • If watchdog sends a nudge first, member-work-sync dispatcher must see that cooldown and avoid duplicate member sync pings.

This keeps responsibilities separate:

member-work-sync: does the member know the current actionable agenda?
watchdog: has meaningful task progress stalled for too long?
delivery ledger: did a specific message reach a runtime?
runtime liveness: is a process/session alive?

Required anti-spam contract:

  • A turn_settled enqueue may cause at most one member-work-sync reconciliation for (teamName, memberName) after coalescing.
  • It must not create a nudge if the agenda fingerprint is already caught up or covered by a valid lease/report.
  • If a watchdog nudge was recently sent for the same member/task, member-work-sync must respect the existing cooldown adapter.
  • If member-work-sync sends a nudge first, watchdog must see the same cooldown signal through the already-existing TeamTaskStallJournalWorkSyncCooldown.
  • Stop hook events do not reset leases and do not extend leases. Only explicit report/tool/task state can do that.

12. Security And Reliability

12.1 No Network In Hook

The hook must not call local HTTP control endpoints. Reasons:

  • app may be down;
  • hook could hang;
  • local ports can change;
  • network calls increase Stop latency.

File spool is safer.

12.2 No Secrets In Spool

Do not persist:

  • API keys;
  • full env;
  • full assistant messages;
  • user prompt content beyond what Claude already puts in raw Stop payload.

If raw payload includes last_assistant_message, the app normalizer should not copy it into normalized event. Optionally hash it for dedupe.

12.3 Bounded IO

Every read/list operation must be bounded:

  • max event file size;
  • max files per drain tick;
  • bounded retention cleanup;
  • no recursive scanning outside known spool dirs.

12.4 Fail Open

If hook settings cannot be installed, launch continues.

If hook command cannot write, it exits 0.

If event cannot resolve, no nudge is sent.


13. Implementation Phases

Commit order should follow dependency direction. Each commit must leave tests for touched layer green.

commit 1: domain ports + hook settings merge + script installer
commit 2: file spool store + normalizers + ingestor
commit 3: target resolver + router integration
commit 4: launch integration + composition wiring
commit 5: docs/test hardening and optional Codex contract adapter

Do not wire launch integration before RuntimeTurnSettledHookSettings.test.ts and store/ingestor tests exist. Otherwise a bad hook command could silently ship into every launched Claude teammate.

Phase 0: Preflight And Existing-Code Assertions

🎯 10 🛡️ 9 🧠 2, roughly 40-90 LOC.

Tasks:

  • add characterization tests for current mergeJsonSettingsArgs or equivalent --settings helper;
  • add characterization test that createMemberWorkSyncFeature() exposes stable startup/dispose lifecycle;
  • add a small fixture for a Claude Stop payload with session_id, transcript_path, and cwd;
  • confirm no renderer import path is needed.

Why this phase exists: it locks current behavior before adding hook-aware merge and prevents accidental breakage of existing Claude fast mode settings.

Phase 1: Hook Settings And Spool Foundation

🎯 9 🛡️ 9 🧠 5, roughly 300-450 LOC.

Tasks:

  • add RuntimeTurnSettledSpoolPaths;
  • add shell hook script installer;
  • add hook-aware settings merge helper;
  • add Claude Stop settings patch builder;
  • wire launch path to include patch through public member-work-sync main helper/facade;
  • keep failure non-fatal.
  • if the orchestrator can still receive multiple inline app-owned settings fragments, add the same hook-aware merge test/fix there before wiring production launch.

Tests:

  • script installer writes executable content;
  • settings merge preserves existing user hooks;
  • settings merge dedupes our hook marker;
  • fastMode settings and hook settings both survive.

Phase 2: Drainer And Resolver

🎯 9 🛡️ 9 🧠 6, roughly 350-600 LOC.

Tasks:

  • add file event store;
  • add Claude payload normalizer;
  • add target resolver;
  • add drain scheduler;
  • add startup recovery for stale processing files;
  • emit member-turn-settled only for resolved active member.
  • add structured drain summary for diagnostics but do not expose it to UI yet.

Tests:

  • concurrent file claims are safe;
  • malformed and oversized payloads are quarantined;
  • unresolved events do not enqueue;
  • sessionId and transcriptPath resolution works;
  • stopped team and removed member are ignored.

Phase 3: Member Work Sync Router Integration

🎯 10 🛡️ 9 🧠 3, roughly 100-180 LOC.

Tasks:

  • add turn_settled trigger reason;
  • add member-turn-settled event type;
  • route to one member with a short delay;
  • include trigger reason in diagnostics.
  • verify watchdog cooldown path is unchanged.

Tests:

  • router enqueues only target member;
  • missing detail is ignored;
  • coalescing works with existing queue.

Phase 4: Codex Contract-Ready Adapter, No Production Launch Yet

🎯 7 🛡️ 8 🧠 5, roughly 150-300 LOC.

Tasks:

  • add CodexStopHookPayloadNormalizer behind tests only;
  • add synthetic captured-payload tests;
  • do not install Codex Stop hook in production until runtime launch and config format are verified.

Tests:

  • Codex payload maps into same normalized event;
  • unsupported event names are ignored;
  • missing identity fields stay unresolved.

14. Testing Plan

Unit

pnpm vitest run \
  test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts \
  test/features/member-work-sync/main/RuntimeTurnSettledSpool.test.ts \
  test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts \
  test/features/member-work-sync/main/RuntimeTurnSettledTargetResolver.test.ts \
  test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts

Existing Regression

pnpm vitest run \
  test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts \
  test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts \
  test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts \
  test/features/member-work-sync/main/MemberWorkSyncToolActivityBusySignal.test.ts

Broader Regression

pnpm typecheck --pretty false
git diff --check

Optional Live Claude Hook Probe

Only if token/cost is acceptable:

CLAUDE_TEAM_LIVE_HOOK_PROBE=1 pnpm vitest run test/features/member-work-sync/live/ClaudeStopHook.live.test.ts

The live probe should:

  • create temp settings with only our Stop hook;
  • launch a minimal Claude runtime turn;
  • verify one spool event;
  • not assert any model behavior.

Do not run this in normal CI.


15. Edge Cases

15.1 App Closed While Agent Stops

Hook still writes event file. On next app startup, drainer processes it if still relevant. If team/member is no longer active, event is ignored.

15.2 Many Agents Stop Together

Each hook writes a separate file. Drainer claims a bounded batch. Queue coalesces by team/member.

15.3 Hook Script Missing After App Update

Launch calls installer before creating settings patch. If script path changes, new settings patch uses new path. Old running agents may keep old hook command. If old script is gone, hook exits fail-open or shell fails. No runtime break.

Recommended improvement: keep versioned scripts for at least one app session after update.

15.4 --settings Inheritance

Inherited settings are expected. The hook is generic. Resolver validates identity post-fact.

15.5 User Has Their Own Stop Hook

Hook-aware merge appends our hook and preserves the user's hook. If the user hook fails or blocks, that is outside our control, but our hook should be independent.

15.6 Invalid User Project Settings

Because we do not mutate project settings, invalid project .claude/settings.local.json does not affect hook installation beyond normal Claude settings loading behavior.

15.7 Duplicate Events

Dedupe can use:

  • provider;
  • sessionId;
  • turnId if present;
  • payloadHash;
  • recordedAt bucket.

Even without perfect dedupe, queue coalescing and outbox one-per-fingerprint prevent duplicate nudges.

15.8 Runtime Payload Has No Session ID

Resolver may use transcript path. If both are missing, event is unresolved and ignored.

15.9 Codex Payload Differs From Claude

Codex normalizer is separate. It must not force Claude assumptions into the core event model.


16. Open Questions

No blocker questions for v1.

Non-blocking decisions with recommended defaults:

  1. Processed event retention Recommended: keep 24h or 1000 files. 🎯 9 🛡️ 9 🧠 2, roughly 20-40 LOC. This gives enough debugging signal without making hook files a growing log store.

  2. Turn-settled queue delay Recommended: 15s. 🎯 8 🛡️ 9 🧠 1, roughly 5-15 LOC. Short enough to feel responsive, long enough for task/comment writes from the just-finished turn to appear.

  3. Max event file size Recommended: 256KB. 🎯 8 🛡️ 9 🧠 1, roughly 10-20 LOC. Claude Stop payloads should be small for our use case. Large payloads likely contain message text we do not need.

  4. Lead Stop events Recommended: ignore in v1. 🎯 8 🛡️ 9 🧠 2, roughly 10-30 LOC. Fan-out from lead Stop is the highest-noise path and current task/inbox events already cover team-wide changes.

  5. Codex production hook installation Recommended: not in v1. 🎯 7 🛡️ 8 🧠 5, roughly 150-300 LOC later. Keep the adapter seam and tests, but do not install until Codex hook payload and config semantics are proven.

The only question worth escalating before implementation is this:

Should processed hook payload files be retained for 24h for debugging, or deleted immediately after metrics?

Default if no answer: retain short-term with bounded cleanup.


17. Acceptance Criteria

Implementation is accepted when:

  • Claude launches include exactly one managed member-work-sync Stop hook.
  • Existing user hooks.Stop entries are preserved.
  • Existing app inline settings such as fast mode are preserved.
  • Hook command writes raw payload files atomically.
  • App drains payloads without blocking launch or UI.
  • Resolved Claude Stop event enqueues one turn_settled reconcile for the correct active member.
  • Unresolved, inactive, oversized, and malformed events never send nudges.
  • Existing watchdog tests remain green.
  • Codex support can be added by implementing provider adapter only, without rewriting core or Claude adapter.
  • member-work-sync does not nudge if current agenda fingerprint is already caught up or covered by valid lease/report.
  • A Stop hook event alone is not counted as task progress.
  • Stopped teams, removed members, stale run ids, unresolved lead events, and ambiguous CWD matches are ignored.
  • No renderer code is required for v1.
  • No user project settings file is mutated.