diff --git a/.agentworkforce/agents/factory-feature-guardian/agent.ts b/.agentworkforce/agents/factory-feature-guardian/agent.ts new file mode 100644 index 0000000..990fdf3 --- /dev/null +++ b/.agentworkforce/agents/factory-feature-guardian/agent.ts @@ -0,0 +1,396 @@ +/** + * factory-feature-guardian handler. + * + * Hourly cron tick: + * 1. Read .agentworkforce/features/manifest.yaml + * 2. Load cycle progress from workspace memory + * 3. Pick the next unchecked feature (criticality, tier, then name) + * 4. Generate a concise check via ctx.llm + * 5. Post it to Slack with optional team mentions + * 6. Persist progress, resetting after a complete manifest cycle + */ +import { input } from '@agentworkforce/delivery' +import { defineAgent, type WorkforceCtx } from '@agentworkforce/runtime' +import { slackClient } from '@relayfile/relay-helpers' + +type Criticality = 'critical' | 'hot' | 'standard' + +interface Feature { + id: string + name: string + cli?: string + api?: string + desc: string + location: string + tier: number + criticality: Criticality +} + +interface MutableFeature { + id?: string + name?: string + cli?: string + api?: string + description?: string + location?: string + verifyTier?: number + criticality?: Criticality +} + +interface ProgressState { + kind: 'factory-feature-guardian:progress' + version: 1 + checkedIds: string[] + cycleStartedAt: string + totalFeatures: number +} + +const MANIFEST_RELPATH = '.agentworkforce/features/manifest.yaml' +const PROGRESS_TAG = 'factory-feature-guardian:progress' + +async function loadFeatures(ctx: WorkforceCtx): Promise { + const absPath = `${ctx.sandbox.cwd}/${MANIFEST_RELPATH}` + return parseManifestFeatures(await ctx.sandbox.readFile(absPath)) +} + +/** + * Parse only the checked-in manifest schema. Keeping this parser local avoids + * making the published Factory package depend on a YAML library solely for an + * Agent Workforce deployment artifact. The parser rejects incomplete entries + * and duplicate IDs instead of silently scheduling malformed checks. + */ +export function parseManifestFeatures(raw: string): Feature[] { + const features: Feature[] = [] + const seenIds = new Set() + let categoryCriticality: Criticality | undefined + let current: MutableFeature | undefined + + const finishCurrent = (): void => { + if (!current) return + const feature = validateFeature(current) + if (seenIds.has(feature.id)) { + throw new Error(`Duplicate feature id in manifest: ${feature.id}`) + } + seenIds.add(feature.id) + features.push(feature) + current = undefined + } + + for (const line of raw.split(/\r?\n/u)) { + const categoryMatch = /^ [a-z][a-z0-9-]+:\s*$/u.exec(line) + if (categoryMatch) { + finishCurrent() + categoryCriticality = undefined + continue + } + + const criticalityMatch = /^ criticality:\s*(critical|hot|standard)\s*$/u.exec(line) + if (criticalityMatch) { + finishCurrent() + categoryCriticality = criticalityMatch[1] as Criticality + continue + } + + const idMatch = /^ - id:\s*(.+?)\s*$/u.exec(line) + if (idMatch) { + finishCurrent() + if (!categoryCriticality) { + throw new Error(`Feature ${decodeScalar(idMatch[1])} appears before category criticality`) + } + current = { + id: decodeScalar(idMatch[1]), + criticality: categoryCriticality, + } + continue + } + + if (!current) continue + const fieldMatch = /^ (name|cli|api|description|location|verify_tier):\s*(.*?)\s*$/u.exec(line) + if (!fieldMatch) continue + const [, key, rawValue] = fieldMatch + const value = decodeScalar(rawValue) + if (key === 'name') current.name = value + else if (key === 'cli') current.cli = value + else if (key === 'api') current.api = value + else if (key === 'description') current.description = value + else if (key === 'location') current.location = value + else if (key === 'verify_tier') current.verifyTier = Number(value) + } + + finishCurrent() + if (features.length === 0) throw new Error('Manifest parsed but contained no features') + validateCatalogSummary(raw, features) + return features +} + +function validateCatalogSummary(raw: string, features: Feature[]): void { + const categoriesSection = /^categories:\s*$/mu.exec(raw) + if (!categoriesSection) throw new Error('Manifest is missing categories') + const categoryCount = [ + ...raw.slice(categoriesSection.index + categoriesSection[0].length) + .matchAll(/^ [a-z][a-z0-9-]+:\s*$/gmu), + ].length + const declaredCategories = manifestInteger(raw, /^ category_count:\s*(\d+)\s*$/mu, 'category_count') + const declaredFeatures = manifestInteger(raw, /^ feature_count:\s*(\d+)\s*$/mu, 'feature_count') + if (declaredCategories !== categoryCount || declaredFeatures !== features.length) { + throw new Error( + `Manifest catalog mismatch: declared ${declaredCategories} categories/${declaredFeatures} features, parsed ${categoryCount}/${features.length}`, + ) + } + + for (let tier = 1; tier <= 6; tier += 1) { + const expression = new RegExp(`^ ${tier}:\\s*(\\d+)\\s*$`, 'mu') + const declared = manifestInteger(raw, expression, `tier ${tier}`) + const actual = features.filter((feature) => feature.tier === tier).length + if (declared !== actual) { + throw new Error(`Manifest catalog tier ${tier} mismatch: declared ${declared}, parsed ${actual}`) + } + } +} + +function manifestInteger(raw: string, expression: RegExp, label: string): number { + const match = expression.exec(raw) + const value = Number(match?.[1]) + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Manifest catalog is missing a valid ${label}`) + } + return value +} + +function decodeScalar(value: string | undefined): string { + const trimmed = value?.trim() ?? '' + if (trimmed.length < 2) return trimmed + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + try { + const parsed = JSON.parse(trimmed) as unknown + return typeof parsed === 'string' ? parsed : trimmed + } catch { + return trimmed.slice(1, -1) + } + } + return trimmed +} + +function validateFeature(inputFeature: MutableFeature): Feature { + if (!inputFeature.id || !inputFeature.name || !inputFeature.description || !inputFeature.location) { + throw new Error(`Incomplete manifest feature: ${JSON.stringify(inputFeature)}`) + } + if (!inputFeature.cli && !inputFeature.api) { + throw new Error(`Manifest feature ${inputFeature.id} has neither cli nor api`) + } + const verifyTier = inputFeature.verifyTier + if (typeof verifyTier !== 'number' || !Number.isInteger(verifyTier) || verifyTier < 1 || verifyTier > 6) { + throw new Error(`Manifest feature ${inputFeature.id} has invalid verify_tier`) + } + if (!inputFeature.criticality) { + throw new Error(`Manifest feature ${inputFeature.id} has no category criticality`) + } + return { + id: inputFeature.id, + name: inputFeature.name, + cli: inputFeature.cli, + api: inputFeature.api, + desc: inputFeature.description, + location: inputFeature.location, + tier: verifyTier, + criticality: inputFeature.criticality, + } +} + +async function loadProgress(ctx: WorkforceCtx): Promise { + const items = await ctx.memory.recall('factory feature guardian cycle progress', { + tags: [PROGRESS_TAG], + scope: 'workspace', + limit: 5, + }) + const newestFirst = [...items].sort((left, right) => + (right.createdAt ?? '').localeCompare(left.createdAt ?? '')) + for (const item of newestFirst) { + try { + const parsed = JSON.parse(item.content) as unknown + if (isProgressState(parsed)) return parsed + } catch { + // Ignore malformed or stale memory rows. + } + } + return null +} + +function isProgressState(value: unknown): value is ProgressState { + if (!isRecord(value)) return false + return value.kind === PROGRESS_TAG && + value.version === 1 && + Array.isArray(value.checkedIds) && + value.checkedIds.every((entry) => typeof entry === 'string') && + typeof value.cycleStartedAt === 'string' && + typeof value.totalFeatures === 'number' +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +async function saveProgress(ctx: WorkforceCtx, state: ProgressState): Promise { + await ctx.memory.save(JSON.stringify(state), { + tags: [PROGRESS_TAG], + scope: 'workspace', + ttlSeconds: 60 * 60 * 24 * 14, + }) +} + +function pickNextFeature(features: Feature[], checkedIds: Set): Feature | null { + const criticalityOrder: Record = { + critical: 0, + hot: 1, + standard: 2, + } + const ordered = [...features].sort((left, right) => { + const criticalityDiff = criticalityOrder[left.criticality] - criticalityOrder[right.criticality] + if (criticalityDiff !== 0) return criticalityDiff + const tierDiff = left.tier - right.tier + if (tierDiff !== 0) return tierDiff + return left.name.localeCompare(right.name) + }) + return ordered.find((feature) => !checkedIds.has(feature.id)) ?? null +} + +function tierLabel(tier: number): string { + if (tier === 1) return 'installed package/source checkout only' + if (tier === 2) return 'valid factory.config.json; fixture mode is acceptable' + if (tier === 3) return 'reachable Linear or GitHub ticket provider' + if (tier === 4) return 'internal broker or hosted Relay fleet available' + if (tier === 5) return 'cloud auth and writable Relayfile mount' + return 'manual check with a live issue or pull request' +} + +function featureSurface(feature: Feature): string { + return [ + feature.cli ? `CLI: \`${feature.cli}\`` : undefined, + feature.api ? `API/config: \`${feature.api}\`` : undefined, + ].filter((value): value is string => Boolean(value)).join('\n') +} + +async function generateCheckMessage(ctx: WorkforceCtx, feature: Feature): Promise { + const prompt = [ + 'You are the Factory Feature Guardian, a proactive Slack bot for the Agent Workforce team.', + 'Write a brief conversational Slack message (3-5 sentences, no markdown header) asking the team to confirm whether one Factory feature still works as intended.', + 'Name the feature, include its CLI/API/config surface, describe the expected behavior and source path, mention its verification prerequisite, and ask whether implementation, tests, procedures, or docs have drifted.', + 'Preserve stated safety and topology boundaries exactly. In particular, never imply that cross-host active/active control-plane ownership is supported.', + 'End exactly with: "React βœ… if working as expected, πŸ”§ if something is off, or ❓ if untested."', + 'Keep it casual, precise, and direct. Do not claim that you ran the check.', + '', + `Feature: ${feature.name}`, + featureSurface(feature), + `Expected behavior: ${feature.desc}`, + `Source: ${feature.location}`, + `Verify tier: ${feature.tier} (${tierLabel(feature.tier)})`, + `Criticality: ${feature.criticality}`, + ].join('\n') + + try { + return (await ctx.llm.complete(prompt, { maxTokens: 320 })).trim() + } catch { + return fallbackCheckMessage(feature) + } +} + +export function fallbackCheckMessage(feature: Feature): string { + return [ + `πŸ” *Factory Feature Check: ${feature.name}*`, + '', + featureSurface(feature), + `Source: ${feature.location}`, + '', + `This should: ${feature.desc}`, + `Verification prerequisite: tier ${feature.tier} β€” ${tierLabel(feature.tier)}.`, + '', + 'Is this working as expected right now? React βœ… if working as expected, πŸ”§ if something is off, or ❓ if untested.', + ].join('\n') +} + +export default defineAgent({ + schedules: [{ name: 'hourly-check', cron: '0 * * * *', tz: 'America/New_York' }], + handler: async (ctx, _event) => { + const channel = input(ctx, 'SLACK_CHANNEL') + if (!channel) { + ctx.log('warn', 'factory-feature-guardian.no-channel', { + reason: 'SLACK_CHANNEL not configured', + }) + return + } + + let features: Feature[] + try { + features = await loadFeatures(ctx) + } catch (error) { + const absPath = `${ctx.sandbox.cwd}/${MANIFEST_RELPATH}` + ctx.log('error', 'factory-feature-guardian.manifest-load-failed', { + path: absPath, + error: String(error), + }) + await slackClient().post( + channel, + `⚠️ *factory-feature-guardian* could not load \`${MANIFEST_RELPATH}\`: \`${String(error)}\``, + ).catch(() => undefined) + return + } + + const progress = await loadProgress(ctx) + const checkedIds = new Set( + (progress?.totalFeatures === features.length ? progress.checkedIds : []) + .filter((id) => features.some((feature) => feature.id === id)), + ) + const totalFeatures = features.length + + let feature = pickNextFeature(features, checkedIds) + let cycleStartedAt = progress?.cycleStartedAt ?? new Date().toISOString() + if (!feature) { + ctx.log('info', 'factory-feature-guardian.cycle-complete', { total: totalFeatures }) + checkedIds.clear() + cycleStartedAt = new Date().toISOString() + feature = pickNextFeature(features, checkedIds) + } + if (!feature) return + + const userWill = input(ctx, 'SLACK_USER_WILL') + const userKhaliq = input(ctx, 'SLACK_USER_KHALIQ') + const mentions = [ + userWill ? `<@${userWill}>` : undefined, + userKhaliq ? `<@${userKhaliq}>` : undefined, + ].filter((value): value is string => Boolean(value)).join(' ') + const mentionPrefix = mentions ? `${mentions} β€” ` : '' + + const checkBody = await generateCheckMessage(ctx, feature) + const ordinal = checkedIds.size + 1 + const remaining = totalFeatures - ordinal + const progressNote = `_[factory feature check Β· ${ordinal}/${totalFeatures} Β· ${remaining} remaining in cycle]_` + const result = await slackClient().post( + channel, + [mentionPrefix + checkBody, '', progressNote].join('\n'), + ) + if (!result.ts) { + ctx.log('error', 'factory-feature-guardian.post-failed', { + channel, + feature: feature.id, + }) + return + } + + ctx.log('info', 'factory-feature-guardian.posted', { + channel, + feature: feature.id, + ts: result.ts, + }) + checkedIds.add(feature.id) + await saveProgress(ctx, { + kind: PROGRESS_TAG, + version: 1, + checkedIds: [...checkedIds], + cycleStartedAt, + totalFeatures, + }) + }, +}) diff --git a/.agentworkforce/agents/factory-feature-guardian/persona.json b/.agentworkforce/agents/factory-feature-guardian/persona.json new file mode 100644 index 0000000..3ab5aea --- /dev/null +++ b/.agentworkforce/agents/factory-feature-guardian/persona.json @@ -0,0 +1,54 @@ +{ + "id": "factory-feature-guardian", + "intent": "relay-orchestrator", + "tags": ["factory", "verification", "proactive", "health", "release-safety", "durability"], + "description": "Validates the checked-in Factory catalog summary, then cycles through every @agent-relay/factory feature hourly. Each Slack check names the exact source surface, verification tier, and supported safety/topology boundary; progress persists across the exhaustive manifest and resets after a complete cycle.", + "cloud": true, + "useSubscription": true, + "harnessSettings": { + "reasoning": "low", + "timeoutSeconds": 300 + }, + "integrations": { + "slack": { + "scope": { + "paths": "/slack/channels/**" + } + } + }, + "inputs": { + "SLACK_CHANNEL": { + "description": "Slack channel to post Factory feature checks to (for example factory-health or general).", + "env": "SLACK_CHANNEL", + "default": "C0BHWJSF309", + "picker": { + "provider": "slack", + "resource": "channels" + } + }, + "SLACK_USER_WILL": { + "description": "Slack user ID for Will, used for optional mentions in feature checks.", + "env": "SLACK_USER_WILL", + "optional": true, + "picker": { + "provider": "slack", + "resource": "users" + } + }, + "SLACK_USER_KHALIQ": { + "description": "Slack user ID for Khaliq, used for optional mentions in feature checks.", + "env": "SLACK_USER_KHALIQ", + "optional": true, + "picker": { + "provider": "slack", + "resource": "users" + } + } + }, + "memory": { + "enabled": true, + "scopes": ["workspace"], + "ttlDays": 14 + }, + "onEvent": "./agent.ts" +} diff --git a/.agentworkforce/features/critical-paths.md b/.agentworkforce/features/critical-paths.md new file mode 100644 index 0000000..22e43c4 --- /dev/null +++ b/.agentworkforce/features/critical-paths.md @@ -0,0 +1,279 @@ +# Critical Paths + +The sequences that must work for `@agent-relay/factory` to turn opted-in issues into reviewed pull requests safely. Verify these first after changes and treat a regression in any safety guard as a release blocker. + +--- + +## Path 1: Config β†’ Discover β†’ Triage (Foundation) + +Factory must parse configuration, find a real ready issue, reconcile sparse provider records, enforce scope, and produce a deterministic route before it can spawn anything. + +```bash +npm run build +# Prepare /tmp/factory-tier2-config.json as documented in verify/procedures.md. +factory run-once --config /tmp/factory-tier2-config.json --dry-run +# β†’ pulled contains AR-77 +# β†’ triaged contains a label-derived AgentWorkforce/pear route +# β†’ dispatched describes the dry-run agents +# β†’ no provider writes or fleet spawns occur +``` + +Live Linear acceptance: + +1. Read canonical issues plus Ready for Agent aliases. +2. Recover missing `state.id` from canonical aliases or configured state names. +3. Require the configured title/label and team scope. +4. Route by label β†’ project β†’ keyword β†’ default, never by guesswork. + +GitHub-native direct acceptance: + +1. A positive numeric `canary`, `triage`, or `dispatch` target resolves through the configured source repository without requiring a repo-qualified path. +2. `repos.default` may disambiguate the number; otherwise multiple matching configured repositories fail as ambiguous. +3. Both `owner__repo` and `owner/repo` mounts resolve `meta.json`, `by-id`, flat issue records, and scoped fallback listings without collapsing distinct repositories. +4. Repository-only configuration selects GitHub intake, while explicit or legacy Linear signals keep Linear selected and resolution errors name the selected source. + +**What breaks if this fails:** all issue-driven operation, the canary, safety scoping, and every downstream dispatch. + +--- + +## Path 2: Triage β†’ Batch β†’ Team Dispatch + +An eligible issue must enter the bounded batch exactly once, spawn its implementation shape, receive confirmed tasks, and write the lifecycle transition. + +```bash +factory dispatch AR-123 --config ./factory.config.json --dry-run +# β†’ normalized TriageDecision +# β†’ stable ar-123-* agent names +# β†’ no duplicate agent names on a repeated concurrent request + +factory dispatch AR-123 --config ./factory.config.json +# β†’ implementer(s) + reviewer or workflow:run are spawned +# β†’ dispatch summary is posted +# β†’ Linear moves to Agent Implementing, or GitHub gets factory:in-progress +``` + +For GitHub-native issues, every collision-prone role is repository-qualified and every state key is composite: normalized source repository plus issue number. Equal-number issues in different repositories must keep agents, registry records, retries, PR caches, publication guards, and babysitter ownership isolated. Linear agent names remain byte-compatible. + +Batch capacity must queue excess work and promote the next issue when the current record completes. Dispatch failures must back off and stop at `dispatch.maxAttempts`. Local internal dispatch expands supported home-relative clone paths, infers cwd only for one remote-matching repository, and fails before spawn when a configured checkout is missing, non-git, or not its worktree root. + +**What breaks if this fails:** agent execution, batch fairness, retry safety, issue lifecycle accuracy. + +--- + +## Path 3: Implement β†’ Publish PR β†’ Review + +The dispatched team must work in the configured checkout, commit and push a branch, and produce a normal pull request through the connected workspace GitHub write path. + +```text +implementer receives issue + repo task + β†’ creates branch, edits, tests, commits, pushes + β†’ exits normally +factory resolves repo default branch + β†’ publishes branch ref through Relayfile + β†’ creates PR through Relayfile + β†’ confirms provider acknowledgement and reads PR receipt +reviewer receives the implementation handoff + β†’ reads PR, comments/approves, finishes +``` + +The PR publication fallback must also work when an implementer exits after pushing but before opening the PR. Draft PRs never count as completion. + +Relay-backed dispatch is a durable lifecycle, not an exit callback: it records a spawn intent, deterministic invocation/branch identity, placement, publication phase and receipt, release checkpoints, and terminal writeback. After a crash or lease handoff it must adopt a roster-visible spawn, publish from the remote pushed head without reading an orchestrator-local clone, confirm the exact mounted PR is open/non-draft/on the expected head, reuse provider receipts, and resume release cleanup without duplicating the team or PR. + +**What breaks if this fails:** Factory produces code with no reviewable artifact, and issues remain stuck in flight. + +--- + +## Path 4: PR β†’ Babysitter β†’ Human Review + +With `babysitter.enabled: true`, a PR-open event must spawn one babysitter, not complete the issue immediately. + +```text +non-draft PR event or implementer-exit safety net + β†’ match PR to the in-flight issue + β†’ spawn ar--babysit with issue + PR + integration context + β†’ babysitter fixes CI/conflicts/review findings and pushes + β†’ babysitter DMs [factory-pr-ready] + β†’ Factory verifies mounted PR is still open and non-draft + β†’ issue moves to Human Review and agents are released +``` + +Repeated PR events must not double-spawn the babysitter. A readiness signal from the wrong agent, wrong issue, a draft PR, a closed PR, or an already-merged PR must not advance the issue. + +After spawn, canonical review, review-comment, issue-comment, failed-check, conflict, and base-divergence records must route only to the exact normalized repo/PR owner. Metadata-only wakes are durably coalesced and delivery-confirmed; events arriving during delivery are retained, transient failures retry, and restart restores session ownership plus pending categories. Before destructive babysitter work, Factory must persist the critical no-submit fence and only then ACK; wakes and PTY submissions remain deferred until the matching exit. Paginated polling follows the same router, and a mounted PR readback cancels stale or terminal work before delivery. + +**What breaks if this fails:** PRs stall red, unsafe readiness claims are trusted, or duplicate agents mutate the same branch. + +--- + +## Path 5: Merge Gate β†’ Merge β†’ Close + +The actual close path is guarded and has two valid product branches. + +### Human-owned merge (default) + +```text +mergePolicy: never + β†’ Factory leaves the PR open + β†’ issue remains Human Review + β†’ merged PR event later arrives + β†’ Linear advances to Done or the GitHub-native issue closes +``` + +### Guarded automatic merge + +```text +mergePolicy: on-green-with-review +terminalState: done + β†’ live GitHub check reports MERGEABLE + CLEAN + β†’ reviewDecision is APPROVED + β†’ at least one status check exists; none is blocking + β†’ live head SHA matches the checked SHA + β†’ gh pr merge --squash --delete-branch --match-head-commit + β†’ mounted PR state proves the merge + β†’ Linear advances to Done or the GitHub-native issue closes +``` + +Unknown mergeability, missing fields, no checks, pending/failing checks, absent approval, conflicts, or a moved head must leave the PR open. + +**What breaks if this fails:** unsafe code merges, completed work never closes, or issues close before their PR actually merges. + +--- + +## Path 6: GitHub-Native Issue Lifecycle + +A workspace without Linear must still complete the full factory loop in GitHub. + +```text +open GitHub issue + configured factory label + repo route label + β†’ issueSource resolves to github + β†’ safety and route checks pass + β†’ agents dispatch + β†’ factory:in-progress label + comment + β†’ PR opens and reaches Human Review + β†’ factory:human-review label + comment + β†’ PR merge event + β†’ issue closes with completion comment +``` + +When `issueSource: linear`, the same source issue must instead be mirrored once into Linear and closed-source changes must propagate to the mirror. + +**What breaks if this fails:** GitHub-only customers cannot use Factory, or source and mirror lifecycles diverge. + +--- + +## Path 7: Human Clarification Round Trip + +Ambiguous intake and mid-task agent questions must reach an authorized human and return to the correct workers. + +```text +thin/low-confidence triage OR [factory-needs-input] agent DM + β†’ post to persisted Slack dispatch thread + β†’ reserve the first question and release the complete agent team + β†’ durably park the issue only after fleet absence is confirmed + β†’ subscribe + poll for a non-bot reply + β†’ deduplicate the reply + β†’ lease exactly one wake + β†’ resume saved sessions, or cold-start from persisted issue/question/answer context + β†’ re-triage or inject the answer into implementer/babysitter session +``` + +If Slack is not configured, Factory must use a correlated GitHub issue comment and accept a reply only from the original issue reporter. Watchers, release/parking phases, pending replies, wake progress, and seven-day stakeholder escalations must survive restart; failed escalation attempts retry, and work that loses Factory scope cancels rather than waking. + +**What breaks if this fails:** ambiguous work is guessed, agents wait forever, or an unauthorized commenter can steer execution. + +--- + +## Path 8: Live Daemon β†’ Heartbeat β†’ Recovery + +Production operation must remain live, observable, and recoverable across failures. + +```bash +factory start --mode live --config ./factory.config.json +factory loop-status --config ./factory.config.json +# β†’ ok=true, stale=false, heartbeat pid is live + +# after a simulated stale owner: +factory reap-orphans --config ./factory.config.json +# β†’ only identity-matched factory-owned processes are terminated +``` + +The daemon must register live intake before startup fallback pulls, suppress replay/duplicate events, refresh the heartbeat while remote operations are slow, persist in-flight agents, re-adopt hosted invocations after restart, and protect broker/node processes from the reaper. Durable dispatch ownership uses lease epochs and a shared `FileStateStore` to fence multiple control-plane processes and batch capacity on the same host through placement, publication, clarification parking, release, and completion. + +Cross-host active/active Factory control planes are intentionally unsupported: separate local files cannot provide a truthful compare-and-set fence. Relay execution nodes may be remote and do not need the control-plane state directory, but all simultaneously active control-plane owners must share the same host state store. + +**What breaks if this fails:** ready issues are missed or duplicated, agents leak, healthy infrastructure is killed, or hosted work is orphaned after restart. + +--- + +## Path 9: Fleet Node Placement + +Hosted execution must place work only on nodes that advertise both the required capability and an authorized checkout. + +```text +factory --backend relay dispatch + β†’ hosted action selects spawn:codex / spawn:claude / workflow:run + β†’ initial and no-session restart requests retain AgentSpec.repo + β†’ node resolves repo to clonePaths or cloneRoot + β†’ unadvertised cwd is rejected + β†’ task runs with invocation identity + β†’ inventory/action reconciliation reports exit to orchestrator +``` + +Internal execution must reuse an operator-owned broker when present, otherwise start a workspace-joined broker and shut down only the infrastructure Factory owns. + +**What breaks if this fails:** work runs on the wrong machine or checkout, remote exits disappear, or Factory disrupts an operator's broker. + +--- + +## Hot Paths (Sensitive, Frequently Touched) + +| Path | Risk | Code Area | +| --- | --- | --- | +| Scope title/label/team checks | Out-of-scope issues mutate provider state | `src/safety/`, `src/orchestrator/factory.ts`, `src/writeback/linear.ts` | +| Sparse Linear canonical fallback | Real ready issues appear non-dispatchable | `src/orchestrator/factory.ts`, `src/linear/state-resolver.ts` | +| Label-derived dispatch identity | Wrong repo or duplicate implementers | `src/triage/heuristic.ts`, `src/orchestrator/factory.ts` | +| GitHub numeric/path resolution | Issue is missed, cross-repo ambiguity is hidden, or wrong source is selected | `src/cli/fleet.ts` | +| Repo-qualified identity + composite issue key | Equal-number issues share agents, PR caches, or lifecycle state | `src/triage/agent-names.ts`, `src/orchestrator/factory.ts` | +| Clone expansion/inference/preflight | Local work spawns in a missing or wrong checkout | `src/config/schema.ts`, `src/config/local-clone-paths.ts`, `src/cli/fleet.ts` | +| Live high-water + replay suppression | Missed or duplicate dispatch | `src/orchestrator/factory.ts`, `src/subscriptions/event-client.ts` | +| Critical message confirmation | Agents start without receiving tasks | `src/orchestrator/factory.ts`, `src/fleet/internal-fleet-client.ts` | +| Durable relay lifecycle + owner epoch | Duplicate remote team/PR, leaked capacity, or stalled release | `src/orchestrator/factory.ts`, `src/ports/state.ts`, `src/state/file-state-store.ts` | +| Remote head publication + receipt recovery | In-flight issue stalls or publishes a stale/local branch | `src/orchestrator/factory.ts`, `src/mount/relayfile-github-connection-write.ts` | +| Canonical babysitter routing + durable wake | Wrong PR wakes, event loss, or duplicate branch mutation | `src/orchestrator/factory.ts`, `src/ports/state.ts`, `src/state/file-state-store.ts` | +| Babysitter critical ACK fence | PTY input interrupts destructive work before safety state is durable | `src/orchestrator/factory.ts`, `src/dispatch/templates.ts` | +| Clarification release/park/wake | Capacity leaks, duplicate workers, or human replies disappear | `src/orchestrator/factory.ts`, `src/ports/state.ts`, `src/state/file-state-store.ts` | +| Bounded log normalization | Errors disappear or hostile metadata crashes/redacts incorrectly | `src/logging.ts` | +| Merge gate live fields/head guard | Unsafe or stale-head merge | `src/github/merge-gate.ts`, `src/orchestrator/factory.ts` | +| GitHub post-merge advancement | Issue stays Human Review after merge | `src/orchestrator/factory.ts`, `src/writeback/github.ts` | +| Slack/GitHub answer authorization | Wrong human steers active agents | `src/orchestrator/factory.ts` | +| File state + re-adoption | Restart loses live agent ownership | `src/state/`, `src/fleet/relay-fleet-client.ts` | +| Relay repo placement on spawn/restart | Hosted work lands on a node without the source checkout | `src/ports/fleet.ts`, `src/fleet/relay-fleet-client.ts`, `src/orchestrator/factory.ts` | +| Reaper PID identity/protection | Healthy broker, node, or unrelated process is killed | `src/orchestrator/reaper.ts`, `src/orchestrator/process-identity.ts` | +| Node checkout containment | Hosted input executes outside advertised repos | `src/node/factory-node.ts` | +| Relayfile guarded write + confirmation | Writes escape scope or are reported before provider ack | `src/mount/`, `src/writeback/` | + +--- + +## First Health Check + +Run these in order when the system's state is unclear: + +```bash +factory --help +npm run build +npm test +# Prepare /tmp/factory-tier2-config.json as documented in verify/procedures.md. +factory run-once --config /tmp/factory-tier2-config.json --dry-run +factory status --config /tmp/factory-tier2-config.json +``` + +Then, with provider and fleet prerequisites available: + +```bash +factory canary --config ./factory.config.json +factory loop-status --config ./factory.config.json +factory fleet roster --backend internal +``` + +The first failing step narrows the problem to package/CLI health, config and deterministic orchestration, provider sync fidelity, daemon liveness, or fleet availability. diff --git a/.agentworkforce/features/manifest.yaml b/.agentworkforce/features/manifest.yaml new file mode 100644 index 0000000..54757e9 --- /dev/null +++ b/.agentworkforce/features/manifest.yaml @@ -0,0 +1,1806 @@ +version: '1.0' +updated: '2026-07-17' +catalog: + category_count: 19 + feature_count: 238 + tier_counts: + 1: 22 + 2: 104 + 3: 10 + 4: 41 + 5: 47 + 6: 14 + +# Every user-facing feature in @agent-relay/factory, categorized and scored. +# +# criticality: +# critical - product cannot function without this; verify on every change +# hot - commonly used or safety-sensitive path; verify for related changes +# standard - useful supporting or programmatic feature; verify periodically +# +# verify_tier (what is required to run verification): +# 1 - nothing beyond the installed package/source checkout +# 2 - a valid factory.config.json (fixture-backed is acceptable) +# 3 - reachable ticket provider credentials (Linear or GitHub) +# 4 - a fleet backend (internal broker or hosted Relay fleet) is available +# 5 - cloud auth and a writable Relayfile mount are available +# 6 - manual verification with a live issue or pull request + +categories: + cli-operations: + name: Factory CLI Operations + description: Published factory commands and global execution controls + criticality: critical + features: + - id: cli-help + name: CLI Help + cli: factory --help + description: Print the complete factory command and global option surface without loading config + location: src/cli/fleet.ts + verify_tier: 1 + + - id: cli-run-once + name: Single Factory Cycle + cli: factory run-once + api: Factory.runOnce() + description: Run one discover, triage, and dispatch cycle and emit an IterationReport as JSON + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: cli-start-live + name: Live Factory Daemon + cli: factory start --mode live + api: "Factory.start({ mode: 'live' })" + description: Start the long-lived event-driven factory daemon until SIGINT or SIGTERM + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: cli-status + name: Factory Status + cli: factory status + api: Factory.status() + description: Print in-flight issues, queued issues, counters, and Slack degradation state as JSON + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: cli-loop + name: Bounded Factory Loop + cli: factory loop + api: Factory.runLoop() + description: Run a bounded multi-iteration factory loop with heartbeat and circuit-breaker handling + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: cli-loop-status + name: Loop Liveness Status + cli: factory loop-status + api: checkFactoryLoopLiveness() + description: Read the loop heartbeat and report whether the owning process is live or stale + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: cli-kill-loop + name: Stop Loop by Heartbeat PID + cli: factory kill-loop + description: Send SIGTERM to the process recorded in the configured factory heartbeat + location: src/cli/fleet.ts + verify_tier: 6 + + - id: cli-reap-orphans + name: Reap Orphaned Factory Agents + cli: factory reap-orphans + api: reapFactoryOrphansOnce() + description: Reconcile stale heartbeat and registry state and terminate factory-owned orphan processes + location: src/cli/fleet.ts, src/orchestrator/reaper.ts + verify_tier: 4 + + - id: cli-canary + name: Sync-Fidelity Canary + cli: factory canary + description: Exercise the real dry-run discovery and triage path and fail if a known issue is not dispatch-ready + location: src/cli/fleet.ts + verify_tier: 3 + + - id: cli-triage + name: Triage One Issue + cli: factory triage + api: Factory.triageIssue() + description: Read one Linear or GitHub issue, route it, and print the normalized triage decision + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 3 + + - id: cli-dispatch + name: Dispatch One Issue + cli: factory dispatch + api: Factory.dispatch() + description: Triage one issue and dispatch its implementation team through the selected fleet backend + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: cli-github-numeric-issue + name: GitHub Numeric Issue Resolution + cli: factory + description: Resolve a positive GitHub issue number within configured repositories without requiring a repo-qualified path + location: src/cli/fleet.ts + verify_tier: 3 + + - id: cli-babysit + name: Standalone Pull Request Babysitter + cli: factory babysit + description: Spawn a one-shot task-exit babysitter for an existing open non-draft same-repository PR + location: src/cli/fleet.ts, src/github/standalone-babysitter.ts + verify_tier: 6 + + - id: cli-close-probe + name: Close Synthetic Probe PR + cli: factory close-probe --repo --issue + api: closeProbePr() + description: Guard and close a synthetic probe pull request through the connected GitHub write path + location: src/cli/fleet.ts, src/github/probe-closer.ts + verify_tier: 6 + + - id: cli-config-option + name: Alternate Config File + cli: factory --config + description: Load factory configuration from a caller-selected JSON path instead of ./factory.config.json + location: src/cli/fleet.ts + verify_tier: 1 + + - id: cli-dry-run-option + name: Dry-Run Execution + cli: factory --dry-run + description: Discover and triage without provider writes, agent spawns, or destructive lifecycle changes + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: cli-backend-option + name: Fleet Backend Selection + cli: factory --backend + api: 'createFleet({ backend })' + description: Select an internal relay broker or the hosted Relay fleet for agent placement + location: src/cli/fleet.ts, src/fleet/create-fleet.ts + verify_tier: 4 + + - id: cli-agent-exit-timeout-option + name: Owned Broker Agent Exit Timeout + cli: factory --agent-exit-timeout + api: FACTORY_AGENT_EXIT_TIMEOUT_MS + description: Bound how long a factory-owned internal broker waits for task-exit agents during disposal + location: src/cli/fleet.ts, src/fleet/create-fleet.ts, src/fleet/internal-fleet-client.ts + verify_tier: 4 + + fleet-cli: + name: Low-Level Fleet CLI + description: Direct spawn, inventory, resume, and release controls under factory fleet + criticality: hot + features: + - id: fleet-spawn-codex + name: Spawn Codex Worker + cli: factory fleet spawn spawn:codex + api: "FleetClient.spawn({ capability: 'spawn:codex' })" + description: Launch a Codex harness worker with optional name, node, task, model, and checkout inputs + location: src/cli/fleet.ts, src/fleet/internal-fleet-client.ts, src/fleet/relay-fleet-client.ts + verify_tier: 4 + + - id: fleet-spawn-claude + name: Spawn Claude Worker + cli: factory fleet spawn spawn:claude + api: "FleetClient.spawn({ capability: 'spawn:claude' })" + description: Launch a Claude harness worker with optional name, node, task, model, and checkout inputs + location: src/cli/fleet.ts, src/fleet/internal-fleet-client.ts, src/fleet/relay-fleet-client.ts + verify_tier: 4 + + - id: fleet-spawn-workflow + name: Spawn Relayflows Workflow + cli: factory fleet spawn workflow:run + api: "FleetClient.spawn({ capability: 'workflow:run' })" + description: Place a workflow run on a capable fleet node + location: src/cli/fleet.ts, src/node/factory-node.ts + verify_tier: 4 + + - id: fleet-resume + name: Resume Agent Session + cli: factory fleet spawn --resume + api: FleetClient.resume() + description: Resume an existing harness session instead of creating a fresh worker + location: src/cli/fleet.ts, src/fleet/internal-fleet-client.ts, src/fleet/relay-fleet-client.ts + verify_tier: 4 + + - id: fleet-target-node + name: Target Fleet Node + cli: factory fleet spawn --node + description: Constrain a spawn or resume request to the local node or a named hosted node + location: src/cli/fleet.ts, src/fleet/relay-fleet-client.ts + verify_tier: 4 + + - id: fleet-spawn-inputs + name: Spawn Task Inputs + cli: factory fleet spawn [--name N] [--task T] [--model M] [--cwd P] + description: Customize worker identity, initial task, model, and working directory + location: src/cli/fleet.ts + verify_tier: 4 + + - id: fleet-roster + name: Fleet Roster + cli: factory fleet roster + api: FleetClient.roster() + description: List active agents and fleet nodes with capability and liveness information + location: src/cli/fleet.ts, src/ports/fleet.ts + verify_tier: 4 + + - id: fleet-release + name: Release Fleet Agent + cli: factory fleet release [--reason ] + api: FleetClient.release() + description: Gracefully release an agent and optionally record the operator-supplied reason + location: src/cli/fleet.ts, src/ports/fleet.ts + verify_tier: 4 + + discovery-events: + name: Issue Discovery and Event Ingestion + description: Pull and live-event paths that find work without missing or double-processing it + criticality: critical + features: + - id: discovery-linear-ready + name: Linear Ready Issue Discovery + api: Factory.runOnce() + description: Discover canonical Linear issues in the Ready for Agent state, including ready-state aliases + location: src/orchestrator/factory.ts + verify_tier: 3 + + - id: discovery-github-native + name: GitHub-Native Issue Discovery + api: Factory.runOnce() + description: Discover open GitHub issues carrying the configured readiness label without requiring Linear + location: src/orchestrator/factory.ts + verify_tier: 3 + + - id: discovery-github-path-shapes + name: Canonical GitHub Issue Path Compatibility + api: findIssuePath() + description: Resolve owner__repo and owner/repo mounts across meta.json, by-id, and flat issue records, with scoped fallback scans and ambiguity rejection + location: src/cli/fleet.ts + verify_tier: 3 + + - id: discovery-github-linear-mirror + name: GitHub-to-Linear Issue Mirroring + api: Factory.runOnce() + description: Mirror factory-labeled GitHub issues into scoped Linear issues and deduplicate repeated ingestion + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: discovery-github-mirror-close + name: GitHub Mirror Closure Propagation + api: Factory.runOnce() + description: Advance a mirrored Linear issue to Done when its source GitHub issue closes + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: discovery-source-auto-select + name: Automatic Issue Source Selection + api: Factory.start() + description: Infer GitHub-native intake from repository-only config, preserve legacy Linear-oriented configs, and report the selected source in resolution failures + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 5 + + - id: discovery-canonical-fallback + name: Sparse Linear Record Reconciliation + api: readLinearIssueWithCanonicalFallback() + description: Replace sparse primary event stubs with canonical by-id or by-uuid issue records before triage + location: src/orchestrator/factory.ts + verify_tier: 3 + + - id: discovery-state-name-backfill + name: Linear State Name Backfill + api: FactoryStateResolution.idForName() + description: Recover missing workflow-state UUIDs from configured state names on sparse synced records + location: src/orchestrator/factory.ts, src/linear/state-resolver.ts + verify_tier: 3 + + - id: discovery-startup-backfill + name: Startup Ready-Issue Backfill + api: "Factory.start({ mode: 'backfill-and-subscribe' })" + description: Process recent events and the current ready tree before relying on subsequent live changes + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: discovery-live-subscribe + name: Live Subscription Transport + api: "Factory.start({ mode: 'live' })" + description: Subscribe to issue and PR changes from the current high-water mark + location: src/orchestrator/factory.ts, src/subscriptions/event-client.ts + verify_tier: 5 + + - id: discovery-live-poll + name: Live Polling Transport + api: "Factory.start({ liveSubscription: { transport: 'poll' } })" + description: Drain paginated workspace events on an interval when polling is selected + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: discovery-live-hybrid + name: Subscribe-and-Poll Recovery + api: liveSubscription.transport = 'subscribe-and-poll' + description: Use the Relayfile stream with SDK-managed polling fallback and recovery + location: src/config/schema.ts, src/subscriptions/event-client.ts + verify_tier: 5 + + - id: discovery-replay-suppression + name: Replay Cutoff and High-Water Suppression + api: Factory.start() + description: Reject stale and already-observed live events by timestamp, high-water mark, and stable event identity + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: discovery-duplicate-suppression + name: Issue Event Deduplication + api: Factory.start() + description: Suppress duplicate issue and PR events within a drain and against queued or in-flight work + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: discovery-high-water-fallback + name: High-Water Route Fallback + api: Factory.start() + description: Run a safe startup full pull when the event high-water route is unavailable, then drain buffered changes + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: discovery-relayflow-events + name: Integration Event Relayflow Dispatch + api: dispatchRelayflowForChangeEvent() + description: Normalize provider change events, resolve registered trigger policies, and run the selected Relayflow + location: src/dispatch/relayflow-registry.ts, src/orchestrator/factory.ts + verify_tier: 5 + + triage-routing: + name: Triage and Repository Routing + description: Deterministic and optional LLM-assisted decisions that shape each factory team + criticality: critical + features: + - id: triage-label-routing + name: Label-Based Repository Routing + api: HeuristicTriage.triage() + description: Route issue labels through repos.byLabel with case-insensitive matching and highest precedence + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-project-routing + name: Project-Based Repository Routing + api: HeuristicTriage.triage() + description: Route an issue through repos.byProject when no repository label matches + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-keyword-routing + name: Keyword Repository Routing + api: HeuristicTriage.triage() + description: Apply configured case-insensitive regex rules to issue title and description after label and project routing + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-default-routing + name: Default Repository Routing + api: HeuristicTriage.triage() + description: Use repos.default when no label, project, or keyword route matches + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-unroutable-escalation + name: Unroutable Issue Escalation + api: HeuristicTriage.triage() + description: Produce a low-confidence decision for human routing instead of guessing a repository + location: src/triage/heuristic.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: triage-single-scope + name: Single-Agent Execution Shape + api: TriageDecision.scope = 'single' + description: Assign one implementer plus a reviewer for work constrained to one implementation surface + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-team-scope + name: Multi-Agent Team Execution Shape + api: TriageDecision.scope = 'team' + description: Assign bounded implementers across multiple routes or inferred surfaces plus a reviewer + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-workflow-scope + name: Relayflow Execution Shape + api: TriageDecision.scope = 'workflow' + description: Dispatch a workflow:run specification instead of direct implementers + location: src/triage/heuristic.ts, src/node/factory-node.ts + verify_tier: 2 + + - id: triage-shape-labels + name: Explicit Shape Labels + api: scopeFromLabels() + description: Honor agent:single, agent:team, and agent:workflow labels ahead of shape inference + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-surface-inference + name: Surface-Bucket Shape Inference + api: detectSurfaceBuckets() + description: Infer team scope from UI, main, Slack, Linear, GitHub, cloud, and docs signals + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-thin-issue-detection + name: Thin Issue Detection + api: isThinIssue() + description: Mark descriptions below the length threshold or without acceptance signals for clarification + location: src/triage/heuristic.ts + verify_tier: 2 + + - id: triage-llm-fallback + name: LLM Triage Fallback + api: LlmTriage.triage() + description: Ask an injected LLM for schema-validated routing only when deterministic triage is thin or low confidence + location: src/triage/llm.ts, src/triage/schema.ts + verify_tier: 2 + + - id: triage-tiered-fail-safe + name: Tiered Triage Fail-Safe + api: TieredTriage.triage() + description: Fall back to a low-confidence heuristic decision when optional LLM triage times out or fails validation + location: src/triage/tiered.ts + verify_tier: 2 + + - id: triage-decision-normalization + name: Triage Decision Normalization + api: TriageDecisionSchema + description: Validate agent specs and rebuild routes, names, clone paths, scope, and implementer limits deterministically + location: src/triage/schema.ts, src/triage/heuristic.ts, src/triage/tiered.ts + verify_tier: 2 + + - id: triage-repo-qualified-agent-identities + name: Repository-Qualified GitHub Agent Identities + api: agentNameForRole() + description: Add the GitHub source repository slug to implementer, reviewer, workflow, and babysitter names while preserving byte-compatible Linear identities + location: src/triage/agent-names.ts, src/triage/heuristic.ts, src/triage/llm.ts + verify_tier: 2 + + dispatch-orchestration: + name: Team Dispatch and Agent Lifecycle + description: Batch admission, agent spawning, messaging, persistence, recovery, and completion coordination + criticality: critical + features: + - id: dispatch-batch-admission + name: Bounded Batch Admission + api: BatchTracker.start() + description: Start work only while batch capacity is available and queue excess triage decisions + location: src/orchestrator/batch-tracker.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: dispatch-queue-promotion + name: Queued Issue Promotion + api: BatchTracker.complete() + description: Promote the next queued issue immediately when an in-flight record completes + location: src/orchestrator/batch-tracker.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: dispatch-duplicate-suppression + name: Duplicate Dispatch Suppression + api: Factory.dispatch() + description: Return the existing result or in-flight promise for repeated dispatch attempts on the same issue and phase + location: src/orchestrator/factory.ts + verify_tier: 2 + + - id: dispatch-composite-issue-identity + name: Composite GitHub Issue State Identity + api: issueKey() + description: Key GitHub dispatch, registry, PR cache, publication guard, retry, and babysitter state by normalized repository plus issue number so equal-number issues remain isolated + location: src/orchestrator/factory.ts, src/orchestrator/batch-tracker.ts + verify_tier: 2 + + - id: dispatch-retry-backoff + name: Dispatch Failure Backoff + api: dispatch.errorCooldownMs + description: Back off a failed dispatch before it becomes eligible for another attempt + location: src/orchestrator/factory.ts, src/config/schema.ts + verify_tier: 2 + + - id: dispatch-retry-limit + name: Dispatch Attempt Limit + api: dispatch.maxAttempts + description: Mark repeatedly failing dispatches terminal after the configured bounded retry count + location: src/orchestrator/factory.ts, src/config/schema.ts + verify_tier: 2 + + - id: dispatch-label-authority + name: Authoritative Label Dispatch Identity + api: Factory.dispatch() + description: Rebuild dispatch identity from valid repository labels and block ambiguous or invalid label sets with a deduplicated issue comment + location: src/orchestrator/factory.ts + verify_tier: 3 + + - id: dispatch-implementers + name: Implementer Agent Dispatch + api: FleetClient.spawn() + description: Spawn one or more Codex implementers with stable issue-derived names and idempotent invocation IDs + location: src/triage/heuristic.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: dispatch-reviewer + name: Reviewer Agent Dispatch + api: FleetClient.spawn() + description: Spawn a Claude reviewer alongside the implementation team and coordinate it by direct messages + location: src/triage/heuristic.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: dispatch-workflow-agent + name: Workflow Dispatch + api: "FleetClient.spawn({ capability: 'workflow:run' })" + description: Dispatch configured Relayflow templates with normalized issue and route inputs + location: src/triage/heuristic.ts, src/orchestrator/factory.ts, src/node/factory-node.ts + verify_tier: 4 + + - id: dispatch-agent-task-rendering + name: Agent Task Rendering + api: renderAgentTask() + description: Give implementers, reviewers, and babysitters role-specific issue, repo, PR, merge-policy, and integration instructions + location: src/dispatch/templates.ts + verify_tier: 2 + + - id: dispatch-confirmed-task-injection + name: Confirmed Critical Task Injection + api: FleetClient.waitForInjected() + description: Wait for broker delivery confirmation before submitting task input to a harness + location: src/orchestrator/factory.ts, src/fleet/internal-fleet-client.ts + verify_tier: 4 + + - id: dispatch-critical-delivery-retry + name: Critical Delivery Retry + api: FleetClient.onDeliveryFailed() + description: Persist critical messages by event ID and retry them when broker delivery fails + location: src/orchestrator/factory.ts, src/state/in-memory-state-store.ts + verify_tier: 4 + + - id: dispatch-agent-resume + name: Agent Exit Resume + api: FleetClient.resume() + description: Resume an interrupted session once, then fail safely when the same worker remains stalled + location: src/orchestrator/factory.ts + verify_tier: 4 + + - id: dispatch-exit-pr-publication + name: Implementer Branch PR Publication + api: GithubConnectionWrite.publishPullRequest() + description: Publish a committed implementer branch through the workspace GitHub connection when the agent exits without opening a PR + location: src/orchestrator/factory.ts, src/mount/relayfile-github-connection-write.ts + verify_tier: 5 + + - id: dispatch-state-writeback + name: Dispatch Lifecycle Writeback + api: LinearWriteback.setState() / GithubWriteback.setStatus() + description: Post the dispatch summary and advance the source issue to Agent Implementing or factory:in-progress + location: src/orchestrator/factory.ts, src/writeback/linear.ts, src/writeback/github.ts + verify_tier: 5 + + - id: dispatch-inflight-registry + name: In-Flight Registry Persistence + api: FileStateStore + description: Persist issue, agent, session, PID, invocation, and node ownership for crash recovery and reaping + location: src/orchestrator/factory.ts, src/state/file-state-store.ts + verify_tier: 2 + + - id: dispatch-remote-re-adoption + name: Remote Agent Re-Adoption + api: FleetClient.hydrateTracked() / reconcileTrackedAgents() + description: Restore hosted fleet tracking from the registry after an orchestrator restart and reconcile missed exits + location: src/orchestrator/factory.ts, src/fleet/relay-fleet-client.ts + verify_tier: 4 + + - id: dispatch-durable-relay-lifecycle + name: Durable Relay Dispatch Lifecycle + api: DispatchLifecycle / StateStore.claimDispatchLifecycle() + description: Persist placement intents, agents, publication receipts, release checkpoints, and waiting phases so relay dispatch survives owner crashes through terminal writeback + location: src/ports/state.ts, src/state/file-state-store.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: dispatch-same-host-owner-fencing + name: Same-Host Dispatch Owner Fencing + api: StateStore.claimDispatchLifecycle() / renewDispatchLifecycle() + description: Fence lifecycle ownership and global batch capacity by lease epoch across processes sharing one FileStateStore; cross-host active/active control planes remain unsupported without shared compare-and-set state + location: README.md, src/ports/state.ts, src/state/file-state-store.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: dispatch-remote-publication-recovery + name: Remote PR Publication and Release Recovery + api: GithubConnectionWrite.publishPullRequest() / DispatchLifecycle + description: Publish from a remote pushed head, verify the exact mounted PR is open and non-draft, reuse provider receipts, and autonomously retry publication or release cleanup after lease loss or restart + location: src/orchestrator/factory.ts, src/mount/relayfile-github-connection-write.ts, src/ports/state.ts + verify_tier: 5 + + human-loop: + name: Human Clarification Loop + description: Slack and GitHub conversations that unblock triage and running agents + criticality: hot + features: + - id: human-slack-dispatch-thread + name: Slack Dispatch Thread + api: SlackWriteback.postThread() + description: Post and persist a per-issue Slack status thread after dispatch + location: src/orchestrator/factory.ts, src/writeback/slack.ts + verify_tier: 5 + + - id: human-triage-escalation + name: Triage Clarification Escalation + api: Factory.dispatch() + description: Ask a targeted routing or specification question when triage is thin or low confidence + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: human-agent-questions + name: Running Agent Questions + api: FleetClient.onAgentMessage() + description: Recognize factory-needs-input messages from tracked agents and surface them to a human + location: src/orchestrator/factory.ts + verify_tier: 4 + + - id: human-team-release-for-clarification + name: Clarification Team Release and Parking + api: DispatchLifecycle.phase = 'parking' / 'waiting-for-human' + description: Durably reserve the first agent question, release the full team after fleet absence is confirmed, and keep batch capacity occupied until parking is committed + location: src/orchestrator/factory.ts, src/ports/state.ts, src/state/file-state-store.ts + verify_tier: 4 + + - id: human-github-question-fallback + name: GitHub Issue Question Fallback + api: GithubWriteback.postComment() + description: Post correlated questions to the source GitHub issue when no Slack channel is configured + location: src/orchestrator/factory.ts, src/writeback/github.ts + verify_tier: 5 + + - id: human-authorized-github-replies + name: Authorized GitHub Replies + api: Factory.start() + description: Accept correlated non-bot answers only from the original GitHub issue reporter + location: src/orchestrator/factory.ts + verify_tier: 6 + + - id: human-slack-reply-watch + name: Slack Reply Watch and Replay + api: Factory.start() + description: Subscribe and poll for new thread replies, deduplicate them, and re-arm persisted watchers after restart + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: human-durable-clarification-wake + name: Durable Clarification Wake + api: StateStore.claimClarificationWake() + description: Lease one wake per reply, resume saved sessions or cold-start from persisted issue/question/answer context, and restore interrupted wakes after restart + location: src/orchestrator/factory.ts, src/ports/state.ts, src/state/file-state-store.ts + verify_tier: 4 + + - id: human-clarification-escalation-retry + name: Durable Clarification Escalation Retry + api: Factory.start() + description: Cancel stale out-of-scope wakes and durably retry unanswered seven-day stakeholder escalations without consuming failed attempts + location: src/orchestrator/factory.ts, src/ports/state.ts, src/state/file-state-store.ts + verify_tier: 5 + + - id: human-answer-injection + name: Human Answer Injection + api: FleetClient.sendInput() + description: Inject Slack or GitHub answers into implementer and babysitter sessions as integration events + location: src/orchestrator/factory.ts + verify_tier: 4 + + - id: human-clarified-redispatch + name: Clarified Issue Re-Triage + api: TieredTriage.triage() + description: Re-triage an issue with the human answer, then start or queue the clarified decision + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: human-slack-degraded-mode + name: Slack Degraded Mode + api: Factory.status() + description: Detect stale or failed Slack sync/writeback, suppress unsafe writes during backoff, and keep core factory work running + location: src/orchestrator/factory.ts + verify_tier: 5 + + pr-lifecycle: + name: Pull Request and Completion Lifecycle + description: PR discovery, babysitting, merge gating, terminal transitions, and probe cleanup + criticality: critical + features: + - id: pr-completion-detection + name: Completion PR Detection + api: Factory.start() / Factory.runLoop() + description: Resolve an issue's PR from mounted GitHub records with guarded gh fallback and bounded polling sweeps + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: pr-draft-block + name: Draft PR Completion Guard + api: Factory.start() + description: Keep an issue in flight while its matching pull request remains a draft + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: pr-babysitter-opt-in + name: Event-Driven PR Babysitter + api: babysitter.enabled + description: Spawn a Claude babysitter when a non-draft PR opens and let it address CI, conflicts, and review feedback + location: src/orchestrator/factory.ts, src/triage/heuristic.ts, src/dispatch/templates.ts + verify_tier: 6 + + - id: pr-standalone-babysitter-validation + name: Standalone Babysitter PR Validation + api: readStandalonePullRequest() + description: Combine mount and gh metadata while rejecting closed, merged, draft, incomplete, or cross-repository PRs + location: src/cli/fleet.ts, src/github/standalone-babysitter.ts + verify_tier: 6 + + - id: pr-babysitter-ready-signal + name: Babysitter Readiness Signal + api: FleetClient.onAgentMessage() + description: Accept a matching factory-pr-ready signal from the tracked babysitter and guard it against current mounted PR state + location: src/orchestrator/factory.ts + verify_tier: 6 + + - id: pr-canonical-event-routing + name: Canonical Owned PR Event Routing + api: Factory.start() + description: Route validated review, review-comment, issue-comment, failed-check, conflict, and base-divergence records only to the babysitter owning the exact normalized repo and PR + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: pr-durable-babysitter-sessions + name: Durable Babysitter Session Ownership + api: BabysitterSessionState / StateStore.setBabysitterSession() + description: Persist exact issue, repo, PR, agent, session, critical fence, and pending wake categories and restore them without replaying the original implementation team + location: src/ports/state.ts, src/state/file-state-store.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: pr-babysitter-wake-coalescing + name: Confirmed Babysitter Wake Coalescing and Retry + api: FleetClient.waitForInjected() / StateStore.setBabysitterSession() + description: Coalesce metadata-only PR activity, require confirmed delivery, retain events arriving in flight, and retry failed delivery or persistence without loss or multiplication + location: src/orchestrator/factory.ts, src/ports/state.ts, src/state/file-state-store.ts + verify_tier: 4 + + - id: pr-babysitter-critical-ack-fence + name: Critical Babysitter ACK Fence + api: parseBabysitterCriticalSignal() + description: Persist the no-submit destructive-work fence before acknowledging entry, recheck it after delivery ACK, and defer PTY submission until the matching critical section exits + location: src/orchestrator/factory.ts, src/dispatch/templates.ts, src/ports/state.ts + verify_tier: 4 + + - id: pr-polling-readback-backstop + name: PR Polling and Readback Backstop + api: Factory.start() + description: Feed paginated poll events through the same exact-PR router and re-read mounted PR state before wake delivery or cancellation so missed or stale events fail closed + location: src/orchestrator/factory.ts, src/ports/mount.ts + verify_tier: 5 + + - id: pr-human-review-terminal + name: Human Review Terminal State + api: terminalState = 'human-review' + description: Park completed work in Linear Human Review or GitHub factory:human-review while leaving the PR open + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: pr-done-terminal + name: Done Terminal State + api: terminalState = 'done' + description: Advance completed Linear work to Done or close a GitHub issue only after its PR is observed merged + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: pr-merge-gate-check + name: Merge Gate Readiness Check + api: GithubMergeGate.check() + description: Require MERGEABLE, CLEAN, APPROVED, a stable head SHA, and at least one non-blocking check result + location: src/github/merge-gate.ts, src/orchestrator/factory.ts + verify_tier: 6 + + - id: pr-guarded-merge + name: Head-Guarded Squash Merge + api: GithubMergeGate.merge() + description: Squash merge and delete the branch only when the live head still matches the approved SHA + location: src/github/merge-gate.ts + verify_tier: 6 + + - id: pr-never-auto-merge + name: Never Auto-Merge Policy + api: mergePolicy = 'never' + description: Leave all pull requests open for a human unless guarded auto-merge is explicitly enabled + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: pr-on-green-merge-policy + name: On-Green-With-Review Policy + api: mergePolicy = 'on-green-with-review' + description: Poll the merge gate and attempt a guarded merge only after checks and review approval are green + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 6 + + - id: pr-post-merge-advance + name: Post-Merge Done Advancement + api: Factory.start() + description: Match merged PR events back to in-flight or persisted issues and advance them to the true Done state + location: src/orchestrator/factory.ts + verify_tier: 5 + + - id: pr-synthetic-probe-cleanup + name: Synthetic Probe Cleanup + api: closeProbePr() + description: Detect factory-e2e issues and close their guarded PRs instead of merging synthetic work + location: src/orchestrator/factory.ts, src/github/probe-closer.ts + verify_tier: 6 + + safety: + name: Opt-In Safety and Mutation Guards + description: Fail-closed checks that constrain issue scope, provider writes, PR actions, and node paths + criticality: critical + features: + - id: safety-title-prefix + name: Linear Title Prefix Gate + api: isInFactoryScope() + description: Admit Linear work only when its title starts at the configured marker boundary or an accepted label is present + location: src/safety/factory-scope.ts + verify_tier: 2 + + - id: safety-label-gate + name: Factory Label Gate + api: isInFactoryScope() + description: Treat the configured factory label as an explicit opt-in across supported label payload shapes + location: src/safety/factory-scope.ts + verify_tier: 2 + + - id: safety-team-gate + name: Linear Team Gate + api: isInFactoryScope() + description: Refuse dispatch and writeback when an issue belongs to a different configured team + location: src/safety/factory-scope.ts, src/writeback/linear.ts + verify_tier: 2 + + - id: safety-github-open-label + name: GitHub Native Scope Gate + api: Factory.runOnce() + description: Admit only open GitHub issues carrying safety.requireLabel + location: src/orchestrator/factory.ts + verify_tier: 3 + + - id: safety-guarded-linear-drafts + name: Guarded Linear Draft Writes + api: 'MountClient.writeFile(..., { guarded: true })' + description: Allow issue and nested comment drafts only after the owning issue passes factory scope checks + location: src/cli/fleet.ts, src/orchestrator/factory.ts, src/writeback/linear.ts + verify_tier: 5 + + - id: safety-guarded-github-drafts + name: Guarded GitHub Draft Writes + api: 'MountClient.writeFile(..., { guarded: true })' + description: Restrict GitHub provider writes to factory PR creation, branch refs, and explicit PR-close command paths + location: src/cli/fleet.ts, src/orchestrator/factory.ts + verify_tier: 5 + + - id: safety-probe-close-identity + name: Probe Close Identity Guard + api: closeProbePr() + description: Require the expected issue key and, by default, the factory-e2e title marker before closing a PR + location: src/github/probe-closer.ts + verify_tier: 6 + + - id: safety-babysit-pr-identity + name: Standalone PR Identity Guard + api: parseStandaloneBabysitTarget() + description: Accept only positive PR numbers or canonical GitHub PR URLs and refuse draft or cross-repository work + location: src/github/standalone-babysitter.ts, src/cli/fleet.ts + verify_tier: 2 + + - id: safety-node-checkout-containment + name: Advertised Checkout Containment + api: createFactoryNodeDefinition() + description: Refuse cloud-supplied working directories outside configured clone paths or clone root + location: src/node/factory-node.ts + verify_tier: 2 + + - id: safety-relay-token-types + name: Relay Credential Type Separation + api: resolveRelayWorkspaceKey() / resolveRelayAgentToken() + description: Accept only rk_live workspace keys and at_live agent tokens for their respective identities + location: src/fleet/relay-workspace-key.ts + verify_tier: 1 + + - id: safety-merge-head-sha + name: Merge Head SHA Guard + api: evaluateGithubMergeGate() + description: Refuse merge if the live PR head moved after readiness was checked + location: src/github/merge-gate.ts + verify_tier: 1 + + - id: safety-delete-fail-closed + name: Provider Delete Guard + api: RelayfileCloudMountClient.deleteFile() + description: Permit provider deletion only for failed unreconciled creates accepted by an explicit delete predicate + location: src/mount/relayfile-cloud-mount-client.ts + verify_tier: 5 + + mount-writeback: + name: Relayfile Mount and Provider Writeback + description: Cloud workspace access, local mount health, and confirmed provider mutations + criticality: critical + features: + - id: mount-active-workspace + name: Active Workspace Resolution + api: resolveFactoryWorkspace() + description: Resolve the active cloud workspace handle and its UUID alias, with an offline default fallback + location: src/mount/relayfile-cloud-mount-client.ts + verify_tier: 5 + + - id: mount-cloud-session + name: Non-Interactive Cloud Session + api: RelayfileCloudMountClient.fromConfig() + description: Refresh cloud authentication, join the workspace with least-privilege scopes, and explain when browser login is required + location: src/mount/relayfile-cloud-mount-client.ts + verify_tier: 5 + + - id: mount-cloud-filesystem + name: Cloud Filesystem Operations + api: RelayfileCloudMountClient + description: Read, list, subscribe, and query events and provider sync status for the selected workspace + location: src/mount/relayfile-cloud-mount-client.ts + verify_tier: 5 + + - id: mount-write-confirmation + name: Provider Write Confirmation + api: MountClient.confirmWrite() + description: Wait for a provider operation to produce an acknowledged external object and fail on incomplete or dead-lettered results + location: src/mount/relayfile-cloud-mount-client.ts + verify_tier: 5 + + - id: mount-local-auto-start + name: Local Integration Mount Auto-Start + api: ensureLocalMount() + description: Start a background relayfile mount when .integrations state is missing or invalid + location: src/mount/local-mount-preflight.ts, src/mount/relayfile-binary.ts + verify_tier: 5 + + - id: mount-stale-self-heal + name: Stale Mount Self-Healing + api: ensureLocalMount() + description: Detect workspace mismatch, stale reconciliation, or a dead mount process and refresh the mount in place + location: src/mount/local-mount-preflight.ts, src/mount/relayfile-binary.ts + verify_tier: 5 + + - id: mount-clone-paths + name: Per-Checkout Integration Mounts + cli: factory --config + description: Ensure each configured repository checkout can reach an absolute .integrations mount for agent writeback + location: src/cli/fleet.ts + verify_tier: 5 + + - id: writeback-linear-state + name: Linear State Writeback + api: MountLinearWriteback().setState() + description: Update a scoped Linear issue state and confirm provider acknowledgement + location: src/writeback/linear.ts + verify_tier: 5 + + - id: writeback-linear-comment + name: Linear Comment Writeback + api: MountLinearWriteback().postComment() + description: Post stable, deduplicated comments under a scoped Linear issue and confirm delivery + location: src/writeback/linear.ts + verify_tier: 5 + + - id: writeback-linear-create + name: Linear Issue Creation + api: MountLinearWriteback().createIssue() + description: Create scoped Linear mirror issues through guarded file-native drafts + location: src/writeback/linear.ts + verify_tier: 5 + + - id: writeback-linear-verify + name: Linear Writeback Verification + api: MountLinearWriteback().verify() + description: Re-read issue state and comment records to verify expected mutations are visible + location: src/writeback/linear.ts + verify_tier: 5 + + - id: writeback-github-lifecycle + name: GitHub Issue Lifecycle Writeback + api: GhCliGithubWriteback + description: Post comments, apply in-progress or human-review labels, and close GitHub-native issues + location: src/writeback/github.ts + verify_tier: 5 + + - id: writeback-github-pr-publication + name: Workspace GitHub PR Publication + api: RelayfileGithubConnectionWrite.publishPullRequest() + description: Push the current branch ref, create a PR draft command, confirm both writes, and read the resulting receipt + location: src/mount/relayfile-github-connection-write.ts + verify_tier: 6 + + - id: writeback-github-pr-close + name: Workspace GitHub PR Close + api: RelayfileGithubConnectionWrite.closePullRequest() + description: Close a validated PR through the connected workspace without relying on local gh authentication + location: src/mount/relayfile-github-connection-write.ts + verify_tier: 6 + + - id: writeback-slack-thread + name: Slack Thread and Reply Writeback + api: MountSlackWriteback + description: Create issue threads and bounded replies using guarded Slack mount paths and confirmed writes + location: src/writeback/slack.ts + verify_tier: 5 + + fleet-node: + name: Fleet Backends and Factory Node + description: Local broker execution, hosted placement, node capabilities, and workflow running + criticality: hot + features: + - id: fleet-internal-backend + name: Internal Broker Fleet + api: InternalFleetClient + description: Spawn, resume, message, list, and release PTY agents through a local harness-driver broker + location: src/fleet/internal-fleet-client.ts + verify_tier: 4 + + - id: fleet-broker-reuse + name: Existing Broker Reuse + api: ensureRelayBroker() + description: Connect to an existing workspace broker without taking ownership or stopping it on dispose + location: src/fleet/ensure-relay-broker.ts + verify_tier: 4 + + - id: fleet-broker-autostart + name: Internal Broker Auto-Start + api: ensureRelayBroker() + description: Start and own a broker when none is reachable, joining the existing workspace when a key is available + location: src/fleet/ensure-relay-broker.ts + verify_tier: 4 + + - id: fleet-owned-broker-drain + name: Owned Broker Drain + api: InternalFleetClient.dispose() + description: Wait a bounded time for factory-spawned task-exit agents before stopping owned infrastructure + location: src/fleet/internal-fleet-client.ts + verify_tier: 4 + + - id: fleet-relay-backend + name: Hosted Relay Fleet + api: RelayFleetClient + description: Invoke hosted spawn and release actions, place by capability or node, and expose a unified roster + location: src/fleet/relay-fleet-client.ts + verify_tier: 4 + + - id: fleet-relay-repo-placement + name: Repository-Aware Relay Placement + api: AgentSpec.repo / FleetClient.spawn() + description: Forward the source repository on initial and no-session restart spawns so hosted placement selects a node advertising the required checkout and preserves node/repo locality on resume + location: src/ports/fleet.ts, src/fleet/relay-fleet-client.ts, src/orchestrator/factory.ts + verify_tier: 4 + + - id: fleet-relay-reconciliation + name: Hosted Agent Reconciliation + api: RelayFleetClient.reconcileTrackedAgents() + description: Reconcile tracked invocation IDs against hosted action status and roster state to recover missed exits + location: src/fleet/relay-fleet-client.ts + verify_tier: 4 + + - id: fleet-message-events + name: Fleet Messaging Events + api: FleetClient.sendMessage() / onAgentMessage() + description: Send coordination messages and surface delivery failures, direct agent messages, and agent exits + location: src/ports/fleet.ts, src/fleet/internal-fleet-client.ts, src/fleet/relay-fleet-client.ts + verify_tier: 4 + + - id: node-definition + name: Factory Fleet Node Definition + api: createFactoryNodeDefinition() + description: Export a deployable node with configured capabilities, tags, clone metadata, and concurrency + location: src/node/factory-node.ts, src/node/factory.node.ts + verify_tier: 2 + + - id: node-spawn-capabilities + name: Node Claude and Codex Capabilities + api: spawn:claude / spawn:codex + description: Validate action input and spawn PTY harnesses with task, model, channels, session, restart, and lifecycle controls + location: src/node/factory-node.ts + verify_tier: 4 + + - id: node-workflow-capability + name: Node Workflow Capability + api: workflow:run + description: Run YAML, TypeScript, TSX, or Python Relayflows in an authorized checkout with structured inputs + location: src/node/factory-node.ts + verify_tier: 4 + + - id: node-inventory-sync + name: Node Inventory Reconciliation Payload + api: factoryNodeInventorySync() + description: Report workspace, advertised clone paths, and live agent session identities after reconnect + location: src/node/factory-node.ts + verify_tier: 2 + + - id: node-relay-mcp-harness + name: Agent Relay MCP Harness Injection + api: buildRelayMcpHarnessConfig() + description: Attach the Agent Relay MCP server configuration to spawned Claude and Codex harnesses when available + location: src/fleet/internal-fleet-client.ts, src/node/factory-node.ts + verify_tier: 4 + + webhook-subscriptions: + name: Webhooks and Subscription Utilities + description: Programmatic event intake, filtering, coalescing, and delivery target derivation + criticality: standard + features: + - id: webhook-hmac-validation + name: Webhook HMAC Validation + api: WebhookHandler.handle() + description: Verify Relay timestamp-body signatures with timing-safe comparison before event processing + location: src/webhook/handler.ts + verify_tier: 1 + + - id: webhook-event-routing + name: Provider Webhook Routing + api: WebhookHandler.handle() + description: Route Linear, Slack, and GitHub path events to provider-specific handlers with correct HTTP status semantics + location: src/webhook/handler.ts + verify_tier: 1 + + - id: webhook-event-deduplication + name: Webhook Event Deduplication + api: WebhookHandler.handle() + description: Return success without reprocessing an already-seen event ID + location: src/webhook/handler.ts + verify_tier: 1 + + - id: webhook-registration + name: Idempotent Webhook Registration + api: WebhookRegistrar.register() + description: Reuse an existing URL and glob-equivalent subscription or register the default provider paths + location: src/webhook/registrar.ts + verify_tier: 5 + + - id: webhook-unregistration + name: Webhook Unregistration + api: WebhookRegistrar.unregister() + description: Delete the active workspace webhook subscription and clear local registration state + location: src/webhook/registrar.ts + verify_tier: 5 + + - id: subscription-canonical-paths + name: Canonical Integration Paths + api: canonicalMountPaths() / eventPathGlobsForIntegration() + description: Normalize provider mount paths, Slack aliases, direct-message roots, and SDK-compatible globs + location: src/subscriptions/specs.ts, src/subscriptions/globs.ts + verify_tier: 1 + + - id: subscription-delivery-targets + name: Integration Delivery Targets + api: deliveryTargetsFor() + description: Normalize and deduplicate agent and channel listeners from integration scope + location: src/subscriptions/specs.ts + verify_tier: 1 + + - id: subscription-linear-predicates + name: Linear Scope Predicate Filtering + api: filterLinearPredicateSpecs() + description: Filter issue events by configured team, project, label, and assignee predicates + location: src/subscriptions/linear-filter.ts + verify_tier: 1 + + - id: subscription-stream-recovery + name: Workspace Stream Coalescing and Recovery + api: createWorkspaceScopedEventClient() + description: Coalesce changes by path, reject mismatched workspace JWTs, fall back to polling, and allow SDK stream self-healing + location: src/subscriptions/event-client.ts + verify_tier: 5 + + programmatic-api: + name: Public TypeScript API + description: Stable package exports for embedding factory behavior in other applications + criticality: standard + features: + - id: api-factory-lifecycle + name: Factory Lifecycle API + api: createFactory() / FactoryLoop + description: Construct, start, stop, dispose, run, triage, dispatch, inspect, and subscribe to a factory instance + location: src/index.ts, src/types.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: api-factory-events + name: Factory Event API + api: Factory.on() + description: Subscribe to issue-queued, dispatched, issue-done, writeback-verified, and error events + location: src/types.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: api-config-schemas + name: Config Schema API + api: FactoryConfigSchema / WorkspaceConfigSchema / NodeConfigSchema + description: Parse normalized combined or split workspace, node, and factory configuration with Zod + location: src/config/schema.ts + verify_tier: 1 + + - id: api-linear-state-resolution + name: Linear State Resolution API + api: resolveFactoryStates() / stateResolutionFromIds() + description: Resolve global and per-team role names to IDs with ambiguity checks and issue-record fallback + location: src/linear/state-resolver.ts + verify_tier: 2 + + - id: api-triage-engines + name: Pluggable Triage Engines + api: HeuristicTriage / LlmTriage / TieredTriage + description: Embed deterministic, LLM, or tiered triage behind the TriageEngine contract + location: src/triage/ + verify_tier: 1 + + - id: api-fleet-ports + name: Fleet Client API + api: FleetClient / InternalFleetClient / RelayFleetClient + description: Implement or consume a common fleet contract for spawn, resume, release, messaging, roster, and lifecycle events + location: src/ports/fleet.ts, src/fleet/ + verify_tier: 1 + + - id: api-mount-ports + name: Mount Client API + api: MountClient / RelayfileCloudMountClient + description: Implement or consume the file, event, subscription, write-confirmation, and provider-readiness contract + location: src/ports/mount.ts, src/mount/relayfile-cloud-mount-client.ts + verify_tier: 1 + + - id: api-writeback-ports + name: Provider Writeback API + api: LinearWriteback / GithubWriteback / SlackWriteback + description: Inject or consume provider mutation ports independently from orchestration + location: src/ports/writeback.ts, src/writeback/ + verify_tier: 1 + + - id: api-state-stores + name: State Store API + api: InMemoryStateStore / FileStateStore + description: Persist batch, retry, critical delivery, human-loop, handoff, and canonical-state records + location: src/ports/state.ts, src/state/ + verify_tier: 1 + + - id: api-reaper + name: Reaper API + api: FactoryReaper / terminatePids() / reapFactoryOrphansOnce() + description: Embed heartbeat-aware, identity-checked process cleanup with protected-PID handling + location: src/orchestrator/reaper.ts + verify_tier: 1 + + - id: api-relayflow-policy + name: Relayflow Policy Registry API + api: RelayflowPolicyRegistry + description: Register provider-resource-action triggers with input mappers and workflow templates + location: src/dispatch/relayflow-registry.ts + verify_tier: 1 + + - id: api-testing + name: Testing Fakes Export + api: '@agent-relay/factory/testing' + description: Provide fake mount and fleet clients for hermetic consumer tests + location: src/testing/index.ts, package.json + verify_tier: 1 + + - id: api-safe-log-serialization + name: Safe Bounded Log Serialization + api: normalizeLogValue() / normalizeLogger() / stringifyLogValue() + description: Preserve Error diagnostics and structured context as bounded redacted JSON-safe metadata without invoking getters, toJSON hooks, custom stack hooks, or failing on cycles and BigInt + location: src/logging.ts + verify_tier: 1 + + config-intake: + name: Workspace Intake Configuration + description: Factory source, capacity, subscription, and retry controls in factory.config.json + criticality: critical + features: + - id: config-workspace-id + name: Workspace ID + api: factory.config.json#workspaceId + description: Pin a Relayfile workspace handle; when omitted the CLI derives the active cloud workspace + location: src/config/schema.ts, src/mount/relayfile-cloud-mount-client.ts + verify_tier: 2 + + - id: config-issue-source + name: Issue Source + api: factory.config.json#issueSource + description: Select linear or github lifecycle explicitly, or omit it for sub-root-based auto-detection + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-batch-size + name: Batch Size + api: factory.config.json#batchSize + description: Limit concurrent issue records to an integer from one through five + location: src/config/schema.ts, src/orchestrator/batch-tracker.ts + verify_tier: 2 + + - id: config-subscription-teams + name: Subscribed Linear Teams + api: factory.config.json#subscription.teams + description: Scope issue intake and upfront state resolution to selected team identifiers + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-subscription-projects + name: Subscribed Linear Projects + api: factory.config.json#subscription.projects + description: Scope integration subscription predicates to selected projects + location: src/config/schema.ts, src/subscriptions/linear-filter.ts + verify_tier: 2 + + - id: config-subscription-labels + name: Subscribed Issue Labels + api: factory.config.json#subscription.labels + description: Scope intake to selected labels; defaults to repos.names when the explicit list is empty + location: src/config/schema.ts, src/subscriptions/linear-filter.ts + verify_tier: 2 + + - id: config-subscription-assignees + name: Subscribed Assignees + api: factory.config.json#subscription.assignees + description: Scope integration subscription predicates to selected assignee identities + location: src/config/schema.ts, src/subscriptions/linear-filter.ts + verify_tier: 2 + + - id: config-live-transport + name: Live Subscription Transport + api: factory.config.json#liveSubscription.transport + description: Choose subscribe-and-poll, subscribe, or poll for live workspace events + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-live-poll-interval + name: Live Poll Interval + api: factory.config.json#liveSubscription.pollIntervalMs + description: Set the minimum 50 ms delay between live polling passes + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-live-event-limit + name: Live Event Page Limit + api: factory.config.json#liveSubscription.eventLimit + description: Bound each event page from one through one thousand records + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-live-replay-skew + name: Live Replay Skew Margin + api: factory.config.json#liveSubscription.replaySkewMarginMs + description: Allow bounded clock skew while rejecting events older than the live connection cutoff + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-dispatch-cooldown + name: Dispatch Error Cooldown + api: factory.config.json#dispatch.errorCooldownMs + description: Set the non-negative retry backoff after a dispatch failure + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-dispatch-attempts + name: Dispatch Maximum Attempts + api: factory.config.json#dispatch.maxAttempts + description: Limit dispatch attempts to one through five before marking work terminal + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-triage-implementers + name: Maximum Implementers + api: factory.config.json#triage.maxImplementers + description: Bound a team-shaped issue to one through six implementer assignments + location: src/config/schema.ts, src/triage/heuristic.ts + verify_tier: 2 + + config-repositories: + name: Repository Routing Configuration + description: Compact and explicit repository routing plus checkout derivation + criticality: critical + features: + - id: config-repos-org + name: Repository Organization + api: factory.config.json#repos.org + description: Supply the owner used to expand compact repo names and bare repository routes + location: src/config/schema.ts + verify_tier: 2 + + - id: config-repos-names + name: Compact Repository Names + api: factory.config.json#repos.names + description: Derive label routes, default subscription labels, and clone paths from a repository name list + location: src/config/schema.ts + verify_tier: 2 + + - id: config-repos-overrides + name: Compact Route Overrides + api: factory.config.json#repos.overrides + description: Override the owner/repo expansion for individual compact repository names + location: src/config/schema.ts + verify_tier: 2 + + - id: config-repos-by-label + name: Label Route Map + api: factory.config.json#repos.byLabel + description: Map issue labels to repositories; explicit entries override derived compact routes + location: src/config/schema.ts, src/triage/heuristic.ts + verify_tier: 2 + + - id: config-repos-by-project + name: Project Route Map + api: factory.config.json#repos.byProject + description: Map Linear project names to repositories after label routing + location: src/config/schema.ts, src/triage/heuristic.ts + verify_tier: 2 + + - id: config-repos-keyword-rules + name: Keyword Route Rules + api: factory.config.json#repos.keywordRules + description: Provide ordered regex-to-repository routes used after label and project routes + location: src/config/schema.ts, src/triage/heuristic.ts + verify_tier: 2 + + - id: config-repos-default + name: Default Repository + api: factory.config.json#repos.default + description: Set the final routing fallback and the repo used for numeric standalone babysit targets + location: src/config/schema.ts, src/triage/heuristic.ts, src/cli/fleet.ts + verify_tier: 2 + + - id: config-repos-clone-root + name: Legacy Repository Clone Root + api: factory.config.json#repos.cloneRoot + description: Accept the legacy nested clone root and promote it into normalized node and factory config + location: src/config/schema.ts + verify_tier: 2 + + - id: config-repos-clone-paths + name: Legacy Repository Clone Paths + api: factory.config.json#repos.clonePaths + description: Accept legacy nested repo checkout paths, with top-level explicit entries taking precedence + location: src/config/schema.ts + verify_tier: 2 + + - id: config-clone-root + name: Node Clone Root + api: factory.config.json#cloneRoot + description: Derive repository checkout paths and authorize node-local child paths beneath one root + location: src/config/schema.ts, src/node/factory-node.ts + verify_tier: 2 + + - id: config-clone-paths + name: Explicit Clone Paths + api: factory.config.json#clonePaths + description: Map owner/repo keys to local checkouts; explicit values override all derived and legacy paths + location: src/config/schema.ts, src/node/factory-node.ts + verify_tier: 2 + + - id: config-home-clone-expansion + name: Clone Path Home Expansion + api: factory.config.json#cloneRoot / clonePaths + description: Expand exact tilde and leading tilde-slash values across legacy, combined, and split clone configuration while rejecting unsupported user-home forms + location: src/config/schema.ts + verify_tier: 2 + + - id: config-cwd-clone-inference + name: Single-Repository Checkout Inference + api: resolveLocalFactoryConfig() + description: Infer a local checkout root from cwd only for one unambiguous repository whose parsed GitHub remote matches the resolved route + location: src/config/local-clone-paths.ts, src/cli/fleet.ts + verify_tier: 2 + + - id: config-local-checkout-preflight + name: Local Dispatch Checkout Preflight + api: 'resolveLocalFactoryConfig({ validateConfiguredCheckouts: true })' + description: Fail local internal dispatch before spawning when a configured clone path is missing, non-git, or not the checkout root while accepting linked worktree roots and leaving relay or maintenance paths environment-independent + location: src/config/local-clone-paths.ts, src/cli/fleet.ts + verify_tier: 2 + + config-loop: + name: Loop and Recovery Configuration + description: Bounded iteration, heartbeat, and registry controls for production operation + criticality: hot + features: + - id: config-loop-max-iterations + name: Loop Maximum Iterations + api: factory.config.json#loop.maxIterations + description: Bound a factory loop invocation to one through five iterations + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-loop-failure-limit + name: Loop Consecutive Failure Limit + api: factory.config.json#loop.maxConsecutiveFailures + description: Open the loop circuit after one through five consecutive failed iterations + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-loop-heartbeat-path + name: Loop Heartbeat Path + api: factory.config.json#loop.heartbeatPath + description: Select the JSON file used for process liveness, progress, and stopping state + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-loop-registry-path + name: In-Flight Registry Path + api: factory.config.json#loop.registryPath + description: Select the JSON file used for tracked agent and process recovery metadata + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-loop-heartbeat-stale + name: Heartbeat Staleness Threshold + api: factory.config.json#loop.heartbeatStaleMs + description: Set the minimum one-second threshold used by liveness checks and orphan reaping + location: src/config/schema.ts, src/orchestrator/factory.ts, src/orchestrator/reaper.ts + verify_tier: 2 + + - id: config-loop-heartbeat-alias + name: Node Heartbeat Path Alias + api: factory.config.json#factoryLoopHeartbeatPath + description: Override loop.heartbeatPath through the node-oriented compatibility field + location: src/config/schema.ts + verify_tier: 2 + + - id: config-loop-registry-alias + name: Node Registry Path Alias + api: factory.config.json#factoryLoopRegistryPath + description: Override loop.registryPath through the node-oriented compatibility field + location: src/config/schema.ts + verify_tier: 2 + + config-models-and-human: + name: Agent, Merge, and Human-Loop Configuration + description: Per-role model selection, babysitting, terminal policy, and Slack behavior + criticality: hot + features: + - id: config-model-implementer + name: Implementer Model + api: factory.config.json#models.implementer + description: Override the model passed to implementer spawns + location: src/config/schema.ts, src/triage/heuristic.ts + verify_tier: 2 + + - id: config-model-reviewer + name: Reviewer Model + api: factory.config.json#models.reviewer + description: Override the model passed to reviewer spawns + location: src/config/schema.ts, src/triage/heuristic.ts + verify_tier: 2 + + - id: config-model-triage + name: Triage Model + api: factory.config.json#models.triage + description: Reserve a workspace-level model selection for consumers that inject an LLM triage engine + location: src/config/schema.ts + verify_tier: 2 + + - id: config-model-babysitter + name: Babysitter Model + api: factory.config.json#models.babysitter + description: Override the babysitter model; defaults to sonnet + location: src/config/schema.ts, src/triage/heuristic.ts + verify_tier: 2 + + - id: config-babysitter-enabled + name: Automatic Babysitter Enablement + api: factory.config.json#babysitter.enabled + description: Opt into event-driven PR babysitting after issue-driven dispatch + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-merge-policy + name: Merge Policy + api: factory.config.json#mergePolicy + description: Choose never or on-green-with-review for guarded automatic merge behavior + location: src/config/schema.ts, src/github/merge-gate.ts + verify_tier: 2 + + - id: config-terminal-state + name: Terminal State + api: factory.config.json#terminalState + description: Choose done or human-review, with safe fallback when a Linear team lacks Human Review + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-slack-channel + name: Slack Channel + api: factory.config.json#slack.channel + description: Select a Slack name, ID, or mounted directory for dispatch and completion threads + location: src/config/schema.ts, src/writeback/slack.ts + verify_tier: 2 + + - id: config-slack-style + name: Slack Thread Style + api: factory.config.json#slack.style + description: Validate the threaded-summarized writeback style contract + location: src/config/schema.ts + verify_tier: 2 + + - id: config-slack-bot-user + name: Slack Bot User ID + api: factory.config.json#slack.botUserId + description: Identify and ignore the factory's own bot replies in thread watchers + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-slack-staleness + name: Slack Staleness Threshold + api: factory.config.json#slack.staleAfterMs + description: Set the provider-sync freshness threshold that activates Slack degraded mode + location: src/config/schema.ts, src/orchestrator/factory.ts + verify_tier: 2 + + config-linear-states: + name: Linear State Configuration + description: Global, per-team, and pinned UUID mappings for every factory lifecycle role + criticality: critical + features: + - id: config-linear-ready-name + name: Ready for Agent State Name + api: factory.config.json#linear.states.readyForAgent + description: Map the factory readyForAgent role to the workspace-wide Linear state name + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-linear-implementing-name + name: Agent Implementing State Name + api: factory.config.json#linear.states.agentImplementing + description: Map the factory agentImplementing role to the workspace-wide Linear state name + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-linear-planning-name + name: In Planning State Name + api: factory.config.json#linear.states.inPlanning + description: Map the factory inPlanning role to the workspace-wide Linear state name + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-linear-done-name + name: Done State Name + api: factory.config.json#linear.states.done + description: Map the factory done role to the workspace-wide Linear state name + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-linear-human-review-name + name: Human Review State Name + api: factory.config.json#linear.states.humanReview + description: Map the optional humanReview role to the workspace-wide Linear state name + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-linear-states-by-team + name: Per-Team State Names + api: factory.config.json#linear.statesByTeam + description: Override any lifecycle role name for teams whose workflows use different names + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-linear-team-ids + name: Linear Team ID Map + api: factory.config.json#linear.teamIds + description: Preserve configured team-key to team-ID metadata for workspace consumers + location: src/config/schema.ts + verify_tier: 2 + + - id: config-state-id-ready + name: Ready for Agent State UUID + api: factory.config.json#stateIds.readyForAgent + description: Pin a fallback UUID for the readyForAgent role when name resolution is unavailable + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-state-id-implementing + name: Agent Implementing State UUID + api: factory.config.json#stateIds.agentImplementing + description: Pin a fallback UUID for the agentImplementing role when name resolution is unavailable + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-state-id-planning + name: In Planning State UUID + api: factory.config.json#stateIds.inPlanning + description: Pin a fallback UUID for the inPlanning role when name resolution is unavailable + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-state-id-done + name: Done State UUID + api: factory.config.json#stateIds.done + description: Pin a fallback UUID for the done role when name resolution is unavailable + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + - id: config-state-id-human-review + name: Human Review State UUID + api: factory.config.json#stateIds.humanReview + description: Pin a fallback UUID for the optional humanReview role when name resolution is unavailable + location: src/config/schema.ts, src/linear/state-resolver.ts + verify_tier: 2 + + config-safety: + name: Safety Configuration + description: Explicit factory opt-in boundaries for titles, labels, and teams + criticality: critical + features: + - id: config-safety-title-prefix + name: Required Title Prefix + api: factory.config.json#safety.requireTitlePrefix + description: Configure the exact boundary-aware title marker accepted for Linear work + location: src/config/schema.ts, src/safety/factory-scope.ts + verify_tier: 2 + + - id: config-safety-label + name: Required Factory Label + api: factory.config.json#safety.requireLabel + description: Configure the case-insensitive opt-in label used for Linear and GitHub issue intake + location: src/config/schema.ts, src/safety/factory-scope.ts, src/orchestrator/factory.ts + verify_tier: 2 + + - id: config-safety-team + name: Required Team Key + api: factory.config.json#safety.requireTeamKey + description: Configure the Linear team boundary enforced before dispatch and writeback + location: src/config/schema.ts, src/safety/factory-scope.ts + verify_tier: 2 + + config-node-and-loading: + name: Node and Config Loading + description: Node advertisement, local defaults, split config envelopes, and hermetic fixture mode + criticality: hot + features: + - id: config-node-capabilities + name: Node Capabilities + api: factory.node.json#capabilities + description: Advertise any supported subset of spawn:claude, spawn:codex, and workflow:run; reject empty or unknown-only sets + location: src/config/schema.ts, src/node/factory-node.ts + verify_tier: 2 + + - id: config-node-dry-run + name: Node Dry-Run Metadata + api: factory.node.json#dryRun + description: Advertise node dry-run intent in factory capability metadata + location: src/config/schema.ts, src/node/factory-node.ts + verify_tier: 2 + + - id: config-node-path-env + name: Node Config Path Environment + api: AGENT_RELAY_FACTORY_NODE_CONFIG / FACTORY_NODE_CONFIG / FACTORY_CONFIG + description: Resolve factory.node.json through ordered environment overrides before the default path + location: src/node/factory-node.ts + verify_tier: 1 + + - id: config-node-name-env + name: Node Name Environment + api: AGENT_RELAY_NODE_NAME / FACTORY_NODE_NAME + description: Override the default hostname-derived factory node name + location: src/node/factory-node.ts + verify_tier: 1 + + - id: config-combined-envelope + name: Combined Factory Config Envelope + api: factory.config.json#factoryConfig + description: Accept a factoryConfig wrapper around the combined workspace and node schema + location: src/config/schema.ts, src/cli/fleet.ts + verify_tier: 2 + + - id: config-split-envelope + name: Split Workspace and Node Config + api: factory.config.json#workspaceConfig + nodeConfig + description: Combine required workspaceConfig and nodeConfig objects while rejecting mismatched workspace IDs + location: src/config/schema.ts + verify_tier: 2 + + - id: config-fixture-files + name: Hermetic Fixture Files + api: factory.config.json#fixtureFiles + description: Replace cloud mount and internal fleet construction with fakes for deterministic CLI verification + location: src/cli/fleet.ts, src/testing/fakes.ts + verify_tier: 2 diff --git a/.agentworkforce/features/verify/procedures.md b/.agentworkforce/features/verify/procedures.md new file mode 100644 index 0000000..bda35ec --- /dev/null +++ b/.agentworkforce/features/verify/procedures.md @@ -0,0 +1,442 @@ +# Feature Verification Procedures + +Verify Factory from the user's point of view, lowest prerequisite tier first. A higher tier does not replace the lower tiers: it adds provider, fleet, cloud, or live-work prerequisites. + +Use a disposable workspace, issue, repository branch, and Slack channel for tiers 3–6. Never point mutation checks at production work unless the operator explicitly selected those records. + +--- + +## Tier 1 β€” Package Only + +Requires only a source checkout with dependencies installed. These checks are deterministic and must run on every change. + +### Build, test, entrypoints, and manifest + +```bash +npm run build +npm test + +node bin/factory.mjs --help +# β†’ exits 0 and lists every top-level command and global option + +node --input-type=module <<'NODE' +import { existsSync, readFileSync } from 'node:fs' +import { parse } from 'yaml' + +const manifest = parse(readFileSync('.agentworkforce/features/manifest.yaml', 'utf8')) +const categories = Object.values(manifest.categories) +const features = categories.flatMap((category) => category.features) +const ids = features.map((feature) => feature.id) +if (new Set(ids).size !== ids.length) throw new Error('duplicate feature IDs') +const tierCounts = Object.fromEntries([1, 2, 3, 4, 5, 6].map((tier) => [ + tier, + features.filter((feature) => feature.verify_tier === tier).length, +])) +if (manifest.catalog.category_count !== categories.length || + manifest.catalog.feature_count !== features.length || + JSON.stringify(manifest.catalog.tier_counts) !== JSON.stringify(tierCounts)) { + throw new Error('catalog summary does not match manifest contents') +} +for (const feature of features) { + if (!feature.id || !feature.name || (!feature.cli && !feature.api) || + !feature.description || !feature.location || + !Number.isInteger(feature.verify_tier) || feature.verify_tier < 1 || feature.verify_tier > 6) { + throw new Error(`invalid feature: ${JSON.stringify(feature)}`) + } + for (const location of feature.location.split(',').map((value) => value.trim())) { + if (!existsSync(location)) throw new Error(`missing location for ${feature.id}: ${location}`) + } +} +console.log(`${features.length} features in ${categories.length} categories`) +NODE +``` + +Pass criteria: build and all tests exit 0; both published entrypoints remain importable; the manifest parses with unique, complete entries. + +### Focused Tier 1 checks + +```bash +npx vitest run \ + src/__tests__/dist-entrypoints.test.ts \ + src/config/schema.test.ts \ + src/config/local-clone-paths.test.ts \ + src/github/merge-gate.test.ts \ + src/logging.test.ts \ + src/webhook/handler.test.ts \ + src/webhook/registrar.test.ts \ + src/subscriptions/__tests__/globs.test.ts \ + src/subscriptions/__tests__/linear-filter.test.ts \ + src/subscriptions/__tests__/specs.test.ts \ + src/subscriptions/__tests__/event-client.test.ts +``` + +Confirm specifically: + +- invalid webhook signatures return 403, malformed JSON returns 400, duplicate event IDs return 200 without a second callback, and Linear/Slack/GitHub paths reach the matching handler; +- the merge gate refuses missing fields, unknown/dirty/unapproved state, no checks, blocking checks, and moved heads; +- `Error` diagnostics survive as bounded, redacted JSON-safe data while cycles, BigInt, getters, `toJSON`, and custom stack hooks cannot crash or execute through the logger; +- subscription path aliases, Slack DM globs, targets, predicates, event conversion, coalescing, token workspace checks, polling fallback, and stream self-recovery remain deterministic; +- public ESM exports for the root, `/node`, `/writeback`, and `/testing` load from `dist`. + +--- + +## Tier 2 β€” Valid Config or Fixture Config + +Use the checked-in fixture for hermetic orchestration. Add the canonical Linear URL to a temporary copyβ€”the source requires it as part of a real provider identity, while the current fixture omits it. This uses fake mount and fleet records, so no provider credentials or broker are used. + +### Fixture-backed CLI cycle + +```bash +npm run build + +node --input-type=module <<'NODE' +import { readFileSync, writeFileSync } from 'node:fs' +const config = JSON.parse(readFileSync('test/fixtures/factory.config.json', 'utf8')) +config.fixtureFiles['/linear/issues/AR-77__uuid-77.json'].payload.url = + 'https://linear.app/agent-relay/issue/AR-77/cli-dry-run' +writeFileSync('/tmp/factory-tier2-config.json', JSON.stringify(config, null, 2)) +NODE + +node bin/factory.mjs run-once \ + --config /tmp/factory-tier2-config.json \ + --dry-run > /tmp/factory-tier2-run-once.json + +node --input-type=module <<'NODE' +import { readFileSync } from 'node:fs' +const report = JSON.parse(readFileSync('/tmp/factory-tier2-run-once.json', 'utf8')) +if (!report.dryRun) throw new Error('expected dryRun=true') +if (!report.pulled.some((issue) => issue.key === 'AR-77')) throw new Error('AR-77 not discovered') +const decision = report.triaged.find((entry) => entry.issue.key === 'AR-77') +if (!decision) throw new Error('AR-77 not triaged') +if (decision.routes[0]?.repo !== 'AgentWorkforce/pear') throw new Error('wrong route') +if (!report.dispatched.some((entry) => entry.issue.key === 'AR-77')) throw new Error('AR-77 not dry-run dispatched') +console.log('fixture cycle passed') +NODE + +node bin/factory.mjs status --config /tmp/factory-tier2-config.json +# β†’ JSON with inFlight, queued, counters, and slackDegraded +``` + +Pass criteria: the fixture issue is discovered, scoped, routed, and represented as a dry-run dispatch with no real writes or spawns. + +### Config matrix + +Run the schema and domain suites: + +```bash +npx vitest run \ + src/config/schema.test.ts \ + src/config/local-clone-paths.test.ts \ + src/cli/fleet.test.ts \ + src/safety/factory-scope.test.ts \ + src/triage/triage.test.ts \ + src/dispatch/templates.test.ts \ + src/dispatch/relayflow-registry.test.ts \ + src/linear/state-resolver.test.ts \ + src/orchestrator/factory.test.ts \ + src/orchestrator/reaper.test.ts \ + src/state/file-state-store.test.ts \ + src/node/factory-node.test.ts +``` + +The config suite must prove every field listed in the manifest, including defaults, min/max validation, derived repo maps and paths, split/combined envelopes, state mapping precedence, and workspace mismatch rejection. It must also prove exact `~`/leading `~/` expansion, unsupported `~user` rejection, one-repo cwd inference only after a matching GitHub remote, linked-worktree acceptance, invalid checkout preflight failure, and no local environment inspection for relay or maintenance commands. The safety suite must prove boundary-aware titles, label forms, team gating, GitHub mirror markers, and fail-closed writeback. + +The triage/orchestrator suite must preserve byte-compatible Linear names while repo-qualifying every GitHub role and isolating equal-number GitHub issues by composite repo/number identity across dispatch, registry, resume, PR, publication, retry, and babysitter state. + +### Loop and recovery files + +Use a temporary config that overrides `loop.heartbeatPath` and `loop.registryPath` under `/tmp/factory-feature-verify/`. Run a one-iteration fixture loop and then: + +```bash +node bin/factory.mjs loop --config /tmp/factory-feature-verify/config.json --dry-run +node bin/factory.mjs loop-status --config /tmp/factory-feature-verify/config.json +# β†’ heartbeat exists, reports idle after the bounded loop, and is not malformed +``` + +Do not run `kill-loop` at this tier; process signaling is tier 6. + +--- + +## Tier 3 β€” Reachable Ticket Provider + +Requires read credentials for the selected issue source and a known disposable issue. Use `--dry-run` throughout this tier. + +### Linear source + +Prepare one real issue that: + +- is in the configured Ready for Agent state; +- has the configured team and title prefix or label; +- has one valid repo route label; +- contains a substantial description with acceptance signals. + +```bash +factory triage AR-VERIFY --config ./factory.config.json +# β†’ route source is label, confidence is high, expected shape and agents are present + +factory canary AR-VERIFY --config ./factory.config.json +# β†’ { "ok": true, "issue": "AR-VERIFY", "status": "triaged"|"dispatched" } +``` + +Repeat with a sparse synced record if the provider exposes one. Pass only if canonical fallback or state-name backfill recovers the real state. + +### GitHub-native source + +Set `issueSource: "github"`, create an open disposable issue with `safety.requireLabel` plus a configured repo route label, and run: + +```bash +factory triage --config ./factory-github.config.json +factory canary --config ./factory-github.config.json +factory run-once --config ./factory-github.config.json --dry-run +``` + +Repeat against compact `owner__repo` and nested `owner/repo` mounts, covering `meta.json`, `by-id`, flat issue records, shallow listings, and paginated listings. Prove `repos.default` disambiguates a bare positive number, multiple configured matches reject as ambiguous, non-positive numbers reject with source context, provider read failures are not hidden by fallback scans, and repository-only config selects GitHub while explicit/legacy Linear config remains Linear. + +Pass criteria: the labeled open issue appears once and routes correctly; an unlabeled or closed neighbor does not dispatch; alternate paths for the same repository deduplicate without merging different repositories. No provider mutation occurs. + +--- + +## Tier 4 β€” Fleet Backend Available + +Requires either a reachable internal relay broker or hosted Relay fleet credentials and at least one capable node. + +### Internal broker lifecycle + +```bash +factory fleet roster --backend internal +# β†’ reuses the running broker, or starts one if none is reachable + +NAME="factory-vf-codex-$(date +%s)" +factory fleet spawn spawn:codex --backend internal \ + --name "$NAME" --task "Reply with exactly FACTORY_VERIFY_OK" +factory fleet roster --backend internal +# β†’ roster contains $NAME +factory fleet release "$NAME" --reason feature-verification --backend internal +# β†’ { "released": "$NAME" } +``` + +Repeat with `spawn:claude` when that harness is installed. Verify a resumed session with `--resume ` when the spawn result supplies one. + +### Hosted Relay fleet + +With `RELAY_WORKSPACE_KEY` set and an enrolled node online: + +```bash +factory fleet roster --backend relay +factory fleet spawn spawn:codex --backend relay \ + --name "factory-vf-remote-$(date +%s)" \ + --node \ + --cwd \ + --task "Reply with exactly FACTORY_VERIFY_REMOTE_OK" +``` + +Pass criteria: capability placement selects the requested live node; an unadvertised checkout is refused; the invocation reaches a terminal result; roster reconciliation emits one exit; rehydrating the saved invocation after a client restart does not duplicate it. + +For a normal multi-repository dispatch and a no-session restart, inspect the placement request and prove `AgentSpec.repo` is present both times. Resume must retain the placed node, repo, and clone path; a remote node PID must never be signaled as a local process. + +### Dispatch messaging and recovery + +Against a disposable fixture/provider issue, verify: + +1. implementer(s) and reviewer/workflow spawn once; +2. task delivery receives `delivery_injected` acknowledgement before input submission; +3. a simulated delivery failure retries the persisted critical message; +4. an interrupted session resumes once; +5. batch overflow queues and completion promotes the next issue; +6. registry data contains agent/session/PID or remote invocation/node identity; +7. `factory reap-orphans` never terminates broker/node/protected PIDs. + +For the durable relay lifecycle, additionally prove: + +1. lease epochs fence a second same-host owner before duplicate spawn or publication; +2. separate `FileStateStore` instances enforce one global batch slot, including clarification parking; +3. restart adopts a roster-visible spawn across the spawn-ack persistence gap; +4. running, queued, publishing, parking, releasing, and waiting-for-human phases resume autonomously; +5. transient publication/release failures retry, a provider receipt is reused after lease loss, and capacity is released at the documented checkpoint; +6. cross-host active/active control-plane ownership is not claimed as supportedβ€”this check requires one shared same-host file store. + +For clarification, reserve the first concurrent question, release the complete team, confirm fleet absence before committing the parked state, and wake once from the first authorized reply. Exercise saved-session resume, cold-start context, wake-lease renewal, reply-before-restart recovery, scope-loss cancellation, and retry of a failed seven-day escalation. + +For babysitters, persist and restore exact repo/PR/session ownership, critical state, and pending wake categories. Coalesce repeated wake kinds, retain an event arriving during confirmed delivery, retry failure without duplication, persist the critical fence before ACK, defer PTY submit through the critical section, and cancel pending work after a terminal PR readback. + +Use the focused suites when live failure injection is impractical: + +```bash +npx vitest run \ + src/fleet/ensure-relay-broker.test.ts \ + src/fleet/internal-fleet-client.test.ts \ + src/fleet/relay-fleet-client.test.ts \ + src/fleet/create-fleet.test.ts \ + src/node/factory-node.test.ts \ + src/orchestrator/factory.test.ts \ + src/orchestrator/reaper.test.ts +``` + +--- + +## Tier 5 β€” Cloud Auth and Writable Relayfile Mount + +Requires a non-interactive cloud session, readable Linear/GitHub/Slack integrations as applicable, and write permission for a disposable scope. + +### Mount preflight and event intake + +```bash +factory run-once --config ./factory.config.json --dry-run +# β†’ resolves active workspace when workspaceId is omitted +# β†’ starts or refreshes .integrations mount +# β†’ reads provider roots and current high-water mark +``` + +Inspect `.integrations/.relay/state.json`: workspace ID must match the resolved handle or UUID alias, `lastReconcileAt` must be fresh, and a recorded PID must be live when present. Simulate a stale timestamp in a disposable mount and confirm `ensureLocalMount()` refreshes it or prints an actionable recovery warning. + +### Live subscription modes + +Exercise `subscribe`, `poll`, and `subscribe-and-poll` with a disposable issue update: + +1. start the daemon before changing the issue; +2. update the issue once; +3. observe exactly one intake/dispatch attempt; +4. replay the same event ID and confirm suppression; +5. restart from a high-water fallback and confirm buffered events drain after the full pull; +6. confirm the heartbeat stays fresh during slow remote reads. + +Canonical PR routing must accept only consistent normalized repo/PR identity for review, review-comment, issue-comment, failed/cancelled/timed-out check, conflict, and base-divergence records. Pending/green checks and inconsistent or body-only identity must not wake. Route paginated poll events through the same coalescer and re-read mounted PR metadata before delivery/cancellation. + +### Provider writebacks + +On a scoped disposable issue, verify each independently: + +- Linear comment, state update, create-mirror, and verification reads return provider acknowledgement; +- GitHub-native comment and lifecycle labels reach the issue; +- Slack root and reply drafts reach the configured channel and bot replies do not feed back into Factory; +- an out-of-scope Linear draft, unsupported GitHub path, or unapproved delete is rejected; +- webhook register is idempotent and unregister removes the selected subscription. + +For a relay implementer that exits after pushing, publish from its remote head without consulting an orchestrator-local clone. Confirm mounted metadata matches the exact repo, PR, expected head, open state, and non-draft state; then simulate owner loss after provider acknowledgement and prove the receipt is reused rather than creating a second PR. Restart must finish writeback and release cleanup. + +### Human clarification + +Trigger one thin issue and one running-agent question. Confirm the Slack thread persists, a non-bot reply is delivered once, the clarified issue re-triages or the answer reaches the right worker, and restart re-arms the watcher. Without Slack, confirm a correlated GitHub question is posted and unauthorized/bot replies are ignored. + +Pass criteria: provider-visible state matches the write, every mutation is scoped and acknowledged, event replay is idempotent, and core execution continues when Slack becomes degraded. + +--- + +## Tier 6 β€” Manual Live Issue or Pull Request + +These checks mutate a real disposable issue/PR or signal a process and require an operator to inspect the external systems. + +### Full issue-to-PR path + +Run the first five critical paths from `../critical-paths.md` with a disposable issue and repository: + +```text +discover β†’ triage β†’ dispatch β†’ implement β†’ PR β†’ review/babysit + β†’ Human Review β†’ merge β†’ Done/closed +``` + +Verify the PR contains the issue key, commits, tests, and review activity; no draft PR counts as complete; no issue closes before an observed merge. + +### Automatic babysitter + +Enable `babysitter.enabled`, open a non-draft PR through the issue-driven path, and confirm only one babysitter starts. Make CI fail or add a review request, confirm only the exact repo/PR owner wakes, then enter the documented destructive critical section and prove Factory ACKs only after the no-submit fence persists. Confirm coalesced activity is delivered once after exit, the babysitter fixes the current PR branch, then send/observe its readiness signal. A mismatched signal or draft/closed PR must not advance the issue. + +Do not attempt a cross-host active/active control-plane test: the supported ownership topology is multiple Factory processes sharing one same-host `FileStateStore`. Remote relay execution nodes are supported and do not need that directory. + +### Standalone babysitter + +```bash +factory babysit https://github.com///pull/ \ + --config ./factory.config.json +``` + +Pass criteria: the command rejects a draft, closed, merged, incomplete, or cross-repository PR; uses one explicit linked issue when available; otherwise uses PR title/body; prints a spawned receipt; leaves final merge to a human. + +### Guarded merge and close + +For a disposable PR, prove each refusal (dirty, unapproved, missing checks, failing/pending check, moved head), then make it clean, approved, and green. With `mergePolicy: on-green-with-review` and `terminalState: done`, verify the guarded squash merge uses the checked head SHA and the issue advances only after merge observation. + +For a synthetic probe: + +```bash +factory close-probe --repo --issue \ + --config ./factory.config.json +``` + +Pass criteria: wrong key/title marker refuses closure; the correct probe closes and reports live CLOSED state. + +### Loop process controls + +Start a disposable live daemon, read its heartbeat, run `factory kill-loop`, and confirm SIGTERM reaches only that PID. After simulating a stale heartbeat/registry, run `factory reap-orphans` and inspect the report before accepting the result. + +--- + +## Verification by Change Area + +| Change area | Minimum tiers | +| --- | --- | +| CLI parser or entrypoint (`src/cli/`, `bin/`) | 1, 2, plus the command's tier | +| Config or triage (`src/config/`, `src/triage/`) | 1, 2, 3 | +| Logging normalization (`src/logging.ts`) | 1, 2 | +| Core orchestrator (`src/orchestrator/factory.ts`) | 1–5; tier 6 for PR/completion changes | +| Safety or guarded write paths | 1, 2, 5, 6 | +| Linear/GitHub/Slack writeback | 1, 2, 5 | +| Merge gate, babysitter, probe closer | 1, 2, 5, 6 | +| Internal/hosted fleet clients | 1, 2, 4 | +| Node capability or checkout resolution | 1, 2, 4 | +| Mount/event/subscription code | 1, 2, 5 | +| State/reaper/process identity | 1, 2, 4 | +| Durable dispatch/babysitter state | 1, 2, 4, 5; tier 6 for destructive-fence behavior | +| Public exports | 1 | + +--- + +## Manifest Coverage Index + +Every feature below maps to the procedure for its manifest tier. This index is intentionally explicit so a manifest/procedure drift check can prove that no feature lacks a verification route. + +### Tier 1 IDs + +`cli-help`, `cli-config-option`, `safety-relay-token-types`, `safety-merge-head-sha`, `webhook-hmac-validation`, `webhook-event-routing`, `webhook-event-deduplication`, `subscription-canonical-paths`, `subscription-delivery-targets`, `subscription-linear-predicates`, `api-config-schemas`, `api-triage-engines`, `api-fleet-ports`, `api-mount-ports`, `api-writeback-ports`, `api-state-stores`, `api-reaper`, `api-relayflow-policy`, `api-testing`, `api-safe-log-serialization`, `config-node-path-env`, `config-node-name-env`. + +### Tier 2 IDs + +`cli-run-once`, `cli-status`, `cli-loop`, `cli-loop-status`, `cli-dry-run-option`, `triage-label-routing`, `triage-project-routing`, `triage-keyword-routing`, `triage-default-routing`, `triage-unroutable-escalation`, `triage-single-scope`, `triage-team-scope`, `triage-workflow-scope`, `triage-shape-labels`, `triage-surface-inference`, `triage-thin-issue-detection`, `triage-llm-fallback`, `triage-tiered-fail-safe`, `triage-decision-normalization`, `triage-repo-qualified-agent-identities`, `dispatch-batch-admission`, `dispatch-duplicate-suppression`, `dispatch-composite-issue-identity`, `dispatch-retry-backoff`, `dispatch-retry-limit`, `dispatch-agent-task-rendering`, `dispatch-inflight-registry`, `pr-never-auto-merge`, `safety-title-prefix`, `safety-label-gate`, `safety-team-gate`, `safety-babysit-pr-identity`, `safety-node-checkout-containment`, `node-definition`, `node-inventory-sync`, `api-factory-lifecycle`, `api-factory-events`, `api-linear-state-resolution`, `config-workspace-id`, `config-issue-source`, `config-batch-size`, `config-subscription-teams`, `config-subscription-projects`, `config-subscription-labels`, `config-subscription-assignees`, `config-live-transport`, `config-live-poll-interval`, `config-live-event-limit`, `config-live-replay-skew`, `config-dispatch-cooldown`, `config-dispatch-attempts`, `config-triage-implementers`, `config-repos-org`, `config-repos-names`, `config-repos-overrides`, `config-repos-by-label`, `config-repos-by-project`, `config-repos-keyword-rules`, `config-repos-default`, `config-repos-clone-root`, `config-repos-clone-paths`, `config-clone-root`, `config-clone-paths`, `config-home-clone-expansion`, `config-cwd-clone-inference`, `config-local-checkout-preflight`, `config-loop-max-iterations`, `config-loop-failure-limit`, `config-loop-heartbeat-path`, `config-loop-registry-path`, `config-loop-heartbeat-stale`, `config-loop-heartbeat-alias`, `config-loop-registry-alias`, `config-model-implementer`, `config-model-reviewer`, `config-model-triage`, `config-model-babysitter`, `config-babysitter-enabled`, `config-merge-policy`, `config-terminal-state`, `config-slack-channel`, `config-slack-style`, `config-slack-bot-user`, `config-slack-staleness`, `config-linear-ready-name`, `config-linear-implementing-name`, `config-linear-planning-name`, `config-linear-done-name`, `config-linear-human-review-name`, `config-linear-states-by-team`, `config-linear-team-ids`, `config-state-id-ready`, `config-state-id-implementing`, `config-state-id-planning`, `config-state-id-done`, `config-state-id-human-review`, `config-safety-title-prefix`, `config-safety-label`, `config-safety-team`, `config-node-capabilities`, `config-node-dry-run`, `config-combined-envelope`, `config-split-envelope`, `config-fixture-files`. + +### Tier 3 IDs + +`cli-canary`, `cli-triage`, `cli-github-numeric-issue`, `discovery-linear-ready`, `discovery-github-native`, `discovery-github-path-shapes`, `discovery-canonical-fallback`, `discovery-state-name-backfill`, `dispatch-label-authority`, `safety-github-open-label`. + +### Tier 4 IDs + +`cli-start-live`, `cli-reap-orphans`, `cli-dispatch`, `cli-backend-option`, `cli-agent-exit-timeout-option`, `fleet-spawn-codex`, `fleet-spawn-claude`, `fleet-spawn-workflow`, `fleet-resume`, `fleet-target-node`, `fleet-spawn-inputs`, `fleet-roster`, `fleet-release`, `dispatch-queue-promotion`, `dispatch-implementers`, `dispatch-reviewer`, `dispatch-workflow-agent`, `dispatch-confirmed-task-injection`, `dispatch-critical-delivery-retry`, `dispatch-agent-resume`, `dispatch-remote-re-adoption`, `dispatch-durable-relay-lifecycle`, `dispatch-same-host-owner-fencing`, `human-agent-questions`, `human-team-release-for-clarification`, `human-durable-clarification-wake`, `human-answer-injection`, `pr-durable-babysitter-sessions`, `pr-babysitter-wake-coalescing`, `pr-babysitter-critical-ack-fence`, `fleet-internal-backend`, `fleet-broker-reuse`, `fleet-broker-autostart`, `fleet-owned-broker-drain`, `fleet-relay-backend`, `fleet-relay-repo-placement`, `fleet-relay-reconciliation`, `fleet-message-events`, `node-spawn-capabilities`, `node-workflow-capability`, `node-relay-mcp-harness`. + +### Tier 5 IDs + +`discovery-github-linear-mirror`, `discovery-github-mirror-close`, `discovery-source-auto-select`, `discovery-startup-backfill`, `discovery-live-subscribe`, `discovery-live-poll`, `discovery-live-hybrid`, `discovery-replay-suppression`, `discovery-duplicate-suppression`, `discovery-high-water-fallback`, `discovery-relayflow-events`, `dispatch-exit-pr-publication`, `dispatch-remote-publication-recovery`, `dispatch-state-writeback`, `human-slack-dispatch-thread`, `human-triage-escalation`, `human-github-question-fallback`, `human-slack-reply-watch`, `human-clarification-escalation-retry`, `human-clarified-redispatch`, `human-slack-degraded-mode`, `pr-completion-detection`, `pr-draft-block`, `pr-canonical-event-routing`, `pr-polling-readback-backstop`, `pr-human-review-terminal`, `pr-done-terminal`, `pr-post-merge-advance`, `safety-guarded-linear-drafts`, `safety-guarded-github-drafts`, `safety-delete-fail-closed`, `mount-active-workspace`, `mount-cloud-session`, `mount-cloud-filesystem`, `mount-write-confirmation`, `mount-local-auto-start`, `mount-stale-self-heal`, `mount-clone-paths`, `writeback-linear-state`, `writeback-linear-comment`, `writeback-linear-create`, `writeback-linear-verify`, `writeback-github-lifecycle`, `writeback-slack-thread`, `webhook-registration`, `webhook-unregistration`, `subscription-stream-recovery`. + +### Tier 6 IDs + +`cli-kill-loop`, `cli-babysit`, `cli-close-probe`, `human-authorized-github-replies`, `pr-babysitter-opt-in`, `pr-standalone-babysitter-validation`, `pr-babysitter-ready-signal`, `pr-merge-gate-check`, `pr-guarded-merge`, `pr-on-green-merge-policy`, `pr-synthetic-probe-cleanup`, `safety-probe-close-identity`, `writeback-github-pr-publication`, `writeback-github-pr-close`. + +--- + +## Quick Sanity Check + +```bash +set -euo pipefail +npm run build +npm test +node bin/factory.mjs --help >/dev/null +node --input-type=module <<'NODE' +import { readFileSync, writeFileSync } from 'node:fs' +const config = JSON.parse(readFileSync('test/fixtures/factory.config.json', 'utf8')) +config.fixtureFiles['/linear/issues/AR-77__uuid-77.json'].payload.url = + 'https://linear.app/agent-relay/issue/AR-77/cli-dry-run' +writeFileSync('/tmp/factory-tier2-config.json', JSON.stringify(config, null, 2)) +NODE +node bin/factory.mjs run-once --config /tmp/factory-tier2-config.json --dry-run \ + | node --input-type=module -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{const r=JSON.parse(s);if(!r.dryRun||r.triaged.length<1)process.exit(1)})" +``` + +This is the default post-change gate. It is safe, deterministic, and requires no provider or broker. diff --git a/.gitignore b/.gitignore index 36117f8..889e35a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,11 @@ dist/ !.workflow-artifacts/factory-p7-label-scope/ !.workflow-artifacts/factory-p10-fleet-client/ !.workflow-artifacts/factory-p13-factory-node-definition/ + +# Local Agent Relay broker state and credentials +.agentworkforce/relay/ + +# Local Relayflow execution reports and guardian trajectories +.agent-relay/step-outputs/ +.agent-relay/workflow-runs.jsonl +.agentworkforce/trajectories/ diff --git a/workflows/verify-features.ts b/workflows/verify-features.ts new file mode 100644 index 0000000..2bbbb4f --- /dev/null +++ b/workflows/verify-features.ts @@ -0,0 +1,634 @@ +/** + * Automated feature verification for @agent-relay/factory. + * + * Factory's higher tiers mutate real ticket, fleet, and provider state, so this + * workflow always runs deterministic package/config checks and makes tiers 3–5 + * explicit opt-ins. Tier 6 remains manual and is reported as such. + * + * Run locally: + * relay node workflow run workflows/verify-features.ts + * + * Schedule after merge: + * relay cloud schedule workflows/verify-features.ts --cron "0 3 * * *" + * + * Optional live checks: + * FACTORY_VERIFY_CANARY_ISSUE=AR-123 + * FACTORY_VERIFY_CONFIG=/path/to/factory.config.json + * FACTORY_VERIFY_FLEET=1 + * FACTORY_VERIFY_BACKEND=internal|relay + * FACTORY_VERIFY_CLOUD=1 + */ +import { workflow } from '@relayflows/core' + +const ARTIFACTS = '.workflow-artifacts/verify-features' +const TIMESTAMP = new Date().toISOString().replace(/[:.]/gu, '-').slice(0, 19) +const RUN_ID = `factory-verify-${TIMESTAMP}` + +async function main(): Promise { + const wf = workflow('factory-verify-features') + .description( + 'Verifies the Factory manifest, package, tests, fixture cycle, and optional provider/fleet/cloud tiers. ' + + 'Writes a deterministic PASS/FAIL/SKIP/MANUAL report.', + ) + .pattern('pipeline') + .channel('factory-health') + .maxConcurrency(1) + .timeout(900_000) + + wf.step('acceptance-contract', { + type: 'deterministic', + captureOutput: true, + failOnError: false, + command: ` +set -euo pipefail +mkdir -p "${ARTIFACTS}" +cat > "${ARTIFACTS}/acceptance-contract.txt" <<'EOF' +FACTORY FEATURE VERIFICATION ACCEPTANCE CONTRACT + +T1 Package only: manifest schema/coverage, public entrypoint, build +T2 Valid config: complete unit suite and fixture-backed CLI cycle +T3 Ticket provider: optional live sync-fidelity canary +T4 Fleet backend: optional internal/hosted roster health +T5 Cloud mount: optional provider-backed dry-run discovery +T6 Live issue/PR: manual critical-path verification + +Final-main regression contracts exercised by T1/T2: +- #77 durable clarification release, parking, wake, restart, and escalation retry +- #78 clone path expansion, cwd inference, and local checkout preflight +- #81 numeric GitHub issue resolution, source selection, and supported path shapes +- #82 repo-qualified roles and composite repo/issue state +- #83 bounded Error/log normalization +- #84 repo-aware relay placement on initial and restart paths +- #85 durable relay lifecycle, same-host fencing, remote publication/release/recovery +- #87 canonical PR routing, durable/coalesced babysitter wakes, ACK fence, poll/readback + +Automated overall PASS requires T1 and T2 PASS and every opted-in T3-T5 check PASS. +Skipped optional tiers are reported, never silently treated as exercised. +Tier 6 is always MANUAL; see .agentworkforce/features/critical-paths.md. +EOF +cat "${ARTIFACTS}/acceptance-contract.txt" +`, + }) + + wf.step('setup', { + type: 'deterministic', + dependsOn: ['acceptance-contract'], + captureOutput: true, + failOnError: false, + command: ` +set -euo pipefail +mkdir -p "${ARTIFACTS}" +rm -f "${ARTIFACTS}"/tier*.log "${ARTIFACTS}/summary.txt" +{ + echo "Run ID: ${RUN_ID}" + echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "Node: $(node --version)" + echo "npm: $(npm --version)" + echo "Branch: $(git branch --show-current)" +} | tee "${ARTIFACTS}/setup.log" +`, + }) + + wf.step('tier1-package-manifest', { + type: 'deterministic', + dependsOn: ['setup'], + captureOutput: true, + failOnError: false, + command: ` +set -uo pipefail +LOG="${ARTIFACTS}/tier1.log" +PASS=0 +FAIL=0 +: > "$LOG" + +run_check() { + name="$1" + shift + if "$@" >> "$LOG" 2>&1; then + echo "PASS $name" | tee -a "$LOG" + PASS=$((PASS + 1)) + else + echo "FAIL $name" | tee -a "$LOG" + FAIL=$((FAIL + 1)) + fi +} + +run_check "package build" npm run build +# TypeScript emits before reporting dependency-type errors. Finish alias +# rewriting so the remaining diagnostics can still distinguish CLI health from +# a build-only failure. +npx tsc-alias -p tsconfig.build.json >> "$LOG" 2>&1 || true +run_check "factory help" node bin/factory.mjs --help + +if [ ! -e .agentworkforce/FEATURE_MAPPING_BRIEF.md ]; then + echo "PASS brief removed" | tee -a "$LOG" + PASS=$((PASS + 1)) +else + echo "FAIL brief removed" | tee -a "$LOG" + FAIL=$((FAIL + 1)) +fi + +if node --input-type=module <<'NODE' >> "$LOG" 2>&1 +import { existsSync, readFileSync } from 'node:fs' +import { parse } from 'yaml' + +const manifest = parse(readFileSync('.agentworkforce/features/manifest.yaml', 'utf8')) +const procedures = readFileSync('.agentworkforce/features/verify/procedures.md', 'utf8') +const categories = Object.values(manifest.categories || {}) +const validCriticalities = new Set(['critical', 'hot', 'standard']) +function validateCategoryCriticalities(categoryMap) { + for (const [categoryId, category] of Object.entries(categoryMap || {})) { + if (!validCriticalities.has(category?.criticality)) { + throw new Error('invalid manifest category criticality for ' + categoryId + ': ' + JSON.stringify(category?.criticality)) + } + } +} +validateCategoryCriticalities(manifest.categories) +for (const invalidCriticality of [undefined, 'urgent']) { + let rejected = false + try { + validateCategoryCriticalities({ regression: { criticality: invalidCriticality } }) + } catch { + rejected = true + } + if (!rejected) throw new Error('category criticality regression was accepted: ' + JSON.stringify(invalidCriticality)) +} +const features = categories.flatMap((category) => category.features || []) +const ids = features.map((feature) => feature.id) +if (categories.length === 0 || features.length === 0) throw new Error('manifest is empty') +if (new Set(ids).size !== ids.length) throw new Error('duplicate manifest feature IDs') +const tierCounts = Object.fromEntries([1, 2, 3, 4, 5, 6].map((tier) => [ + tier, + features.filter((feature) => feature.verify_tier === tier).length, +])) +if (manifest.catalog?.category_count !== categories.length || + manifest.catalog?.feature_count !== features.length || + JSON.stringify(manifest.catalog?.tier_counts) !== JSON.stringify(tierCounts)) { + throw new Error('manifest catalog summary mismatch: ' + JSON.stringify({ + catalog: manifest.catalog, + actual: { category_count: categories.length, feature_count: features.length, tier_counts: tierCounts }, + })) +} +for (const feature of features) { + if (!feature.id || !feature.name || (!feature.cli && !feature.api) || + !feature.description || !feature.location || + !Number.isInteger(feature.verify_tier) || feature.verify_tier < 1 || feature.verify_tier > 6) { + throw new Error('invalid manifest feature: ' + JSON.stringify(feature)) + } + for (const location of feature.location.split(',').map((value) => value.trim())) { + if (!existsSync(location)) throw new Error('manifest location does not exist for ' + feature.id + ': ' + location) + } + const marker = String.fromCharCode(96) + feature.id + String.fromCharCode(96) + if (!procedures.includes(marker)) throw new Error('feature missing from procedures: ' + feature.id) +} + +const regressionMarkers = { + 'src/config/local-clone-paths.test.ts': [ + 'infers the git top-level from a nested cwd when the resolved route remote matches', + 'validates configured paths through git and accepts linked worktrees', + ], + 'src/cli/fleet.test.ts': [ + 'auto-detects GitHub-only workspaces without resolving Linear states', + 'rejects an ambiguous bare GitHub issue number across configured repositories', + 'keeps relay dispatch ownership until the remote PR is published and the issue is parked', + ], + 'src/triage/triage.test.ts': [ + 'repo-qualifies every collision-prone GitHub-native role while preserving its source repo identity', + ], + 'src/logging.test.ts': [ + 'contains nested errors, cycles, BigInts, getters, and throwing toJSON hooks', + 'applies the global field budget to branching nested arrays', + ], + 'src/fleet/relay-fleet-client.test.ts': [ + 'dispatches spawn through placement with task-exit lifecycle and no self target', + 'hydrates tracked agents for restart recovery', + ], + 'src/mount/relayfile-github-connection-write.test.ts': [ + 'publishes an already-pushed remote branch without reading an orchestrator-local clone', + ], + 'src/state/file-state-store.test.ts': [ + 'persists and fences dispatch lifecycle ownership across processes and crash takeover', + 'restores and clears babysitter ownership plus pending wake state in a fresh process-equivalent store', + ], + 'src/orchestrator/factory.test.ts': [ + 'isolates equal-number GitHub dispatch names, state, registry, resume, and completion across repos', + 'releases the whole team before waiting for slow Slack question confirmation', + 'fences a duplicate owner while the active owner is inside a slow PR publication', + 'autonomously retries a transient remote PR publication without another exit event', + 'routes and coalesces only the owned PR review/check/comment events with metadata-only fencing', + 'installs the destructive fence before delivering its explicit begin acknowledgment', + 'routes paginated poll events through the same exact-PR coalescer', + ], +} +for (const [path, markers] of Object.entries(regressionMarkers)) { + const source = readFileSync(path, 'utf8') + for (const marker of markers) { + if (!source.includes(marker)) throw new Error('missing final-main regression marker in ' + path + ': ' + marker) + } +} +console.log(JSON.stringify({ categories: categories.length, features: features.length })) +NODE +then + echo "PASS manifest schema and procedure coverage" | tee -a "$LOG" + PASS=$((PASS + 1)) +else + echo "FAIL manifest schema and procedure coverage" | tee -a "$LOG" + FAIL=$((FAIL + 1)) +fi + +GUARDIAN_SELF_TEST="${ARTIFACTS}/guardian-self-test.ts" +cat > "${ARTIFACTS}/guardian-delivery-stub.mjs" <<'JS' +export const input = () => undefined +JS +cat > "${ARTIFACTS}/guardian-runtime-stub.mjs" <<'JS' +export const defineAgent = (definition) => definition +JS +cat > "${ARTIFACTS}/guardian-slack-stub.mjs" <<'JS' +export const slackClient = () => ({}) +JS +cat > "$GUARDIAN_SELF_TEST" <<'TS' +import { fallbackCheckMessage, parseManifestFeatures } from './guardian-test-bundle.mjs' + +const missingSecondCriticality = [ + 'catalog:', + ' category_count: 2', + ' feature_count: 2', + ' tier_counts:', + ' 1: 2', + ' 2: 0', + ' 3: 0', + ' 4: 0', + ' 5: 0', + ' 6: 0', + 'categories:', + ' first:', + ' criticality: critical', + ' features:', + ' - id: first-feature', + ' name: First feature', + ' cli: factory first', + ' description: First expected behavior', + ' location: src/index.ts', + ' verify_tier: 1', + ' second:', + ' features:', + ' - id: second-feature', + ' name: Second feature', + ' cli: factory second', + ' description: Second expected behavior', + ' location: src/index.ts', + ' verify_tier: 1', +].join('\\n') + +let rejected = false +try { + parseManifestFeatures(missingSecondCriticality) +} catch { + rejected = true +} +if (!rejected) throw new Error('guardian inherited criticality across category boundary') + +const source = 'src/orchestrator/factory.ts' +const fallback = fallbackCheckMessage({ + id: 'fallback-source-regression', + name: 'Fallback source regression', + cli: 'factory status', + desc: 'Preserve exact source context when LLM completion fails', + location: source, + tier: 1, + criticality: 'critical', +}) +if (!fallback.includes('Source: ' + source)) throw new Error('guardian fallback omitted feature.location') +TS +if npx esbuild .agentworkforce/agents/factory-feature-guardian/agent.ts \ + --bundle --platform=node --format=esm \ + --alias:@agentworkforce/delivery="$PWD/${ARTIFACTS}/guardian-delivery-stub.mjs" \ + --alias:@agentworkforce/runtime="$PWD/${ARTIFACTS}/guardian-runtime-stub.mjs" \ + --alias:@relayfile/relay-helpers="$PWD/${ARTIFACTS}/guardian-slack-stub.mjs" \ + --outfile="${ARTIFACTS}/guardian-test-bundle.mjs" >> "$LOG" 2>&1 && \ + npx tsx "$GUARDIAN_SELF_TEST" >> "$LOG" 2>&1 +then + echo "PASS guardian parser and fallback regressions" | tee -a "$LOG" + PASS=$((PASS + 1)) +else + echo "FAIL guardian parser and fallback regressions" | tee -a "$LOG" + FAIL=$((FAIL + 1)) +fi + +echo "Tier 1 result: $PASS passed, $FAIL failed" | tee -a "$LOG" +if [ "$FAIL" -gt 0 ]; then + echo "TIER1_FAIL" | tee -a "$LOG" + exit 1 +fi +echo "TIER1_PASS" | tee -a "$LOG" +`, + }) + + wf.step('tier2-tests-config', { + type: 'deterministic', + dependsOn: ['tier1-package-manifest'], + captureOutput: true, + failOnError: false, + command: ` +set -uo pipefail +LOG="${ARTIFACTS}/tier2.log" +PASS=0 +FAIL=0 +: > "$LOG" + +if npm test -- --maxWorkers=1 >> "$LOG" 2>&1; then + echo "PASS full Vitest suite" | tee -a "$LOG" + PASS=$((PASS + 1)) +else + echo "FAIL full Vitest suite" | tee -a "$LOG" + FAIL=$((FAIL + 1)) +fi + +CONFIG="${ARTIFACTS}/fixture-config.json" +if node --input-type=module - "$CONFIG" <<'NODE' >> "$LOG" 2>&1 +import { readFileSync, writeFileSync } from 'node:fs' + +const output = process.argv[2] +const config = JSON.parse(readFileSync('test/fixtures/factory.config.json', 'utf8')) +const fixture = config.fixtureFiles?.['/linear/issues/AR-77__uuid-77.json'] +if (!output || !fixture?.payload) throw new Error('AR-77 fixture payload is missing') +fixture.payload.url = 'https://linear.app/agent-relay/issue/AR-77/cli-dry-run' +writeFileSync(output, JSON.stringify(config, null, 2)) +NODE +then + echo "PASS canonical fixture identity" | tee -a "$LOG" + PASS=$((PASS + 1)) +else + echo "FAIL canonical fixture identity" | tee -a "$LOG" + FAIL=$((FAIL + 1)) +fi + +REPORT="${ARTIFACTS}/fixture-report.json" +if node bin/factory.mjs run-once \ + --config "$CONFIG" \ + --dry-run > "$REPORT" 2>> "$LOG" && \ + node --input-type=module -e ' + import { readFileSync } from "node:fs" + const report = JSON.parse(readFileSync(process.argv[1], "utf8")) + const decision = report.triaged.find((entry) => entry.issue.key === "AR-77") + if (!report.dryRun || !decision || decision.routes[0]?.repo !== "AgentWorkforce/pear" || + !report.dispatched.some((entry) => entry.issue.key === "AR-77")) process.exit(1) + ' "$REPORT" >> "$LOG" 2>&1; then + echo "PASS fixture discover-triage-dispatch cycle" | tee -a "$LOG" + PASS=$((PASS + 1)) +else + echo "FAIL fixture discover-triage-dispatch cycle" | tee -a "$LOG" + FAIL=$((FAIL + 1)) +fi + +if node bin/factory.mjs status --config "$CONFIG" >> "$LOG" 2>&1; then + echo "PASS fixture status JSON" | tee -a "$LOG" + PASS=$((PASS + 1)) +else + echo "FAIL fixture status JSON" | tee -a "$LOG" + FAIL=$((FAIL + 1)) +fi + +echo "Tier 2 result: $PASS passed, $FAIL failed" | tee -a "$LOG" +if [ "$FAIL" -gt 0 ]; then + echo "TIER2_FAIL" | tee -a "$LOG" + exit 1 +fi +echo "TIER2_PASS" | tee -a "$LOG" +`, + }) + + wf.step('tier3-provider-canary', { + type: 'deterministic', + dependsOn: ['tier2-tests-config'], + captureOutput: true, + failOnError: false, + command: ` +set -uo pipefail +LOG="${ARTIFACTS}/tier3.log" +CONFIG=$(printenv FACTORY_VERIFY_CONFIG 2>/dev/null || true) +ISSUE=$(printenv FACTORY_VERIFY_CANARY_ISSUE 2>/dev/null || true) +: > "$LOG" + +if [ -z "$ISSUE" ]; then + echo "Skip reason: FACTORY_VERIFY_CANARY_ISSUE is unset" | tee -a "$LOG" + echo "TIER3_SKIP" | tee -a "$LOG" + exit 0 +fi +if [ -z "$CONFIG" ] || [ ! -f "$CONFIG" ]; then + echo "Failure reason: FACTORY_VERIFY_CONFIG must name a valid live config" | tee -a "$LOG" + echo "TIER3_FAIL" | tee -a "$LOG" + exit 1 +fi +if node bin/factory.mjs canary "$ISSUE" --config "$CONFIG" >> "$LOG" 2>&1; then + echo "TIER3_PASS" | tee -a "$LOG" + exit 0 +fi +echo "TIER3_FAIL" | tee -a "$LOG" +exit 1 +`, + }) + + wf.step('tier4-fleet', { + type: 'deterministic', + dependsOn: ['tier3-provider-canary'], + captureOutput: true, + failOnError: false, + command: ` +set -uo pipefail +LOG="${ARTIFACTS}/tier4.log" +: > "$LOG" +VERIFY_FLEET=$(printenv FACTORY_VERIFY_FLEET 2>/dev/null || true) +BACKEND=$(printenv FACTORY_VERIFY_BACKEND 2>/dev/null || true) + +if [ "$VERIFY_FLEET" != "1" ]; then + echo "Skip reason: FACTORY_VERIFY_FLEET is not 1" | tee -a "$LOG" + echo "TIER4_SKIP" | tee -a "$LOG" + exit 0 +fi +if [ "$BACKEND" != "internal" ] && [ "$BACKEND" != "relay" ]; then + echo "Failure reason: FACTORY_VERIFY_BACKEND must be internal or relay" | tee -a "$LOG" + echo "TIER4_FAIL" | tee -a "$LOG" + exit 1 +fi +if node bin/factory.mjs fleet roster --backend "$BACKEND" >> "$LOG" 2>&1; then + echo "TIER4_PASS" | tee -a "$LOG" + exit 0 +fi +echo "TIER4_FAIL" | tee -a "$LOG" +exit 1 +`, + }) + + wf.step('tier5-cloud-mount', { + type: 'deterministic', + dependsOn: ['tier4-fleet'], + captureOutput: true, + failOnError: false, + command: ` +set -uo pipefail +LOG="${ARTIFACTS}/tier5.log" +CONFIG=$(printenv FACTORY_VERIFY_CONFIG 2>/dev/null || true) +VERIFY_CLOUD=$(printenv FACTORY_VERIFY_CLOUD 2>/dev/null || true) +: > "$LOG" + +if [ "$VERIFY_CLOUD" != "1" ]; then + echo "Skip reason: FACTORY_VERIFY_CLOUD is not 1" | tee -a "$LOG" + echo "TIER5_SKIP" | tee -a "$LOG" + exit 0 +fi +if [ -z "$CONFIG" ] || [ ! -f "$CONFIG" ]; then + echo "Failure reason: FACTORY_VERIFY_CONFIG must name a valid cloud config" | tee -a "$LOG" + echo "TIER5_FAIL" | tee -a "$LOG" + exit 1 +fi +if node bin/factory.mjs run-once --config "$CONFIG" --dry-run >> "$LOG" 2>&1; then + echo "TIER5_PASS" | tee -a "$LOG" + exit 0 +fi +echo "TIER5_FAIL" | tee -a "$LOG" +exit 1 +`, + }) + + wf.step('tier6-manual', { + type: 'deterministic', + dependsOn: ['tier5-cloud-mount'], + captureOutput: true, + failOnError: false, + command: ` +cat > "${ARTIFACTS}/tier6.log" <<'EOF' +TIER6_MANUAL +Run the live issue/PR procedures in: + .agentworkforce/features/verify/procedures.md +and the end-to-end sequences in: + .agentworkforce/features/critical-paths.md + +Required manual surfaces: kill-loop, standalone babysit, guarded merge, +probe close, authorized GitHub replies, remote-head PR publication/recovery, +canonical babysitter routing/readback, destructive-work ACK fencing, and PR close. +Supported ownership topology: multiple control-plane processes sharing one +same-host FileStateStore. Cross-host active/active control planes are unsupported. +EOF +cat "${ARTIFACTS}/tier6.log" +`, + }) + + wf.step('collect-results', { + type: 'deterministic', + dependsOn: ['tier6-manual'], + captureOutput: true, + failOnError: false, + command: ` +set -uo pipefail +SUMMARY="${ARTIFACTS}/summary.txt" +FAIL=0 + +overall_result() { + fail_count="$1" + summary_path="$2" + if [ "$fail_count" -ne 0 ] || \ + grep -Eq '^T[1-5]: (NOT_RUN|TIER[1-5]_(FAIL|UNKNOWN))' "$summary_path"; then + printf 'FAIL' + else + printf 'PASS' + fi +} + +tier_result() { + requested_tier="$1" + tier_log="$2" + marker=$(grep -E '^TIER[0-9]+_' "$tier_log" | tail -1 || true) + case "$requested_tier:$marker" in + 1:TIER1_PASS|1:TIER1_FAIL|2:TIER2_PASS|2:TIER2_FAIL|\ + 3:TIER3_PASS|3:TIER3_FAIL|3:TIER3_SKIP|\ + 4:TIER4_PASS|4:TIER4_FAIL|4:TIER4_SKIP|\ + 5:TIER5_PASS|5:TIER5_FAIL|5:TIER5_SKIP|6:TIER6_MANUAL) printf '%s' "$marker" ;; + *) printf 'TIER%s_UNKNOWN' "$requested_tier" ;; + esac +} + +# Regression guard: an absent required tier log is represented as NOT_RUN and +# must never be certified as an overall pass, even when no explicit *_FAIL +# marker exists to match. +SELF_TEST="${ARTIFACTS}/collector-not-run-self-test.txt" +cat > "$SELF_TEST" <<'EOF' +T1: NOT_RUN +T2: TIER2_PASS +T3: TIER3_SKIP +T4: TIER4_SKIP +T5: TIER5_SKIP +EOF +if [ "$(overall_result 0 "$SELF_TEST")" != "FAIL" ]; then + echo "Collector regression failed: required NOT_RUN tier was accepted" >&2 + exit 1 +fi + +printf '%s\n' 'TIER2_PASS' > "${ARTIFACTS}/collector-cross-tier-self-test.log" +if [ "$(tier_result 1 "${ARTIFACTS}/collector-cross-tier-self-test.log")" != "TIER1_UNKNOWN" ]; then + echo "Collector regression failed: cross-tier marker was accepted" >&2 + exit 1 +fi +printf '%s\n' 'TIER1_PASS_EXTRA' > "${ARTIFACTS}/collector-suffix-self-test.log" +if [ "$(tier_result 1 "${ARTIFACTS}/collector-suffix-self-test.log")" != "TIER1_UNKNOWN" ]; then + echo "Collector regression failed: suffixed marker was accepted" >&2 + exit 1 +fi + +{ + echo "Factory Feature Verification: ${RUN_ID}" + echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo + for tier in 1 2 3 4 5 6; do + LOG="${ARTIFACTS}/tier$tier.log" + if [ ! -f "$LOG" ]; then + RESULT="NOT_RUN" + FAIL=1 + else + RESULT=$(tier_result "$tier" "$LOG") + case "$RESULT" in NOT_RUN|*_FAIL|*_UNKNOWN) FAIL=1 ;; esac + fi + echo "T$tier: $RESULT" + done + echo + echo "Tier 6 is intentionally manual and is not counted as an automated failure." +} > "$SUMMARY" + +echo "Overall: $(overall_result "$FAIL" "$SUMMARY")" >> "$SUMMARY" +cat "$SUMMARY" +`, + }) + + wf.step('report', { + type: 'deterministic', + dependsOn: ['collect-results'], + captureOutput: true, + failOnError: true, + command: ` +set -euo pipefail +report_result() { + ! grep -q '^Overall: FAIL$' "$1" +} + +REPORT_SELF_TEST="${ARTIFACTS}/report-failure-self-test.txt" +printf '%s\n' 'Overall: FAIL' > "$REPORT_SELF_TEST" +if report_result "$REPORT_SELF_TEST"; then + echo "Report regression failed: Overall FAIL returned success" >&2 + exit 1 +fi + +cat "${ARTIFACTS}/summary.txt" +echo +echo "Detailed logs: ${ARTIFACTS}/tier{1,2,3,4,5,6}.log" + +report_result "${ARTIFACTS}/summary.txt" +`, + }) + + const run = await wf.run() + if (run.status !== 'completed') { + throw new Error(`Factory feature verification ended with status ${run.status}: ${run.error ?? 'no error detail'}`) + } +} + +main()