ai-foreman 1.7.27 → 1.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/adapters/claude.js +1 -1
  2. package/dist/adapters/types.d.ts +1 -0
  3. package/dist/buildApproval.d.ts +15 -0
  4. package/dist/buildApproval.js +43 -0
  5. package/dist/cli/start.d.ts +2 -1
  6. package/dist/cli/start.js +35 -5
  7. package/dist/cli/tickets.d.ts +7 -0
  8. package/dist/cli/tickets.js +18 -3
  9. package/dist/config.js +13 -4
  10. package/dist/foreman.d.ts +11 -2
  11. package/dist/foreman.js +22 -10
  12. package/dist/hostGraphApplicability.d.ts +45 -0
  13. package/dist/hostGraphApplicability.js +99 -0
  14. package/dist/hostQaAugmentation.d.ts +18 -0
  15. package/dist/hostQaAugmentation.js +64 -0
  16. package/dist/hostQaComparison.d.ts +50 -0
  17. package/dist/hostQaComparison.js +196 -0
  18. package/dist/observability.d.ts +2 -0
  19. package/dist/observability.js +2 -1
  20. package/dist/permissions/policy.js +11 -0
  21. package/dist/qaBuildGate.js +3 -2
  22. package/dist/qaBuilderCoverage.js +11 -2
  23. package/dist/qaContractCoverage.js +12 -3
  24. package/dist/qaEffectiveConfig.d.ts +1 -0
  25. package/dist/qaEffectiveConfig.js +6 -1
  26. package/dist/qaPreparation.js +21 -2
  27. package/dist/qaPreparationStore.d.ts +4 -0
  28. package/dist/qaPreparationStore.js +42 -6
  29. package/dist/qaReview.js +38 -3
  30. package/dist/qaToolRecovery.d.ts +12 -0
  31. package/dist/qaToolRecovery.js +44 -0
  32. package/dist/qaVerificationContract.d.ts +9 -0
  33. package/dist/qaVerificationContract.js +76 -1
  34. package/dist/supervisedStart.d.ts +1 -1
  35. package/dist/supervisedStart.js +65 -9
  36. package/dist/supervisor.d.ts +18 -2
  37. package/dist/supervisor.js +74 -17
  38. package/dist/ticketPopulation.js +2 -0
  39. package/dist/workflowDb.js +39 -5
  40. package/package.json +3 -3
@@ -749,7 +749,7 @@ export class ClaudeAdapter {
749
749
  const outputDigest = createHash("sha256").update(text).digest("hex");
750
750
  // Persist only correlation metadata. The result text can contain source
751
751
  // paths, command output, or credentials and must not enter lifecycle logs.
752
- this.opts.onLifecycleTrace?.({ phase: "provider-tool-result", at: new Date().toISOString(), sessionId: this._sessionId, providerTurnId: this.activeProviderTurnId, toolCallId: callId, toolName: call?.name ?? "unidentified tool", classification: status, resultReason, isError: failed, outputDigest, rawOutputBytes: Buffer.byteLength(text) });
752
+ this.opts.onLifecycleTrace?.({ phase: "provider-tool-result", at: new Date().toISOString(), sessionId: this._sessionId, providerTurnId: this.activeProviderTurnId, toolCallId: callId, toolName: call?.name ?? "unidentified tool", classification: status, resultReason, isError: failed, outputDigest, rawOutputBytes: Buffer.byteLength(text), completionKnown: status !== "unknown" });
753
753
  if (status === "unknown") {
754
754
  if (this.unknownToolResults.get(callId) === outputDigest)
755
755
  continue;
@@ -287,6 +287,7 @@ export interface BuilderAdapterOptions {
287
287
  isError?: boolean;
288
288
  outputDigest?: string;
289
289
  rawOutputBytes?: number;
290
+ completionKnown?: boolean;
290
291
  }) => void;
291
292
  }
292
293
  export interface BuilderAdapter {
@@ -7,5 +7,20 @@ export declare function approvedBuildScope(projectDir: string, ticketIds?: reado
7
7
  export declare function requiresBuildApproval(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences?: Record<string, unknown>): boolean;
8
8
  /** Bind decisions to current files so an old answer cannot approve a changed scope. */
9
9
  export declare function buildScopeRevision(projectDir: string, consequences?: Record<string, unknown>): string;
10
+ export interface PlanApprovalBinding {
11
+ planDigest: string;
12
+ ticketSetDigest: string;
13
+ ticketDefinitionsDigest: string;
14
+ scopeRevision: string;
15
+ workflowConsequenceDigest: string;
16
+ }
17
+ /** Compute the exact host-owned inputs covered by a recovery plan approval. */
18
+ export declare function planApprovalBinding(projectDir: string, ticketIds: readonly string[], consequences?: Record<string, unknown>): PlanApprovalBinding;
19
+ export declare function validatePlanApprovalBinding(projectDir: string, ticketIds: readonly string[], approval: PlanApprovalBinding, consequences?: Record<string, unknown>): {
20
+ valid: true;
21
+ } | {
22
+ valid: false;
23
+ reason: string;
24
+ };
10
25
  /** Reuse a decision only while its source and consequences remain unchanged. */
11
26
  export declare function createBuildApprovalGate(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences: Record<string, unknown> | undefined, requestApproval: () => Promise<void>): () => Promise<void>;
@@ -125,6 +125,49 @@ export function buildScopeRevision(projectDir, consequences) {
125
125
  });
126
126
  return createHash("sha256").update(JSON.stringify([contents, consequences])).digest("hex");
127
127
  }
