ai-foreman 1.7.26 → 1.7.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/codex.d.ts +12 -0
- package/dist/adapters/codex.js +50 -1
- package/dist/adapters/recovering.d.ts +4 -0
- package/dist/adapters/recovering.js +10 -0
- package/dist/adapters/sessionFailure.d.ts +6 -0
- package/dist/adapters/sessionFailure.js +19 -0
- package/dist/adapters/types.d.ts +6 -0
- package/dist/branch/currentGuard.d.ts +8 -0
- package/dist/branch/currentGuard.js +5 -0
- package/dist/branch/runner.js +6 -2
- package/dist/buildApproval.js +2 -1
- package/dist/buildRuns.d.ts +12 -0
- package/dist/buildRuns.js +50 -0
- package/dist/buildWorkContext.js +1 -1
- package/dist/buildWorkMigration.d.ts +10 -0
- package/dist/buildWorkMigration.js +18 -2
- package/dist/cli/start.d.ts +10 -1
- package/dist/cli/start.js +186 -31
- package/dist/cli/state.js +39 -1
- package/dist/cli/tickets.d.ts +37 -0
- package/dist/cli/tickets.js +367 -12
- package/dist/config.js +14 -4
- package/dist/continuity.d.ts +1 -0
- package/dist/continuity.js +5 -0
- package/dist/foreman.js +89 -8
- package/dist/graph/preparation.js +6 -3
- package/dist/graph/semantic.js +2 -1
- package/dist/graph/turn.js +8 -1
- package/dist/handoffProtocol.js +2 -1
- package/dist/handoffs.js +7 -19
- package/dist/hostQaComparison.d.ts +50 -0
- package/dist/hostQaComparison.js +195 -0
- package/dist/log.d.ts +1 -1
- package/dist/managerPacket.js +1 -1
- package/dist/observability.d.ts +2 -0
- package/dist/observability.js +2 -1
- package/dist/permissions/policy.js +32 -0
- package/dist/planChanges.js +1 -1
- package/dist/projectDiagnostics.js +30 -0
- package/dist/providerQuestions.js +4 -24
- package/dist/qaBuildGate.d.ts +6 -1
- package/dist/qaBuildGate.js +76 -16
- package/dist/qaBuilderCoverage.d.ts +2 -1
- package/dist/qaBuilderCoverage.js +58 -7
- package/dist/qaBuilderEvidence.d.ts +18 -0
- package/dist/qaBuilderEvidence.js +60 -0
- package/dist/qaContractCoverage.d.ts +12 -1
- package/dist/qaContractCoverage.js +34 -2
- package/dist/qaContractDelivery.d.ts +2 -2
- package/dist/qaContractDelivery.js +88 -10
- package/dist/qaEffectiveConfig.js +2 -1
- package/dist/qaFailureDelivery.d.ts +23 -0
- package/dist/qaFailureDelivery.js +87 -6
- package/dist/qaHostFacts.d.ts +2 -0
- package/dist/qaHostFacts.js +11 -0
- package/dist/qaPreparation.d.ts +38 -1
- package/dist/qaPreparation.js +372 -31
- package/dist/qaPreparationInputs.js +5 -1
- package/dist/qaPreparationPolicy.d.ts +2 -1
- package/dist/qaPreparationPolicy.js +39 -9
- package/dist/qaPreparationStore.d.ts +4 -0
- package/dist/qaPreparationStore.js +8 -0
- package/dist/qaRecovery.d.ts +7 -0
- package/dist/qaRecovery.js +73 -1
- package/dist/qaReview.js +58 -9
- package/dist/qaReviewEvidence.d.ts +6 -0
- package/dist/qaReviewEvidence.js +37 -0
- package/dist/qaRollbackRehearsal.d.ts +2 -0
- package/dist/qaRollbackRehearsal.js +78 -0
- package/dist/qaSnapshot.d.ts +3 -1
- package/dist/qaSnapshot.js +3 -2
- package/dist/qaVerificationContract.d.ts +16 -0
- package/dist/qaVerificationContract.js +183 -17
- package/dist/sources/sourceRegistry.js +16 -5
- package/dist/stateTransfer.d.ts +14 -0
- package/dist/stateTransfer.js +51 -0
- package/dist/statusReporter.d.ts +8 -0
- package/dist/statusReporter.js +5 -0
- package/dist/supervisedStart.js +23 -0
- package/dist/ticketPlanning.d.ts +373 -0
- package/dist/ticketPlanning.js +34 -3
- package/dist/ticketPopulation.d.ts +28 -3
- package/dist/ticketPopulation.js +127 -15
- package/dist/tickets/details.d.ts +2 -1
- package/dist/tickets/details.js +7 -3
- package/dist/tickets/eligibility.d.ts +6 -0
- package/dist/tickets/eligibility.js +12 -1
- package/dist/tickets/riskWaivers.d.ts +3 -0
- package/dist/tickets/riskWaivers.js +12 -0
- package/dist/tickets/stateDb.d.ts +10 -0
- package/dist/tickets/stateDb.js +23 -0
- package/dist/tickets/ticketLoader.d.ts +9 -0
- package/dist/tickets/ticketLoader.js +15 -2
- package/dist/tickets/ticketSchema.d.ts +14 -1
- package/dist/tickets/ticketSchema.js +2 -1
- package/dist/tickets/validate.js +11 -1
- package/dist/workflowDb.js +24 -3
- package/package.json +9 -4
package/dist/cli/tickets.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { accent, error, sanitizeTerminalText, success, warning } from "../terminalStyle.js";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { parse as parseYaml } from "yaml";
|
|
6
|
+
import { riskNoteRepairProposalV1Schema, zodIssues } from "rafi-spec";
|
|
6
7
|
import { select, text, confirm, isCancel, multiselect } from "@clack/prompts";
|
|
7
8
|
import { loadConfig } from "../config.js";
|
|
8
9
|
import { Log } from "../log.js";
|
|
@@ -19,14 +20,17 @@ import { DEFAULT_TICKET_SETUP, configuredPlanningSources, configuredAppName, def
|
|
|
19
20
|
import { deactivateSource, discardStagedSourceCaptures, loadSourceRegistry, refreshSourceRegistry, registerSourceRequests, removeSource, saveSourceRegistry, } from "../sources/sourceRegistry.js";
|
|
20
21
|
import { fetchAndSnapshotUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
|
|
21
22
|
import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, readInterviewRecords, resumeInterviewRecord, } from "../interviews.js";
|
|
22
|
-
import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, recoverTicketPublications, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
|
|
23
|
-
import { loadTickets } from "../tickets/ticketLoader.js";
|
|
23
|
+
import { applyTicketPopulation, assertRiskPlanOnlyCorrection, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, recoverTicketPublications, TicketPopulationValidationError, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
|
|
24
|
+
import { loadTickets, saveTickets, validateTicketDefs } from "../tickets/ticketLoader.js";
|
|
24
25
|
import { resolveTicketPaths } from "../tickets/config.js";
|
|
25
26
|
import { formatAllTicketDetails, formatTicketDetails, getAllTicketDetails, getTicketDetails } from "../tickets/details.js";
|
|
26
27
|
import { appendTicketOutput } from "../tickets/output.js";
|
|
27
28
|
import { applyResolvedTicketReset, previewTicketReset, recoverTicketResetPublications, resetTickets, resolveTicketResetSelection, TicketResetDependencyConflictError } from "../tickets/reset.js";
|
|
28
29
|
import { validateBranchPrefix } from "../branch/prefix.js";
|
|
29
30
|
import { listTicketGroups, previewTicketGroupRepair, repairTicketGroups } from "../tickets/groups.js";
|
|
31
|
+
import { StateDb } from "../tickets/stateDb.js";
|
|
32
|
+
import { ticketDefinitionDigest } from "../tickets/riskWaivers.js";
|
|
33
|
+
import { hasRollbackOrMitigationPlan } from "../tickets/ticketLoader.js";
|
|
30
34
|
let nestedTicketCommandDepth = 0;
|
|
31
35
|
class TicketInterviewCancelled extends Error {
|
|
32
36
|
}
|
|
@@ -52,12 +56,100 @@ function cwd(opts) {
|
|
|
52
56
|
function unique(values) {
|
|
53
57
|
return [...new Set(values)];
|
|
54
58
|
}
|
|
59
|
+
/** Commander preserves comma-delimited values as one argument; accept both documented forms. */
|
|
60
|
+
export function normalizeRiskWaiverTicketIds(values) {
|
|
61
|
+
return unique((values ?? []).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean));
|
|
62
|
+
}
|
|
63
|
+
/** Record an explicit, definition-bound exception for existing risk tickets. */
|
|
64
|
+
export function waiveTicketRiskNotes(projectDir, ticketIds, reason, actor = "operator") {
|
|
65
|
+
const ids = unique(ticketIds);
|
|
66
|
+
if (!ids.length)
|
|
67
|
+
throw new Error("provide exact ticket IDs with --tickets");
|
|
68
|
+
if (ids.length !== ticketIds.length)
|
|
69
|
+
throw new Error("--tickets must not contain duplicate IDs");
|
|
70
|
+
if (!reason.trim())
|
|
71
|
+
throw new Error("--reason is required when waiving risk plans");
|
|
72
|
+
const config = loadTicketsConfig(projectDir);
|
|
73
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
74
|
+
const definitions = loadTickets(paths.tickets);
|
|
75
|
+
const selected = ids.map((id) => definitions.find((ticket) => ticket.id === id));
|
|
76
|
+
if (selected.some((ticket) => !ticket))
|
|
77
|
+
throw new Error(`unknown ticket ID(s): ${ids.filter((id, index) => !selected[index]).join(", ")}`);
|
|
78
|
+
for (const ticket of selected) {
|
|
79
|
+
if (ticket.risk === "Low" || hasRollbackOrMitigationPlan(ticket.rollback))
|
|
80
|
+
throw new Error(`${ticket.id} does not have a missing Medium/High-risk rollback plan to waive`);
|
|
81
|
+
}
|
|
82
|
+
const db = new StateDb(paths.stateDb);
|
|
83
|
+
try {
|
|
84
|
+
db.transaction(() => {
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
for (const ticket of selected) {
|
|
87
|
+
db.upsertTicketRiskWaiver({ ticket_id: ticket.id, definition_digest: ticketDefinitionDigest(ticket), reason: reason.trim(), waived_at: now, waived_by: actor });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
db.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function riskWaiverWarnings(projectDir, ticketIds) {
|
|
96
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
97
|
+
const definitions = new Map(loadTickets(paths.tickets).map((ticket) => [ticket.id, ticket]));
|
|
98
|
+
const db = new StateDb(paths.stateDb, { readOnly: true });
|
|
99
|
+
try {
|
|
100
|
+
return ticketIds.flatMap((id) => {
|
|
101
|
+
const ticket = definitions.get(id);
|
|
102
|
+
const waiver = ticket && db.getTicketRiskWaiver(id, ticketDefinitionDigest(ticket));
|
|
103
|
+
return waiver ? [` Warning: missing risk plan explicitly waived${waiver.waived_by ? ` by ${sanitizeTerminalText(waiver.waived_by)}` : ""}; ${sanitizeTerminalText(waiver.reason)}`] : [];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
db.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
55
110
|
function optionWasProvided(command, name) {
|
|
56
111
|
const source = command.getOptionValueSource(name);
|
|
57
112
|
return source !== undefined && source !== "default";
|
|
58
113
|
}
|
|
59
114
|
const TICKET_POPULATE_ROLE = "ticket-maker";
|
|
60
115
|
const RAFI_CONFIG_FILES = ["rafi-config.yaml", "project.yaml"];
|
|
116
|
+
const RISK_NOTE_REPAIR_START = "RAFI_RISK_NOTE_REPAIR_START";
|
|
117
|
+
const RISK_NOTE_REPAIR_END = "RAFI_RISK_NOTE_REPAIR_END";
|
|
118
|
+
export function extractRiskNoteRepairProposal(output) {
|
|
119
|
+
const start = output.lastIndexOf(RISK_NOTE_REPAIR_START);
|
|
120
|
+
const end = output.lastIndexOf(RISK_NOTE_REPAIR_END);
|
|
121
|
+
if (start < 0 || end <= start)
|
|
122
|
+
throw new Error(`Ticket Maker repair output is missing ${RISK_NOTE_REPAIR_START}/${RISK_NOTE_REPAIR_END}`);
|
|
123
|
+
const raw = output.slice(start + RISK_NOTE_REPAIR_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(raw);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
throw new Error(`Ticket Maker risk-note repair is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
130
|
+
}
|
|
131
|
+
const validated = riskNoteRepairProposalV1Schema.safeParse(parsed);
|
|
132
|
+
if (!validated.success)
|
|
133
|
+
throw new Error(`Ticket Maker risk-note repair contract validation failed:\n${zodIssues(validated.error).map((issue) => `- ${issue}`).join("\n")}`);
|
|
134
|
+
return validated.data;
|
|
135
|
+
}
|
|
136
|
+
/** Validate a deliberately narrow repair: only exactly the missing rollback fields may change. */
|
|
137
|
+
export function applyRiskNoteRepairProposal(tickets, proposal) {
|
|
138
|
+
const targets = tickets.filter(ticket => ticket.risk !== "Low" && !hasRollbackOrMitigationPlan(ticket.rollback));
|
|
139
|
+
const expected = targets.map(ticket => ticket.id).sort();
|
|
140
|
+
const received = proposal.tickets.map(ticket => ticket.id).sort();
|
|
141
|
+
if (proposal.version !== 1 || new Set(received).size !== received.length || JSON.stringify(received) !== JSON.stringify(expected))
|
|
142
|
+
throw new Error(`risk-note repair must contain exactly these ticket IDs: ${expected.join(", ") || "none"}`);
|
|
143
|
+
const byId = new Map(proposal.tickets.map(ticket => [ticket.id, ticket.rollback]));
|
|
144
|
+
for (const [id, rollback] of byId)
|
|
145
|
+
if (typeof rollback !== "string" || !rollback.trim())
|
|
146
|
+
throw new Error(`risk-note repair for ${id} needs a concrete non-empty rollback or mitigation plan`);
|
|
147
|
+
const repaired = tickets.map(ticket => byId.has(ticket.id) ? { ...ticket, rollback: byId.get(ticket.id).trim() } : ticket);
|
|
148
|
+
const validation = validateTicketDefs(repaired);
|
|
149
|
+
if (validation.length)
|
|
150
|
+
throw new Error(`risk-note repair did not produce a valid ticket set: ${validation.map(issue => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
151
|
+
return repaired;
|
|
152
|
+
}
|
|
61
153
|
function validateEffort(effort) {
|
|
62
154
|
try {
|
|
63
155
|
assertEffortLevel(effort);
|
|
@@ -101,7 +193,11 @@ Return ticket content using Foreman's schema:
|
|
|
101
193
|
- slice_ref: the exact approved plan slice reference. Do not allocate ticket IDs.
|
|
102
194
|
- order: unique numeric implementation order. Use gaps like 1000, 2000, 3000.
|
|
103
195
|
- title, area, priority, size, risk, depends_on, summary, acceptance, required_tests, likely_files, rollback, notes.
|
|
104
|
-
- Keep dependencies, acceptance criteria, testing expectations, file hints, risk notes, and implementation notes from the source material.
|
|
196
|
+
- Keep dependencies, acceptance criteria, testing expectations, file hints, risk notes, and implementation notes from the source material. For each ticket, use only the mapped approved slice's acceptance and required tests; do not copy plan-level acceptance criteria or add requirements about sibling slices or unfinished tickets. A ticket's acceptance must remain implementable and verifiable when its own slice is delivered.
|
|
197
|
+
- Preserve additive-slice independence without inventing an absence test: each ticket's own behavior and tests should work against the shared approved baseline and must not depend on sibling implementation. Do not add wording such as "with the sibling absent/present," "without [sibling] present," or "with this slice alone" unless the approved plan explicitly requires sibling absence testing or an isolated branch. Otherwise use positive own-ticket checks, the shared project tests, and the combined-result check.
|
|
198
|
+
- Preserve the approved slice's documentation policy exactly. Documentation is off by default; do not invent a documentation requirement because a docs directory exists. When documentation is required, retain its exact targets and criteria so the Builder and independent QA can enforce it.
|
|
199
|
+
- File names and likely_files are implementation hints, not an exhaustive allowlist. Do not invent acceptance criteria or tests that require an exact changed-file list; allow other relevant files needed to complete the approved work. Preserve explicit user-established security, privacy, access, and other safety restrictions. Rafi manages ticket lifecycle state; do not require the Builder to mark tickets done in a document.
|
|
200
|
+
- For every Medium or High risk ticket, \`rollback\` is required. It must state a concrete rollback or mitigation action: what is disabled, reverted, or contained, and how recovery or impact is verified. \`notes\` may add context but never satisfies this requirement. Low-risk tickets may use \`rollback: null\`.
|
|
105
201
|
- Do not store mutable status/progress fields in .tickets/tickets.yaml.
|
|
106
202
|
- Dependencies must reference slice_ref values. Include the exact IDs of existing populated tickets whose removed slices should become obsolete.
|
|
107
203
|
|
|
@@ -109,7 +205,7 @@ If source content does not cleanly map to the new schema, ask for guidance inste
|
|
|
109
205
|
STEP_STATUS: needs_input | question="..." choices="..."
|
|
110
206
|
|
|
111
207
|
Output one JSON object between ${TICKET_POPULATION_PROPOSAL_START} and ${TICKET_POPULATION_PROPOSAL_END} with shape:
|
|
112
|
-
{"version":1,"plan_id":"approved plan ID","revision":1,"tickets":[{"slice_ref":"
|
|
208
|
+
{"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"]}
|
|
113
209
|
Triple-check that every approved slice appears exactly once.
|
|
114
210
|
|
|
115
211
|
End with exactly one marker line as the final non-empty line:
|
|
@@ -117,6 +213,52 @@ STEP_STATUS: done | summary="populated Foreman tickets from existing project tic
|
|
|
117
213
|
or
|
|
118
214
|
STEP_STATUS: blocked | reason="why ticket population cannot proceed"`;
|
|
119
215
|
}
|
|
216
|
+
/** A bounded correction loop ended without a publishable proposal. */
|
|
217
|
+
export class TicketPopulationRiskCorrectionExhaustedError extends Error {
|
|
218
|
+
ticketIds;
|
|
219
|
+
projectDir;
|
|
220
|
+
interviewId;
|
|
221
|
+
proposalFile;
|
|
222
|
+
constructor(ticketIds, projectDir, interviewId, proposalFile) {
|
|
223
|
+
super([
|
|
224
|
+
`Ticket Maker did not provide usable rollback or mitigation plans for ${ticketIds.join(", ")} after two targeted corrections. No tickets were published.`,
|
|
225
|
+
`The population session was retained as ${interviewId}.`,
|
|
226
|
+
`Retry: rafi tickets populate --project ${shellQuote(projectDir)} --interview-id ${shellQuote(interviewId)}`,
|
|
227
|
+
`Or edit the frozen proposal ${shellQuote(proposalFile)}, then apply it with: rafi tickets populate --project ${shellQuote(projectDir)} --proposal-file ${shellQuote(proposalFile)}`,
|
|
228
|
+
"Or update the approved plan/context with rollback guidance and run `rafi tickets populate` again.",
|
|
229
|
+
].join("\n"));
|
|
230
|
+
this.name = "TicketPopulationRiskCorrectionExhaustedError";
|
|
231
|
+
this.ticketIds = [...ticketIds];
|
|
232
|
+
this.projectDir = projectDir;
|
|
233
|
+
this.interviewId = interviewId;
|
|
234
|
+
this.proposalFile = proposalFile;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function populationProposalArtifactPath(projectDir, interviewId) {
|
|
238
|
+
return join(projectDir, ".rafi", "ticket-population-proposals", `${interviewId}.json`);
|
|
239
|
+
}
|
|
240
|
+
function savePopulationProposalArtifact(projectDir, interviewId, approvedPlan, proposal) {
|
|
241
|
+
const path = populationProposalArtifactPath(projectDir, interviewId);
|
|
242
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
243
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, approved_plan: { path: approvedPlan.path, plan_id: approvedPlan.plan.plan_id, revision: approvedPlan.plan.revision }, proposal }, null, 2)}\n`, "utf8");
|
|
244
|
+
return path;
|
|
245
|
+
}
|
|
246
|
+
function readPopulationProposalArtifact(projectDir, source) {
|
|
247
|
+
const path = resolve(projectDir, source);
|
|
248
|
+
if (!existsSync(path))
|
|
249
|
+
throw new Error(`population proposal file does not exist: ${path}`);
|
|
250
|
+
let parsed;
|
|
251
|
+
try {
|
|
252
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
throw new Error(`population proposal file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
256
|
+
}
|
|
257
|
+
const proposal = parsed && typeof parsed === "object" && "proposal" in parsed ? parsed.proposal : parsed;
|
|
258
|
+
if (!proposal || typeof proposal !== "object")
|
|
259
|
+
throw new Error("population proposal file must contain a proposal object");
|
|
260
|
+
return proposal;
|
|
261
|
+
}
|
|
120
262
|
export function resolvePopulateSources(projectDir, explicitSources, ticketsConfig) {
|
|
121
263
|
if (explicitSources && explicitSources.length > 0)
|
|
122
264
|
return explicitSources;
|
|
@@ -167,6 +309,41 @@ export function buildPopulateAgentRunOptions(opts) {
|
|
|
167
309
|
resumeSessionId: opts.resumeSession,
|
|
168
310
|
};
|
|
169
311
|
}
|
|
312
|
+
async function resolvePopulationRiskWaiver(opts, error) {
|
|
313
|
+
const expected = [...error.ticketIds].sort();
|
|
314
|
+
const requested = normalizeRiskWaiverTicketIds(opts.waiveRiskNotes).sort();
|
|
315
|
+
const exact = JSON.stringify(expected) === JSON.stringify(requested);
|
|
316
|
+
if (requested.length || opts.waiverReason) {
|
|
317
|
+
if (!exact)
|
|
318
|
+
throw new Error(`--waive-risk-notes must name exactly the unresolved tickets: ${expected.join(", ")}`);
|
|
319
|
+
if (!opts.waiverReason?.trim())
|
|
320
|
+
throw new Error("--waiver-reason is required with --waive-risk-notes");
|
|
321
|
+
return { ticketIds: expected, reason: opts.waiverReason.trim(), actor: opts.waiverActor?.trim() || "operator" };
|
|
322
|
+
}
|
|
323
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
324
|
+
return undefined;
|
|
325
|
+
const action = await select({
|
|
326
|
+
message: `Ticket Maker could not supply rollback plans for ${expected.join(", ")}. What next?`,
|
|
327
|
+
options: [
|
|
328
|
+
{ value: "retry", label: "Repair all missing plans with Ticket Maker (Recommended)" },
|
|
329
|
+
{ value: "waive", label: "Publish with explicit risk-plan waivers" },
|
|
330
|
+
{ value: "manual", label: "Review or edit the saved proposal manually" },
|
|
331
|
+
{ value: "cancel", label: "Cancel and retain the saved population session" },
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
if (isCancel(action) || action === "cancel")
|
|
335
|
+
return undefined;
|
|
336
|
+
if (action === "retry" || action === "manual") {
|
|
337
|
+
const guidance = action === "retry"
|
|
338
|
+
? `Retry: rafi tickets populate --project ${shellQuote(error.projectDir)} --interview-id ${shellQuote(error.interviewId)}`
|
|
339
|
+
: `Edit ${shellQuote(error.proposalFile)}, then validate and apply it: rafi tickets populate --project ${shellQuote(error.projectDir)} --proposal-file ${shellQuote(error.proposalFile)}`;
|
|
340
|
+
throw new Error(guidance);
|
|
341
|
+
}
|
|
342
|
+
const reason = await text({ message: `Reason for waiving rollback plans for ${expected.join(", ")}:`, validate: value => String(value ?? "").trim() ? undefined : "A waiver reason is required" });
|
|
343
|
+
if (isCancel(reason))
|
|
344
|
+
return undefined;
|
|
345
|
+
return { ticketIds: expected, reason: String(reason).trim(), actor: opts.waiverActor?.trim() || "operator" };
|
|
346
|
+
}
|
|
170
347
|
async function cmdSetupInitCli(opts) {
|
|
171
348
|
const dir = cwd(opts);
|
|
172
349
|
if (!existsSync(dir))
|
|
@@ -1213,6 +1390,32 @@ export async function cmdPopulateCli(opts) {
|
|
|
1213
1390
|
}
|
|
1214
1391
|
const approvedPlan = approvedPlanForMode ?? resolvePopulationPlan(dir, sourceHints, ticketsConfig);
|
|
1215
1392
|
const contextSources = sourceHints.filter((source) => !isApprovedPlanArtifact(dir, source, approvedPlan.path));
|
|
1393
|
+
// A retained proposal is an intentionally narrow manual recovery route. It
|
|
1394
|
+
// still goes through the exact same materialization and publication checks as
|
|
1395
|
+
// an agent response; editing JSON never grants a bypass.
|
|
1396
|
+
if (opts.proposalFile) {
|
|
1397
|
+
const proposal = readPopulationProposalArtifact(dir, opts.proposalFile);
|
|
1398
|
+
const requestedWaiverIds = normalizeRiskWaiverTicketIds(opts.waiveRiskNotes);
|
|
1399
|
+
const materialized = materializeTicketPopulation(proposal, approvedPlan.plan, loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets), {
|
|
1400
|
+
waiveRiskTicketIds: requestedWaiverIds,
|
|
1401
|
+
waiverReason: opts.waiverReason,
|
|
1402
|
+
waiverActor: opts.waiverActor,
|
|
1403
|
+
branchStrategy: setup?.build.branch_strategy,
|
|
1404
|
+
});
|
|
1405
|
+
if (!opts.yes) {
|
|
1406
|
+
const approved = await confirm({ message: "Apply this manually edited ticket population proposal?", initialValue: false });
|
|
1407
|
+
if (isCancel(approved) || !approved) {
|
|
1408
|
+
console.log("foreman tickets: cancelled; no files or tracker state changed");
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
authorizeTicketRetirements(materialized.retirements, { authorizedIds: opts.authorizeRetire, computerRun: Boolean(opts.yes), interactiveConfirmed: !opts.yes });
|
|
1413
|
+
const applied = applyTicketPopulation(dir, materialized);
|
|
1414
|
+
console.log(`foreman tickets: applied manually reviewed proposal atomically (run ${applied.runId}, transaction ${applied.transactionId})`);
|
|
1415
|
+
if (materialized.riskWaivers?.length)
|
|
1416
|
+
console.log(`foreman tickets: explicit risk-plan waivers recorded for ${materialized.riskWaivers.map((waiver) => waiver.ticketId).join(", ")}`);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1216
1419
|
if (!opts.yes) {
|
|
1217
1420
|
const action = await select({
|
|
1218
1421
|
message: "Ask the read-only Ticket Maker for a structured population proposal?",
|
|
@@ -1244,7 +1447,10 @@ export async function cmdPopulateCli(opts) {
|
|
|
1244
1447
|
fast: opts.fast,
|
|
1245
1448
|
yes: Boolean(opts.yes),
|
|
1246
1449
|
log,
|
|
1247
|
-
|
|
1450
|
+
// A recovered interview should continue its recorded Ticket Maker
|
|
1451
|
+
// session unless the caller deliberately supplied another one or the
|
|
1452
|
+
// interview already recorded a lost-continuity boundary.
|
|
1453
|
+
resumeSession: opts.resumeSession ?? (savedPlanning?.continuityLost ? undefined : savedPlanning?.runtime.sessionId),
|
|
1248
1454
|
}));
|
|
1249
1455
|
builder = roleBuilder.builder;
|
|
1250
1456
|
planningRecord = checkpointInterview(dir, planningRecord, { runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() } });
|
|
@@ -1259,10 +1465,8 @@ export async function cmdPopulateCli(opts) {
|
|
|
1259
1465
|
if (contextSources.length) {
|
|
1260
1466
|
console.log(`foreman tickets: context sources ${contextSources.join(" ")}`);
|
|
1261
1467
|
}
|
|
1262
|
-
|
|
1468
|
+
let turn = await foreman.runInstruction(buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc));
|
|
1263
1469
|
planningRecord = checkpointInterview(dir, planningRecord, { runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() }, decisions: { ...planningRecord.decisions, proposalResponse: turn.result.text } });
|
|
1264
|
-
await builder.close();
|
|
1265
|
-
await viewer;
|
|
1266
1470
|
log.write("ticket-populate", {
|
|
1267
1471
|
statusKind: turn.status.kind,
|
|
1268
1472
|
summary: turn.status.summary,
|
|
@@ -1280,8 +1484,51 @@ export async function cmdPopulateCli(opts) {
|
|
|
1280
1484
|
throw new Error(`needs human — ${turn.status.error ?? "builder did not emit done"}`);
|
|
1281
1485
|
}
|
|
1282
1486
|
const existing = loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets);
|
|
1283
|
-
|
|
1284
|
-
|
|
1487
|
+
let proposal = extractTicketPopulationProposal(turn.result.text);
|
|
1488
|
+
let proposalArtifact = savePopulationProposalArtifact(dir, planningRecord.id, approvedPlan, proposal);
|
|
1489
|
+
let materialized;
|
|
1490
|
+
let populationWaiver;
|
|
1491
|
+
for (let correction = 0;; correction++) {
|
|
1492
|
+
try {
|
|
1493
|
+
materialized = materializeTicketPopulation(proposal, approvedPlan.plan, existing, {
|
|
1494
|
+
waiveRiskTicketIds: populationWaiver?.ticketIds,
|
|
1495
|
+
waiverReason: populationWaiver?.reason,
|
|
1496
|
+
waiverActor: populationWaiver?.actor,
|
|
1497
|
+
branchStrategy: setup?.build.branch_strategy,
|
|
1498
|
+
});
|
|
1499
|
+
break;
|
|
1500
|
+
}
|
|
1501
|
+
catch (err) {
|
|
1502
|
+
if (!(err instanceof TicketPopulationValidationError) || !err.onlyRepairableRiskFailures)
|
|
1503
|
+
throw err;
|
|
1504
|
+
const ids = err.repairableRiskTicketIds.join(", ");
|
|
1505
|
+
if (correction >= 2) {
|
|
1506
|
+
const exhausted = new TicketPopulationRiskCorrectionExhaustedError(err.repairableRiskTicketIds, dir, planningRecord.id, proposalArtifact);
|
|
1507
|
+
populationWaiver = await resolvePopulationRiskWaiver(opts, exhausted);
|
|
1508
|
+
if (!populationWaiver)
|
|
1509
|
+
throw exhausted;
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
console.log(`foreman tickets: ${ids} need risk plans; asking Ticket Maker for a targeted correction (${correction + 1}/2)`);
|
|
1513
|
+
const correctionTurn = await foreman.runInstruction(`Correct the existing ticket population proposal only. Do not change plan_id, revision, slice_ref mappings, ticket IDs, dependencies, scope, ownership, acceptance criteria, tests, retirements, or any field except \`rollback\` for these tickets: ${ids}. Each listed ticket is Medium or High risk and requires a concrete rollback or mitigation plan in \`rollback\`; \`notes\` cannot satisfy this requirement. Return the complete replacement JSON proposal between ${TICKET_POPULATION_PROPOSAL_START} and ${TICKET_POPULATION_PROPOSAL_END}, followed by STEP_STATUS: done.`);
|
|
1514
|
+
if (correctionTurn.result.isError || (correctionTurn.status.kind !== "done" && correctionTurn.status.kind !== "plan_complete")) {
|
|
1515
|
+
throw new Error(`Ticket Maker could not correct missing risk plans for ${ids}: ${correctionTurn.status.reason ?? correctionTurn.status.error ?? correctionTurn.result.text.slice(0, 200)}`);
|
|
1516
|
+
}
|
|
1517
|
+
turn = correctionTurn;
|
|
1518
|
+
planningRecord = checkpointInterview(dir, planningRecord, {
|
|
1519
|
+
runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() },
|
|
1520
|
+
decisions: { ...planningRecord.decisions, proposalResponse: turn.result.text, riskPlanCorrections: correction + 1 },
|
|
1521
|
+
});
|
|
1522
|
+
const corrected = extractTicketPopulationProposal(turn.result.text);
|
|
1523
|
+
assertRiskPlanOnlyCorrection(proposal, corrected, err.repairableRiskSliceRefs);
|
|
1524
|
+
proposal = corrected;
|
|
1525
|
+
proposalArtifact = savePopulationProposalArtifact(dir, planningRecord.id, approvedPlan, proposal);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
await builder.close();
|
|
1529
|
+
builder = undefined;
|
|
1530
|
+
await viewer;
|
|
1531
|
+
viewer = undefined;
|
|
1285
1532
|
let retirementsConfirmed = false;
|
|
1286
1533
|
if (!opts.yes) {
|
|
1287
1534
|
console.log(`foreman tickets: proposal maps ${materialized.sliceToTicket.size} slice(s) and retires ${materialized.retirements.join(", ") || "none"}`);
|
|
@@ -1303,12 +1550,71 @@ export async function cmdPopulateCli(opts) {
|
|
|
1303
1550
|
const applied = applyTicketPopulation(dir, materialized);
|
|
1304
1551
|
completeInterview(dir, planningRecord);
|
|
1305
1552
|
console.log(`foreman tickets: populated atomically (run ${applied.runId}, transaction ${applied.transactionId})`);
|
|
1553
|
+
if (materialized.riskWaivers?.length)
|
|
1554
|
+
console.log(`foreman tickets: explicit risk-plan waivers recorded for ${materialized.riskWaivers.map((waiver) => waiver.ticketId).join(", ")}`);
|
|
1306
1555
|
}
|
|
1307
1556
|
catch (err) {
|
|
1308
1557
|
failInterview(dir, planningRecord, planningRecord.checkpoint, err);
|
|
1309
1558
|
await builder?.close().catch(() => { });
|
|
1310
1559
|
await viewer?.catch(() => { });
|
|
1311
|
-
|
|
1560
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1561
|
+
fail(err instanceof TicketPopulationRiskCorrectionExhaustedError ? message : `${message}\nRecover with \`rafi plan:doctor\`.`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
/** Ask Ticket Maker for a reviewable, rollback-only repair of published tickets. */
|
|
1565
|
+
export async function cmdRepairRiskNotesCli(opts) {
|
|
1566
|
+
const dir = cwd(opts);
|
|
1567
|
+
validateEffort(opts.effort);
|
|
1568
|
+
if (!isTicketsInitialized(dir))
|
|
1569
|
+
throw new Error(`ticket tracker is not initialized in ${dir}`);
|
|
1570
|
+
const paths = resolveTicketPaths(loadTicketsConfig(dir), dir);
|
|
1571
|
+
const current = loadTickets(paths.tickets);
|
|
1572
|
+
const targets = current.filter(ticket => ticket.risk !== "Low" && !hasRollbackOrMitigationPlan(ticket.rollback));
|
|
1573
|
+
if (!targets.length) {
|
|
1574
|
+
console.log("foreman tickets: no Medium/High-risk tickets are missing rollback plans");
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
const log = new Log(makeLogPath(dir, "tickets-risk-note-repair"));
|
|
1578
|
+
const roleBuilder = await createRoleBuilder(buildPopulateAgentRunOptions({ projectDir: dir, agent: opts.agent, model: opts.model, effort: opts.effort, fast: opts.fast, yes: Boolean(opts.yes), log }));
|
|
1579
|
+
const builder = roleBuilder.builder;
|
|
1580
|
+
const viewer = printEvents(builder.events());
|
|
1581
|
+
try {
|
|
1582
|
+
const foreman = new Foreman(builder, log, { desktop: loadConfig(join(dir, "foreman.yaml")).notifications.enabled, terminalBell: false }, false, 3, dir);
|
|
1583
|
+
const instruction = [
|
|
1584
|
+
"You are Ticket Maker in read-only repair mode. Do not edit files.",
|
|
1585
|
+
"For every listed ticket, supply a concrete rollback or mitigation plan. Do not change any other field or add/remove tickets.",
|
|
1586
|
+
`Tickets requiring repair:\n${JSON.stringify(targets.map(ticket => ({ id: ticket.id, title: ticket.title, risk: ticket.risk, summary: ticket.summary, acceptance: ticket.acceptance, required_tests: ticket.required_tests })), null, 2)}`,
|
|
1587
|
+
`Return exactly one JSON object between ${RISK_NOTE_REPAIR_START} and ${RISK_NOTE_REPAIR_END}: {"version":1,"tickets":[{"id":"T001","rollback":"Concrete reversal or mitigation steps."}]}.`,
|
|
1588
|
+
"End with STEP_STATUS: done | summary=\"risk-note repair proposed\"",
|
|
1589
|
+
].join("\n\n");
|
|
1590
|
+
const turn = await foreman.runInstruction(instruction);
|
|
1591
|
+
if (turn.result.isError || (turn.status.kind !== "done" && turn.status.kind !== "plan_complete"))
|
|
1592
|
+
throw new Error(`Ticket Maker could not propose risk-plan repairs: ${turn.status.reason ?? turn.status.error ?? turn.result.text.slice(0, 300)}`);
|
|
1593
|
+
const proposal = extractRiskNoteRepairProposal(turn.result.text);
|
|
1594
|
+
const repaired = applyRiskNoteRepairProposal(current, proposal);
|
|
1595
|
+
for (const target of targets)
|
|
1596
|
+
console.log(` ${target.id}: ${proposal.tickets.find(item => item.id === target.id).rollback.trim()}`);
|
|
1597
|
+
if (!opts.apply) {
|
|
1598
|
+
console.log("foreman tickets: preview only; rerun with `rafi tickets repair --risk-notes --apply` to write this exact class of change.");
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (!opts.yes) {
|
|
1602
|
+
const approved = await confirm({ message: `Apply rollback-only repairs to ${targets.map(ticket => ticket.id).join(", ")}?`, initialValue: false });
|
|
1603
|
+
if (isCancel(approved) || !approved) {
|
|
1604
|
+
console.log("foreman tickets: cancelled; no ticket definitions changed");
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
saveTickets(paths.tickets, repaired);
|
|
1609
|
+
cmdRender(dir);
|
|
1610
|
+
const validation = cmdValidate(dir);
|
|
1611
|
+
if (!validation.clean)
|
|
1612
|
+
throw new Error(`risk-note repair was written but validation still fails: ${validation.issues.map(issue => issue.message).join("; ")}`);
|
|
1613
|
+
console.log(`foreman tickets: applied rollback-only repair for ${targets.map(ticket => ticket.id).join(", ")}`);
|
|
1614
|
+
}
|
|
1615
|
+
finally {
|
|
1616
|
+
await builder.close().catch(() => { });
|
|
1617
|
+
await viewer.catch(() => { });
|
|
1312
1618
|
}
|
|
1313
1619
|
}
|
|
1314
1620
|
/** Resolve only the strategy. Context documents never select an import mode. */
|
|
@@ -1736,7 +2042,11 @@ export function buildTicketsCommand(options = {}) {
|
|
|
1736
2042
|
.option("--fast", "fast mode - lower latency")
|
|
1737
2043
|
.option("--resume-session <id>", "resume a saved Ticket Maker session (normally selected by plan:doctor)")
|
|
1738
2044
|
.option("--interview-id <id>", "recover an exact saved population interview (normally selected by plan:doctor)")
|
|
2045
|
+
.option("--proposal-file <path>", "validate and publish a manually reviewed frozen population proposal")
|
|
1739
2046
|
.option("--authorize-retire <ids...>", "exact ticket IDs authorized to become obsolete in computer-run mode")
|
|
2047
|
+
.option("--waive-risk-notes <ids...>", "publish only these exact unresolved Medium/High-risk tickets with explicit risk-plan waivers")
|
|
2048
|
+
.option("--waiver-reason <text>", "required rationale for --waive-risk-notes")
|
|
2049
|
+
.option("--waiver-actor <name>", "operator recorded with the population waiver", "operator")
|
|
1740
2050
|
.option("-y, --yes", "computer-run approval; retirements still require --authorize-retire exact IDs")
|
|
1741
2051
|
.action(async (opts) => {
|
|
1742
2052
|
try {
|
|
@@ -1782,6 +2092,44 @@ export function buildTicketsCommand(options = {}) {
|
|
|
1782
2092
|
fail(String(err instanceof Error ? err.message : err));
|
|
1783
2093
|
}
|
|
1784
2094
|
});
|
|
2095
|
+
tickets
|
|
2096
|
+
.command("waive-risk-notes")
|
|
2097
|
+
.description("Record an explicit, definition-bound waiver for missing Medium/High-risk rollback plans.")
|
|
2098
|
+
.requiredOption("--tickets <ids...>", "exact ticket IDs to waive")
|
|
2099
|
+
.requiredOption("--reason <text>", "why the missing plans are accepted")
|
|
2100
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
2101
|
+
.option("--actor <name>", "operator recorded with the waiver", "operator")
|
|
2102
|
+
.action((opts) => {
|
|
2103
|
+
try {
|
|
2104
|
+
const dir = cwd(opts);
|
|
2105
|
+
waiveTicketRiskNotes(dir, opts.tickets, opts.reason, opts.actor);
|
|
2106
|
+
const validation = cmdValidate(dir);
|
|
2107
|
+
console.log(`foreman tickets: waived missing risk plans for ${opts.tickets.join(", ")}`);
|
|
2108
|
+
console.log(formatValidationIssues(validation.issues));
|
|
2109
|
+
}
|
|
2110
|
+
catch (err) {
|
|
2111
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
tickets
|
|
2115
|
+
.command("repair")
|
|
2116
|
+
.description("Ask Ticket Maker for narrowly scoped repairs to published ticket metadata.")
|
|
2117
|
+
.requiredOption("--risk-notes", "repair only missing Medium/High-risk rollback or mitigation plans")
|
|
2118
|
+
.option("--apply", "apply the reviewed rollback-only proposal")
|
|
2119
|
+
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
2120
|
+
.option("-a, --agent <agent>", "Ticket Maker agent (claude | codex)")
|
|
2121
|
+
.option("-m, --model <model>", "override Ticket Maker model")
|
|
2122
|
+
.option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
|
|
2123
|
+
.option("--fast", "fast mode - lower latency")
|
|
2124
|
+
.option("-y, --yes", "approve the reviewed rollback-only proposal when used with --apply")
|
|
2125
|
+
.action(async (opts) => {
|
|
2126
|
+
try {
|
|
2127
|
+
await cmdRepairRiskNotesCli(opts);
|
|
2128
|
+
}
|
|
2129
|
+
catch (err) {
|
|
2130
|
+
fail(String(err instanceof Error ? err.message : err));
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
1785
2133
|
const groups = tickets.command("groups").description("List and repair durable ticket creation groups.");
|
|
1786
2134
|
groups
|
|
1787
2135
|
.command("list")
|
|
@@ -2259,6 +2607,11 @@ export function buildTicketsCommand(options = {}) {
|
|
|
2259
2607
|
else
|
|
2260
2608
|
for (const line of lines)
|
|
2261
2609
|
console.log(line);
|
|
2610
|
+
const risky = loadTickets(resolveTicketPaths(loadTicketsConfig(dir), dir).tickets)
|
|
2611
|
+
.filter((ticket) => ticket.risk !== "Low" && !hasRollbackOrMitigationPlan(ticket.rollback))
|
|
2612
|
+
.map((ticket) => ticket.id);
|
|
2613
|
+
for (const message of riskWaiverWarnings(dir, risky))
|
|
2614
|
+
console.log(message);
|
|
2262
2615
|
return;
|
|
2263
2616
|
}
|
|
2264
2617
|
console.log(`${accent("Batches:")} none configured. Queue is flat.`);
|
|
@@ -2271,6 +2624,8 @@ export function buildTicketsCommand(options = {}) {
|
|
|
2271
2624
|
console.log(` ${r.rank}. [${r.status}] ${r.ticket}: ${r.title} (${r.priority}, blocked: ${r.blockedBy})`);
|
|
2272
2625
|
if (r.status === "blocked")
|
|
2273
2626
|
console.log(` Next: ${sanitizeTerminalText(r.nextAction)}`);
|
|
2627
|
+
for (const message of riskWaiverWarnings(dir, [r.ticket]))
|
|
2628
|
+
console.log(message);
|
|
2274
2629
|
}
|
|
2275
2630
|
}
|
|
2276
2631
|
}
|
package/dist/config.js
CHANGED
|
@@ -22,6 +22,7 @@ export const DEFAULT_CONFIG = {
|
|
|
22
22
|
"git checkout",
|
|
23
23
|
"git restore",
|
|
24
24
|
"git stash",
|
|
25
|
+
"rafi qa-rehearse-rollback",
|
|
25
26
|
"ls",
|
|
26
27
|
"cat ",
|
|
27
28
|
"mkdir ",
|
|
@@ -83,9 +84,16 @@ export function loadConfig(path = "foreman.yaml") {
|
|
|
83
84
|
...(raw.notifications ?? {}),
|
|
84
85
|
},
|
|
85
86
|
qa: { ...DEFAULT_CONFIG.qa, ...(raw.qa ?? {}) },
|
|
86
|
-
observability: { ...DEFAULT_CONFIG.observability, ...(raw.observability ?? {}) },
|
|
87
|
+
observability: normalizeObservabilityConfig({ ...DEFAULT_CONFIG.observability, ...(raw.observability ?? {}) }),
|
|
87
88
|
};
|
|
88
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
|
+
}
|
|
89
97
|
function validateConfig(raw, path) {
|
|
90
98
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
91
99
|
throw new Error(`${path}: expected a YAML object`);
|
|
@@ -122,17 +130,19 @@ function validateObservability(value, path) {
|
|
|
122
130
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
123
131
|
throw new Error(`${path}: observability must be an object`);
|
|
124
132
|
const input = value;
|
|
125
|
-
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"];
|
|
126
134
|
for (const key of Object.keys(input))
|
|
127
135
|
if (!keys.includes(key))
|
|
128
136
|
throw new Error(`${path}: unknown observability setting ${key}`);
|
|
129
137
|
if (input.enabled !== undefined && typeof input.enabled !== "boolean")
|
|
130
138
|
throw new Error(`${path}: observability.enabled must be a boolean`);
|
|
131
|
-
|
|
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")) {
|
|
132
142
|
if (input[key] !== undefined && (!Number.isInteger(input[key]) || Number(input[key]) <= 0))
|
|
133
143
|
throw new Error(`${path}: observability.${key} must be a positive integer`);
|
|
134
144
|
}
|
|
135
|
-
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 } : {}) };
|
|
136
146
|
if (merged.detail_soft_limit_mb >= merged.detail_hard_limit_mb)
|
|
137
147
|
throw new Error(`${path}: observability.detail_soft_limit_mb must be less than detail_hard_limit_mb`);
|
|
138
148
|
}
|
package/dist/continuity.d.ts
CHANGED
|
@@ -69,6 +69,7 @@ export declare class ContinuityAdapter implements BuilderAdapter {
|
|
|
69
69
|
enableContractEnforcement(): void;
|
|
70
70
|
contractCompactionSequence(): number;
|
|
71
71
|
acceptContractDelivery(sequence: number): void;
|
|
72
|
+
configureQaContractReminder(reminder: string, onEvent?: Parameters<NonNullable<BuilderAdapter["configureQaContractReminder"]>>[1]): void;
|
|
72
73
|
private readonly queue;
|
|
73
74
|
private sourcePump?;
|
|
74
75
|
private recoveryLeasePending;
|
package/dist/continuity.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dispatchWithGraphAccess, withGraphSessionAccess } from "./graph/session.js";
|
|
2
2
|
import { localBuildAuthority } from "./buildAdmission.js";
|
|
3
3
|
import { randomUUID, createHash } from "node:crypto";
|
|
4
|
+
import { continuityDeltaV1Schema, zodIssues } from "rafi-spec";
|
|
4
5
|
import { BuilderEventQueue } from "./activity.js";
|
|
5
6
|
import { providerSessionKey } from "./sessionIdentity.js";
|
|
6
7
|
import { WorkflowDb } from "./workflowDb.js";
|
|
@@ -48,6 +49,9 @@ export function validateContinuityDelta(value) {
|
|
|
48
49
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
49
50
|
throw new ContinuityValidationError(["delta must be an object"]);
|
|
50
51
|
const input = value;
|
|
52
|
+
const contract = continuityDeltaV1Schema.safeParse(value);
|
|
53
|
+
if (!contract.success)
|
|
54
|
+
problems.push(...zodIssues(contract.error));
|
|
51
55
|
if (input.version !== 1)
|
|
52
56
|
problems.push("version must be 1");
|
|
53
57
|
const output = {
|
|
@@ -116,6 +120,7 @@ export class ContinuityAdapter {
|
|
|
116
120
|
enableContractEnforcement() { this.contractEnforcing = true; this.adapter.enableContractEnforcement?.(); }
|
|
117
121
|
contractCompactionSequence() { return this.adapter.contractCompactionSequence?.() ?? 0; }
|
|
118
122
|
acceptContractDelivery(sequence) { this.adapter.acceptContractDelivery?.(sequence); }
|
|
123
|
+
configureQaContractReminder(reminder, onEvent) { this.adapter.configureQaContractReminder?.(reminder, onEvent); }
|
|
119
124
|
queue = new BuilderEventQueue();
|
|
120
125
|
sourcePump;
|
|
121
126
|
recoveryLeasePending;
|