128
+ /** Compute the exact host-owned inputs covered by a recovery plan approval. */
129
+ export function planApprovalBinding(projectDir, ticketIds, consequences) {
130
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
131
+ const all = loadTickets(paths.tickets);
132
+ const selected = ticketIds.map(id => all.find(ticket => ticket.id === id));
133
+ if (selected.some(ticket => !ticket))
134
+ throw new Error("plan approval cannot bind a missing ticket definition");
135
+ const config = parse(readFileSync(join(projectDir, "rafi-config.yaml"), "utf8"));
136
+ const docsRoot = config.docs?.root ?? "docs";
137
+ const plan = JSON.parse(readFileSync(join(projectDir, docsRoot, "rafi-plan.json"), "utf8"));
138
+ const planDigest = createHash("sha256").update(canonical({ ...plan, content_digest: "" })).digest("hex");
139
+ const db = new WorkflowDb(projectDir);
140
+ try {
141
+ const approved = db.completedPlanApprovals().some(receipt => {
142
+ const decision = receipt.decisionReceipt;
143
+ return receipt.planId === plan.plan_id && receipt.revision === plan.revision
144
+ && receipt.digest === planDigest && decision?.planDigest === planDigest;
145
+ });
146
+ if (!approved)
147
+ throw new Error("current plan has no matching completed host approval");
148
+ }
149
+ finally {
150
+ db.close();
151
+ }
152
+ const ticketSetDigest = createHash("sha256").update(canonical([...ticketIds].sort())).digest("hex");
153
+ const ticketDefinitionsDigest = createHash("sha256").update(canonical(selected.map(ticket => material(ticket)).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))))).digest("hex");
154
+ const workflow = { build: config.tickets?.build ?? {}, consequences: consequences ?? {} };
155
+ const workflowConsequenceDigest = createHash("sha256").update(canonical(workflow)).digest("hex");
156
+ return { planDigest, ticketSetDigest, ticketDefinitionsDigest, scopeRevision: buildScopeRevision(projectDir, consequences), workflowConsequenceDigest };
157
+ }
158
+ export function validatePlanApprovalBinding(projectDir, ticketIds, approval, consequences) {
159
+ try {
160
+ const current = planApprovalBinding(projectDir, ticketIds, consequences);
161
+ for (const key of ["planDigest", "ticketSetDigest", "ticketDefinitionsDigest", "scopeRevision", "workflowConsequenceDigest"]) {
162
+ if (current[key] !== approval[key])
163
+ return { valid: false, reason: `${key} changed` };
164
+ }
165
+ return { valid: true };
166
+ }
167
+ catch (error) {
168
+ return { valid: false, reason: `approval inputs unavailable: ${String(error)}` };
169
+ }
170
+ }
128
171
  /** Reuse a decision only while its source and consequences remain unchanged. */
129
172
  export function createBuildApprovalGate(projectDir, ticketIds, explicitYes, consequences, requestApproval) {
130
173
  let approvedRevision;
@@ -5,7 +5,7 @@ import type { AgentRoleDefaultsV1 } from "rafi-spec";
5
5
  import type { BranchPlan } from "../branch/types.js";
6
6
  import { type DeliveryConfig, type DeliveryStack } from "../tickets/delivery.js";
7
7
  import type { QaNonconvergenceContext, QaNonconvergenceDecision, QaReportRecoveryHandler } from "../qaReview.js";
8
- import { RunObserver } from "../observability.js";
8
+ import { RunObserver, type ObservabilityMode } from "../observability.js";
9
9
  export declare function formatStartResumeCommand(executable: "ai-foreman", projectDir: string, steps: number, sessionId: string): string;
10
10
  export declare function formatResumeGuidance(executable: "rafi" | "ai-foreman", projectDir: string, steps: number, sessionId?: string, run?: {
11
11
  runId: string;
@@ -22,6 +22,7 @@ export declare function formatResumeGuidance(executable: "rafi" | "ai-foreman",
22
22
  export declare function savedStartArguments(command: Command, projectDir: string, opts: Record<string, unknown>): string[];
23
23
  /** Pure validation shared by interactive start and persisted preparation. */
24
24
  export declare function validateStartOptions(opts: Record<string, any>, requireCount: boolean): void;
25
+ export declare function resolveAuditLoggingMode(cli: unknown, configured: unknown, environment?: string | undefined): ObservabilityMode;
25
26
  /** Preserve the distinction between a staged handoff and an unattempted acceptance. */
26
27
  export declare function recordHandoffAcceptanceNotAttempted(projectDir: string, runId: string, role: "builder" | "qa", generation: number, stage: string, detail: string): void;
27
28
  /** Default start selection is deliberately the same eligibility model users see in the ticket queue. */
package/dist/cli/start.js CHANGED
@@ -11,7 +11,7 @@ import { captureFrozenQaSource } from "../qaSnapshot.js";
11
11
  import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
12
12
  import { assertBuildAssignmentReconciled, isInterruptedBuilderRecovery, assertInterruptedSourceSnapshot } from "../buildAssignment.js";
13
13
  import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
14
- import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate } from "../buildApproval.js";
14
+ import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate, validatePlanApprovalBinding } from "../buildApproval.js";
15
15
  import { Command, Option } from "commander";
16
16
  import { accent, error, sanitizeTerminalText, success, warning } from "../terminalStyle.js";
17
17
  import { resolve, join, relative } from "node:path";
@@ -426,6 +426,20 @@ export function validateStartOptions(opts, requireCount) {
426
426
  throw new Error("Preparation launch requires both run and token");
427
427
  if (opts.launchToken && !opts.preparationRun && !opts.recoverRun)
428
428
  throw new Error("Launch token requires a preparation or recovery run");
429
+ if (opts.auditLogging !== undefined && !["off", "summary", "trace"].includes(String(opts.auditLogging)))
430
+ throw new Error("--audit-logging must be off, summary, or trace");
431
+ }
432
+ export function resolveAuditLoggingMode(cli, configured, environment = process.env.RAFI_AUDIT_LOGGING) {
433
+ const value = cli ?? configured ?? environment ?? "summary";
434
+ if (!["off", "summary", "trace"].includes(String(value)))
435
+ throw new Error("audit logging must be off, summary, or trace");
436
+ return String(value);
437
+ }
438
+ function configuredAuditLoggingMode(path) {
439
+ if (!existsSync(path))
440
+ return undefined;
441
+ const raw = parseYaml(readFileSync(path, "utf8"));
442
+ return raw?.observability?.mode === undefined ? undefined : resolveAuditLoggingMode(undefined, raw.observability.mode, undefined);
429
443
  }
430
444
  /** A current waiver is an already-recorded decision: surface it, never demand a second start-time approval. */
431
445
  function warnRiskPlanWaivers(projectDir, ticketIds) {
@@ -545,6 +559,7 @@ export function buildStartCommand(options = {}) {
545
559
  .option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
546
560
  .option("--branch-prefix <prefix>", "branch name prefix for ticket branches")
547
561
  .option("--show-session-cost", "show authoritative cost or trustworthy cumulative session tokens for Builder and QA")
562
+ .option("--audit-logging <mode>", "host-owned audit logging (off | summary | trace)", "summary")
548
563
  .option("--hide-session-cost", "hide session cost/token usage for Builder and QA")
549
564
  .option("--auto-compact-threshold <percent>", "initial Builder context compaction threshold (1-99)")
550
565
  .option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
@@ -676,7 +691,20 @@ export function buildStartCommand(options = {}) {
676
691
  const readinessOwner = { project: cwd, runId: supervisedInvocationId, authority: localBuildAuthority(cwd) };
677
692
  const autoApprovePlanUpdates = recoveryRecord
678
693
  ? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
694
+ && (recoveryRecord.recoveryDecision.planUpdateApprovalSource === "explicit-yes" || recoveryRecord.recoveryDecision.planUpdateApprovalSource === "explicit-choice")
679
695
  : Boolean(opts.yes);
696
+ const recoveryTickets = recoveryRecord?.recoveryDecision?.executionTickets ?? recoveryRecord?.tickets ?? [];
697
+ // Empty-scope recovery is used by established/supervisor lifecycle
698
+ // recovery fixtures and has no ticket plan to bind. Ticket-bearing
699
+ // recovery remains fail-closed on an exact current-plan approval.
700
+ if (recoveryRecord && autoApprovePlanUpdates && recoveryTickets.length > 0) {
701
+ const decision = recoveryRecord.recoveryDecision;
702
+ if (!decision?.planApproval)
703
+ fail("saved --yes approval has no exact plan binding; rerun rafi build:resume --yes to approve the current plan");
704
+ const approvalCheck = validatePlanApprovalBinding(cwd, recoveryTickets, decision.planApproval);
705
+ if (!approvalCheck.valid)
706
+ fail(`saved --yes approval is stale (${approvalCheck.reason}); rerun rafi build:resume --yes before provider work`);
707
+ }
680
708
  if (opts.recoveryMode) {
681
709
  const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
682
710
  if (!allowed.includes(String(opts.recoveryMode)))
@@ -934,6 +962,8 @@ export function buildStartCommand(options = {}) {
934
962
  ? recoveryExecutionTickets(recoveryRecord, requestedTicketIds, Boolean(opts.recoveryMode))
935
963
  : selectedNewTicket ? [selectedNewTicket] : undefined;
936
964
  const config = loadConfig(join(cwd, "foreman.yaml"));
965
+ const auditLogging = resolveAuditLoggingMode(command.getOptionValueSource("auditLogging") === "cli" ? opts.auditLogging : undefined, configuredAuditLoggingMode(join(cwd, "foreman.yaml")));
966
+ const observabilityConfig = { ...config.observability, mode: auditLogging, enabled: auditLogging !== "off" };
937
967
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
938
968
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
939
969
  const log = new Log(logPath);
@@ -948,9 +978,9 @@ export function buildStartCommand(options = {}) {
948
978
  console.error(`rafi: warning: ${qaResolution.deprecationWarning}`);
949
979
  const invocationRunId = supervisedInvocationId;
950
980
  let observabilityStore;
951
- if (config.observability.enabled) {
981
+ if (observabilityConfig.enabled) {
952
982
  try {
953
- observabilityStore = new ObservabilityStore(cwd, { config: config.observability });
983
+ observabilityStore = new ObservabilityStore(cwd, { config: observabilityConfig });
954
984
  }
955
985
  catch (error) {
956
986
  console.error(`rafi: observability unavailable; build will continue: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
@@ -2384,7 +2414,7 @@ export function buildStartCommand(options = {}) {
2384
2414
  observabilityStore?.enforceLimits();
2385
2415
  observabilityStore?.maintainLogs(cwd);
2386
2416
  observabilityStore?.close();
2387
- pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2417
+ pruneRecoveryTelemetryAtCompletion(cwd, observabilityConfig.detail_retention_days);
2388
2418
  activeObserver = undefined;
2389
2419
  process.off("exit", finishObservationOnExit);
2390
2420
  console.log("foreman: branch run summary");
@@ -2831,7 +2861,7 @@ export function buildStartCommand(options = {}) {
2831
2861
  observabilityStore?.enforceLimits();
2832
2862
  observabilityStore?.maintainLogs(cwd);
2833
2863
  observabilityStore?.close();
2834
- pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
2864
+ pruneRecoveryTelemetryAtCompletion(cwd, observabilityConfig.detail_retention_days);
2835
2865
  activeObserver = undefined;
2836
2866
  process.off("exit", finishObservationOnExit);
2837
2867
  console.log(`\n${accent("foreman:")} ${result.completed}/${result.requested} step(s) completed`);
@@ -1,10 +1,17 @@
1
1
  import { Command } from "commander";
2
2
  import type { StructuredPlanV1 } from "rafi-spec";
3
3
  import { Log } from "../log.js";
4
+ import { type StepStatus } from "../foreman.js";
4
5
  import type { EffortLevel } from "../adapters/types.js";
5
6
  import { type RoleBuilderOptions } from "../agentRun.js";
6
7
  import type { TicketsConfig } from "../tickets/config.js";
7
8
  import { type TicketPopulateRuntimeMode, type TicketSourceConfig, type TicketsSetupConfig } from "../tickets/setupConfig.js";
9
+ /**
10
+ * Ticket Maker may append an explanatory note after its machine envelope.
11
+ * Preserve a schema-valid proposal before Foreman's strict generic marker
12
+ * repair path replaces the response with a status-only correction.
13
+ */
14
+ export declare function acceptTicketPopulationCompletion(text: string): StepStatus | undefined;
8
15
  /** Commander preserves comma-delimited values as one argument; accept both documented forms. */
9
16
  export declare function normalizeRiskWaiverTicketIds(values: readonly string[] | undefined): string[];
10
17
  /** Record an explicit, definition-bound exception for existing risk tickets. */
@@ -7,7 +7,7 @@ import { riskNoteRepairProposalV1Schema, zodIssues } from "rafi-spec";
7
7
  import { select, text, confirm, isCancel, multiselect } from "@clack/prompts";
8
8
  import { loadConfig } from "../config.js";
9
9
  import { Log } from "../log.js";
10
- import { Foreman } from "../foreman.js";
10
+ import { Foreman, parseStepStatus } from "../foreman.js";
11
11
  import { printEvents } from "./events.js";
12
12
  import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdStackQueue, cmdArchive, cmdReview, } from "../tickets/commands.js";
13
13
  import { loadDeliveryConfig } from "../tickets/delivery.js";
@@ -35,6 +35,21 @@ let nestedTicketCommandDepth = 0;
35
35
  class TicketInterviewCancelled extends Error {
36
36
  }
37
37
  function cancelTicketInterview() { throw new TicketInterviewCancelled("ticket interview cancelled"); }
38
+ /**
39
+ * Ticket Maker may append an explanatory note after its machine envelope.
40
+ * Preserve a schema-valid proposal before Foreman's strict generic marker
41
+ * repair path replaces the response with a status-only correction.
42
+ */
43
+ export function acceptTicketPopulationCompletion(text) {
44
+ try {
45
+ extractTicketPopulationProposal(text);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ const status = parseStepStatus(text, { requireFinalLine: false });
51
+ return status.kind === "done" || status.kind === "plan_complete" ? status : undefined;
52
+ }
38
53
  function fail(msg) {
39
54
  if (nestedTicketCommandDepth > 0)
40
55
  throw new Error(msg);
@@ -208,7 +223,7 @@ Output one JSON object between ${TICKET_POPULATION_PROPOSAL_START} and ${TICKET_
208
223
  {"version":1,"plan_id":"approved plan ID","revision":1,"tickets":[{"slice_ref":"slc_low","title":"...","area":"...","priority":"P1","size":"S","risk":"Low","summary":"...","acceptance":["..."],"required_tests":["..."],"likely_files":["..."],"documentation":{"required":false,"targets":[],"criteria":[]},"depends_on":[],"rollback":null,"notes":null},{"slice_ref":"slc_medium","title":"...","area":"...","priority":"P1","size":"S","risk":"Medium","summary":"...","acceptance":["..."],"required_tests":["..."],"likely_files":["..."],"documentation":{"required":false,"targets":[],"criteria":[]},"depends_on":["slc_low"],"rollback":"Disable the feature flag, restore the previous handler, and verify the affected error rate returns to baseline.","notes":null}],"retirements":["exact existing ticket ID"]}
209
224
  Triple-check that every approved slice appears exactly once.
210
225
 
211
- End with exactly one marker line as the final non-empty line:
226
+ End with exactly one marker line as the final non-empty line; do not append notes or commentary after it:
212
227
  STEP_STATUS: done | summary="populated Foreman tickets from existing project ticket sources"
213
228
  or
214
229
  STEP_STATUS: blocked | reason="why ticket population cannot proceed"`;
@@ -1465,7 +1480,7 @@ export async function cmdPopulateCli(opts) {
1465
1480
  if (contextSources.length) {
1466
1481
  console.log(`foreman tickets: context sources ${contextSources.join(" ")}`);
1467
1482
  }
1468
- let turn = await foreman.runInstruction(buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc));
1483
+ let turn = await foreman.runInstruction(buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc), { completion: acceptTicketPopulationCompletion });
1469
1484
  planningRecord = checkpointInterview(dir, planningRecord, { runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() }, decisions: { ...planningRecord.decisions, proposalResponse: turn.result.text } });
1470
1485
  log.write("ticket-populate", {
1471
1486
  statusKind: turn.status.kind,
package/dist/config.js CHANGED
@@ -84,9 +84,16 @@ export function loadConfig(path = "foreman.yaml") {
84
84
  ...(raw.notifications ?? {}),
85
85
  },
86
86
  qa: { ...DEFAULT_CONFIG.qa, ...(raw.qa ?? {}) },
87
- observability: { ...DEFAULT_CONFIG.observability, ...(raw.observability ?? {}) },
87
+ observability: normalizeObservabilityConfig({ ...DEFAULT_CONFIG.observability, ...(raw.observability ?? {}) }),
88
88
  };
89
89
  }
90
+ function normalizeObservabilityConfig(config) {
91
+ if (config.enabled === false)
92
+ return { ...config, mode: "off" };
93
+ if (config.mode === "off")
94
+ return { ...config, enabled: false };
95
+ return config;
96
+ }
90
97
  function validateConfig(raw, path) {
91
98
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
92
99
  throw new Error(`${path}: expected a YAML object`);
@@ -123,17 +130,19 @@ function validateObservability(value, path) {
123
130
  if (!value || typeof value !== "object" || Array.isArray(value))
124
131
  throw new Error(`${path}: observability must be an object`);
125
132
  const input = value;
126
- const keys = ["enabled", "sample_interval_seconds", "detail_retention_days", "log_retention_days", "detail_soft_limit_mb", "detail_hard_limit_mb", "log_limit_mb"];
133
+ const keys = ["enabled", "mode", "sample_interval_seconds", "detail_retention_days", "log_retention_days", "detail_soft_limit_mb", "detail_hard_limit_mb", "log_limit_mb"];
127
134
  for (const key of Object.keys(input))
128
135
  if (!keys.includes(key))
129
136
  throw new Error(`${path}: unknown observability setting ${key}`);
130
137
  if (input.enabled !== undefined && typeof input.enabled !== "boolean")
131
138
  throw new Error(`${path}: observability.enabled must be a boolean`);
132
- for (const key of keys.filter((key) => key !== "enabled")) {
139
+ if (input.mode !== undefined && !["off", "summary", "trace"].includes(String(input.mode)))
140
+ throw new Error(`${path}: observability.mode must be off, summary, or trace`);
141
+ for (const key of keys.filter((key) => key !== "enabled" && key !== "mode")) {
133
142
  if (input[key] !== undefined && (!Number.isInteger(input[key]) || Number(input[key]) <= 0))
134
143
  throw new Error(`${path}: observability.${key} must be a positive integer`);
135
144
  }
136
- const merged = { ...DEFAULT_OBSERVABILITY_CONFIG, ...input };
145
+ const merged = { ...DEFAULT_OBSERVABILITY_CONFIG, ...input, ...(input.enabled === false && input.mode === undefined ? { mode: "off" } : {}), ...(input.mode === "off" && input.enabled === undefined ? { enabled: false } : {}) };
137
146
  if (merged.detail_soft_limit_mb >= merged.detail_hard_limit_mb)
138
147
  throw new Error(`${path}: observability.detail_soft_limit_mb must be less than detail_hard_limit_mb`);
139
148
  }
package/dist/foreman.d.ts CHANGED
@@ -20,11 +20,18 @@ export interface StepStatus {
20
20
  branchDependency?: string;
21
21
  error?: string;
22
22
  }
23
+ /**
24
+ * Workflow-specific completion is opt-in. Callers may preserve a valid
25
+ * structured response before the generic strict STEP_STATUS repair path runs.
26
+ */
27
+ export type InstructionCompletion = (text: string) => StepStatus | undefined;
23
28
  /**
24
29
  * Pull the STEP_STATUS marker out of a turn's final text.
25
30
  * Format: `STEP_STATUS: <kind> | key="value" key="value"`.
26
31
  */
27
- export declare function parseStepStatus(text: string): StepStatus;
32
+ export declare function parseStepStatus(text: string, options?: {
33
+ requireFinalLine?: boolean;
34
+ }): StepStatus;
28
35
  /** Heuristic: did a marker-less turn end by asking the human something? */
29
36
  export declare function looksLikeQuestion(text: string): boolean;
30
37
  export { MARKER_SPEC } from "./markers.js";
@@ -126,7 +133,9 @@ export declare class Foreman {
126
133
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
127
134
  sendPreflightFeedback(feedback: string): Promise<void>;
128
135
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
129
- runInstruction(instruction: string): Promise<{
136
+ runInstruction(instruction: string, options?: {
137
+ completion?: InstructionCompletion;
138
+ }): Promise<{
130
139
  result: TurnResult;
131
140
  status: StepStatus;
132
141
  }>;
package/dist/foreman.js CHANGED
@@ -60,7 +60,7 @@ const QUESTION_HINTS = [
60
60
  * Pull the STEP_STATUS marker out of a turn's final text.
61
61
  * Format: `STEP_STATUS: <kind> | key="value" key="value"`.
62
62
  */
63
- export function parseStepStatus(text) {
63
+ export function parseStepStatus(text, options = {}) {
64
64
  const markerLines = text.split(/\r?\n/).filter((line) => /^\s*STEP_STATUS:/.test(line));
65
65
  const markerCount = markerLines.length;
66
66
  if (markerCount > 1) {
@@ -69,12 +69,14 @@ export function parseStepStatus(text) {
69
69
  const lines = text.trimEnd().split(/\r?\n/).filter((line) => line.trim().length > 0);
70
70
  const last = lines[lines.length - 1] ?? "";
71
71
  if (!last.includes("STEP_STATUS:")) {
72
- if (markerCount === 1) {
72
+ if (markerCount === 1 && options.requireFinalLine !== false) {
73
73
  return { kind: "unknown", error: "STEP_STATUS marker was not the final non-empty line" };
74
74
  }
75
- return { kind: "unknown" };
75
+ if (markerCount === 0)
76
+ return { kind: "unknown" };
76
77
  }
77
- const match = last.match(/^STEP_STATUS:\s*(done|blocked|plan_complete|needs_input|qa_pass|qa_fail)\b\s*(?:\|\s*(.*))?$/i);
78
+ const marker = options.requireFinalLine === false ? markerLines[0]?.trim() ?? "" : last;
79
+ const match = marker.match(/^STEP_STATUS:\s*(done|blocked|plan_complete|needs_input|qa_pass|qa_fail)\b\s*(?:\|\s*(.*))?$/i);
78
80
  if (!match) {
79
81
  return { kind: "unknown", error: "malformed STEP_STATUS marker" };
80
82
  }
@@ -438,12 +440,12 @@ export class Foreman {
438
440
  this.builder = await this.builderFactory(this.projectDir);
439
441
  this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
440
442
  }
441
- async doTurnWith(adapter, instruction, mode = "builder") {
443
+ async doTurnWith(adapter, instruction, mode = "builder", completion) {
442
444
  return this.projectDir
443
- ? withGraphSessionAccess(this.projectDir, adapter, () => this.doTurnWithOwned(adapter, instruction, mode))
444
- : this.doTurnWithOwned(adapter, instruction, mode);
445
+ ? withGraphSessionAccess(this.projectDir, adapter, () => this.doTurnWithOwned(adapter, instruction, mode, completion))
446
+ : this.doTurnWithOwned(adapter, instruction, mode, completion);
445
447
  }
446
- async doTurnWithOwned(adapter, instruction, mode = "builder") {
448
+ async doTurnWithOwned(adapter, instruction, mode = "builder", completion) {
447
449
  const ticket = mode === "builder" ? this.currentTicketId : undefined;
448
450
  const hostToolObservations = [];
449
451
  const scopedInstruction = (text) => !ticket || text.includes(`Ticket scope: ${ticket}.`) ? text
@@ -498,6 +500,11 @@ export class Foreman {
498
500
  let result = await send(instruction, turnPolicy);
499
501
  await this.observeBuilderNative(adapter);
500
502
  let status = parseStepStatus(result.text);
503
+ if (!result.isError && !result.failure) {
504
+ const completed = completion?.(result.text);
505
+ if (completed)
506
+ return { result, status: completed };
507
+ }
501
508
  if (ticket && validateBuildAssignment(ticket, status))
502
509
  return { result, status };
503
510
  if (result.isError || result.failure)
@@ -626,6 +633,11 @@ export class Foreman {
626
633
  if (result.isError || result.failure)
627
634
  return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
628
635
  status = parseStepStatus(result.text);
636
+ if (completion) {
637
+ const completed = completion(result.text);
638
+ if (completed)
639
+ return { result, status: completed };
640
+ }
629
641
  if (ticket && validateBuildAssignment(ticket, status))
630
642
  return { result, status };
631
643
  }
@@ -710,8 +722,8 @@ export class Foreman {
710
722
  this.preflightPlan = result.text;
711
723
  }
712
724
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
713
- async runInstruction(instruction) {
714
- return this.doTurn(instruction);
725
+ async runInstruction(instruction, options = {}) {
726
+ return this.doTurnWith(this.builder, instruction, "builder", options.completion);
715
727
  }
716
728
  /** Ask an already-active role session to propose choices for its reported blocker. */
717
729
  async resolveBlocker(adapter, reason, mode = "builder") {
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import type { QaContractCoverageV1, QaVerificationContractV1, QaContractCandidateV1 } from "rafi-spec";
3
+ export declare const hostGraphApplicabilityV1Schema: z.ZodObject<{
4
+ version: z.ZodLiteral<1>;
5
+ runId: z.ZodString;
6
+ ticketId: z.ZodString;
7
+ contractDigest: z.ZodString;
8
+ contractRevision: z.ZodNumber;
9
+ reviewAttemptId: z.ZodString;
10
+ reviewBasisDigest: z.ZodString;
11
+ sourceDigest: z.ZodString;
12
+ status: z.ZodEnum<{
13
+ unavailable: "unavailable";
14
+ disabled: "disabled";
15
+ unadopted: "unadopted";
16
+ "adopted-enabled": "adopted-enabled";
17
+ }>;
18
+ reason: z.ZodString;
19
+ observedAt: z.ZodString;
20
+ }, z.core.$strip>;
21
+ export type HostGraphApplicabilityV1 = z.infer<typeof hostGraphApplicabilityV1Schema>;
22
+ export declare function isGraphQaCheck(check: QaVerificationContractV1["checks"][number]): boolean;
23
+ export declare function findGraphQaChecks(contract: QaVerificationContractV1): QaVerificationContractV1["checks"];
24
+ export declare function normalizeGraphQaChecks(candidate: QaContractCandidateV1): QaContractCandidateV1;
25
+ export declare function createHostGraphApplicability(input: {
26
+ projectDir: string;
27
+ runId: string;
28
+ ticketId: string;
29
+ contract: QaVerificationContractV1;
30
+ reviewAttemptId: string;
31
+ reviewBasisDigest: string;
32
+ sourceDigest: string;
33
+ store: {
34
+ artifacts(kind: string): Array<{
35
+ digest: string;
36
+ value: unknown;
37
+ }>;
38
+ putArtifact(kind: string, value: unknown): string;
39
+ };
40
+ }): {
41
+ result: HostGraphApplicabilityV1;
42
+ resultDigest: string;
43
+ };
44
+ export declare function applyHostGraphApplicability(coverage: QaContractCoverageV1, contract: QaVerificationContractV1, result: HostGraphApplicabilityV1, resultDigest: string, projectDir: string): QaContractCoverageV1;
45
+ export declare function hostGraphApplicabilityDigestFromCoverage(coverage: QaContractCoverageV1, checkId: string): string | undefined;
@@ -0,0 +1,99 @@
1
+ import { z } from "zod";
2
+ import { loadGraphConfig } from "./graph/config.js";
3
+ const digest = z.string().regex(/^[a-f0-9]{64}$/);
4
+ export const hostGraphApplicabilityV1Schema = z.object({
5
+ version: z.literal(1),
6
+ runId: z.string().min(1),
7
+ ticketId: z.string().min(1),
8
+ contractDigest: digest,
9
+ contractRevision: z.number().int().positive(),
10
+ reviewAttemptId: z.string().min(1),
11
+ reviewBasisDigest: digest,
12
+ sourceDigest: digest,
13
+ status: z.enum(["adopted-enabled", "disabled", "unadopted", "unavailable"]),
14
+ reason: z.string().max(2048),
15
+ observedAt: z.string().datetime(),
16
+ });
17
+ function graphText(check) {
18
+ return [check.expectedBehavior, ...check.expectedEvidence, ...check.applicability.requiredEvidence,
19
+ ...check.verification.flatMap(method => [method.expectedOutcome, ...(method.steps ?? []), ...(method.argv ?? [])])].join(" ");
20
+ }
21
+ export function isGraphQaCheck(check) {
22
+ return /\bgraphify(?:y)?\b|\brafi[- ]managed graph(?:ify)?\b|\bgraphify adoption\b|\bgraphify[- ](?:receipt|evidence|command)\b/i.test(graphText(check));
23
+ }
24
+ export function findGraphQaChecks(contract) {
25
+ return contract.checks.filter(isGraphQaCheck);
26
+ }
27
+ export function normalizeGraphQaChecks(candidate) {
28
+ const copy = structuredClone(candidate);
29
+ for (const check of copy.checks) {
30
+ if (!isGraphQaCheck(check))
31
+ continue;
32
+ check.origin = "invariant";
33
+ check.obligation = "mandatory";
34
+ check.timing = "postimplementation";
35
+ check.applicability = {
36
+ kind: "conditional",
37
+ predicate: "Rafi-managed Graphify is adopted and enabled for this project",
38
+ decisionOwner: "host",
39
+ requiredEvidence: ["Host-owned Graphify applicability observation"],
40
+ };
41
+ }
42
+ return copy;
43
+ }
44
+ export function createHostGraphApplicability(input) {
45
+ let status = "unavailable";
46
+ let reason = "Rafi-managed Graphify applicability could not be observed; Graphify is optional and direct source inspection remains authoritative.";
47
+ try {
48
+ const graph = loadGraphConfig(input.projectDir);
49
+ status = graph.enabled ? "adopted-enabled" : graph.reason === "disabled" ? "disabled" : "unadopted";
50
+ reason = graph.enabled
51
+ ? "Rafi-managed Graphify is explicitly adopted and enabled."
52
+ : `Rafi-managed Graphify is not required: ${graph.reason}.`;
53
+ }
54
+ catch (error) {
55
+ reason = `Rafi-managed Graphify applicability is unavailable (${error instanceof Error ? error.message.slice(0, 400) : String(error).slice(0, 400)}); direct source inspection remains authoritative.`;
56
+ }
57
+ const result = hostGraphApplicabilityV1Schema.parse({ version: 1, runId: input.runId, ticketId: input.ticketId, contractDigest: input.contract.contentDigest, contractRevision: input.contract.revision, reviewAttemptId: input.reviewAttemptId, reviewBasisDigest: input.reviewBasisDigest, sourceDigest: input.sourceDigest, status, reason, observedAt: new Date().toISOString() });
58
+ const existing = input.store.artifacts("host-graph-applicability").find(item => {
59
+ const parsed = hostGraphApplicabilityV1Schema.safeParse(item.value);
60
+ return parsed.success && JSON.stringify(parsed.data) === JSON.stringify(result);
61
+ });
62
+ return { result, resultDigest: existing?.digest ?? input.store.putArtifact("host-graph-applicability", result) };
63
+ }
64
+ export function applyHostGraphApplicability(coverage, contract, result, resultDigest, projectDir) {
65
+ const parsed = hostGraphApplicabilityV1Schema.parse(result);
66
+ if (parsed.contractDigest !== contract.contentDigest || parsed.contractRevision !== contract.revision || parsed.runId !== contract.runId || parsed.ticketId !== contract.workId || parsed.sourceDigest !== coverage.sourceDigest || parsed.reviewAttemptId !== coverage.attemptId || parsed.reviewBasisDigest !== coverage.inputBasisDigest)
67
+ throw new Error("Host Graphify applicability is not bound to the current contract, review, or source");
68
+ const checks = findGraphQaChecks(contract);
69
+ if (!checks.length)
70
+ return coverage;
71
+ const copy = structuredClone(coverage);
72
+ for (const check of checks) {
73
+ const index = copy.checks.findIndex(row => row.checkId === check.id);
74
+ if (parsed.status !== "adopted-enabled") {
75
+ const row = {
76
+ checkId: check.id,
77
+ applicability: "not-applicable",
78
+ predicateEvidence: [`Host-owned Graphify applicability: ${parsed.status}. ${parsed.reason}`],
79
+ outcome: "passed",
80
+ evidence: [{ procedure: "Rafi host-owned Graphify applicability observation", cwd: projectDir, runtime: "Rafi host", sourceDigest: parsed.sourceDigest, reference: `host-graph-applicability:${resultDigest}` }],
81
+ };
82
+ if (index < 0)
83
+ copy.checks.push(row);
84
+ else
85
+ copy.checks[index] = row;
86
+ }
87
+ else if (index >= 0) {
88
+ copy.checks[index].applicability = "applicable";
89
+ copy.checks[index].predicateEvidence = [...new Set([...(copy.checks[index].predicateEvidence ?? []), "Host-owned Graphify applicability: adopted-enabled."])];
90
+ copy.checks[index].evidence.push({ procedure: "Rafi host-owned Graphify applicability observation", cwd: projectDir, runtime: "Rafi host", sourceDigest: parsed.sourceDigest, reference: `host-graph-applicability:${resultDigest}` });
91
+ }
92
+ }
93
+ return copy;
94
+ }
95
+ export function hostGraphApplicabilityDigestFromCoverage(coverage, checkId) {
96
+ const reference = coverage.checks.find(row => row.checkId === checkId)?.evidence.find(item => item.reference.startsWith("host-graph-applicability:"))?.reference;
97
+ const value = reference?.slice("host-graph-applicability:".length);
98
+ return value && /^[a-f0-9]{64}$/.test(value) ? value : undefined;
99
+ }
@@ -0,0 +1,18 @@
1
+ import type { QaContractCoverageV1, QaVerificationContractV1 } from "rafi-spec";
2
+ import { type HostQaComparisonV1 } from "./hostQaComparison.js";
3
+ import { type HostGraphApplicabilityV1 } from "./hostGraphApplicability.js";
4
+ /** Applies every host-owned coverage decision in one canonical order. */
5
+ export declare function applyHostQaAugmentations(input: {
6
+ coverage: QaContractCoverageV1;
7
+ contract: QaVerificationContractV1;
8
+ projectDir: string;
9
+ hostComparison?: {
10
+ result: HostQaComparisonV1;
11
+ resultDigest: string;
12
+ };
13
+ hostGraph?: {
14
+ result: HostGraphApplicabilityV1;
15
+ resultDigest: string;
16
+ };
17
+ commandObservations?: unknown[];
18
+ }): QaContractCoverageV1;