agents-relay 2.0.158 → 2.0.160

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/cli-help.js CHANGED
@@ -173,7 +173,7 @@ Required: --task-id ID and the normal Job storage options.`,
173
173
  Agent submit: agents-relay plan apply --repo OWNER/REPO --pr PR_NUMBER --id JOB_ID --plan-id PLAN_ID --execution-id EXECUTION_ID --file plan.json. Validation errors reject without partial task writes and can be corrected inside the same Planner execution.
174
174
 
175
175
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
176
- Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt is additional guidance for this planning round and never replaces the durable Job objective. --force-clean-history --clean-history-file PATH requires explicit {taskId,kind,targetTaskId,evidence,reason} records for every unclassified FAILED work Task; it applies graph-authorized classifications and creates the new Plan in one SQLite mutation. Unknown execution outcomes fail closed. Planner proposals must be committed with plan apply; final text is never applied as a Plan.
176
+ Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt is additional guidance for this planning round and never replaces the durable Job objective. --force-clean-history asks Planner to inspect authoritative failed Tasks, decide whether evidence supports superseded/invalid classification or cancellation of a failed review, and apply selected changes through guarded task commands before submitting the Plan. Tasks without sufficient evidence remain unresolved. The durable Plan records the cleanup assessment, and plan apply remains the required receipt. Text responses are never applied as a Plan; unknown execution outcomes are not classified without proof.
177
177
 
178
178
  The command persists one real plan-N round, materializes validated next_tasks with planner provenance, and wakes normal reconciliation. It works for zero-task DRAFT Jobs. Historical planner-N rounds remain readable, but new rounds use plan-N.
179
179
 
package/dist/cli.js CHANGED
@@ -24,7 +24,6 @@ import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
24
24
  import { applyTaskHoldAction, applyTaskQueueAction, dispatchTaskAction } from './task-actions.js';
25
25
  import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
26
26
  import { classifyFailedTask } from './task-actions.js';
27
- import { cleanHistoryOption } from './cli-plan-clean-history.js';
28
27
  import { resolveExecutionContext } from './execution-context.js';
29
28
  import { startServiceExternalActionFlusher } from './service-external-actions.js';
30
29
  import { handleServicePublication } from './service-publication.js';
@@ -807,8 +806,7 @@ async function main() {
807
806
  throw new Error(`${command} requires --repo and --id`);
808
807
  if (command === 'plan') {
809
808
  const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
810
- const cleanHistory = await cleanHistoryOption(args);
811
- console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}), ...(cleanHistory ? { cleanHistory } : {}) }), null, 2));
809
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}), forceCleanHistory: hasArg(args, '--force-clean-history') }), null, 2));
812
810
  return;
813
811
  }
814
812
  if (command === 'reconcile') {
@@ -834,11 +832,10 @@ async function main() {
834
832
  const { store, job, localFile } = loaded;
835
833
  if (command === 'plan') {
836
834
  const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
837
- const cleanHistory = await cleanHistoryOption(args);
838
835
  await withEventBus(args, async (bus) => {
839
836
  const reconciler = runtime(store, args, bus);
840
837
  try {
841
- const result = await reconciler.planJob(job.id, guidance?.trim() || undefined, cleanHistory);
838
+ const result = await reconciler.planJob(job.id, guidance?.trim() || undefined, hasArg(args, '--force-clean-history'));
842
839
  await saveLocal(localFile, result.job);
843
840
  await publishWake(bus, result.job, result.round.id, `Planning round ${result.round.id} completed`);
844
841
  console.log(JSON.stringify(result.round, null, 2));
@@ -1,8 +1,9 @@
1
1
  import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
2
2
  import { createPlanRound, pendingPlan } from './planner.js';
3
3
  import { applyJobActivateAction } from './job-actions.js';
4
- import { classifyTaskResolution } from './workflow/task-resolution.js';
5
- export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner, cleanHistory) {
4
+ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner, forceCleanHistory = false) {
5
+ if (forceCleanHistory && !store.mutateJob)
6
+ throw new Error('--force-clean-history requires atomic durable SQLite mutations');
6
7
  let job = await store.load(jobId);
7
8
  if (['COMPLETED', 'CANCELLED'].includes(job.state))
8
9
  throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
@@ -19,23 +20,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
19
20
  if (current.pause)
20
21
  throw new Error(`Cannot plan paused Job ${current.id}; resume it first`);
21
22
  const concurrent = pendingPlan(current);
22
- if (cleanHistory) {
23
- if (concurrent)
24
- throw new Error(`--force-clean-history requires no pending Plan (${concurrent.id})`);
25
- const failed = current.tasks.filter(task => task.kind !== 'planner' && task.state === 'FAILED' && !task.resolutionClassification);
26
- const provided = new Map(cleanHistory.map(item => [item.taskId, item]));
27
- if (provided.size !== cleanHistory.length || failed.length !== provided.size || failed.some(task => !provided.has(task.id)))
28
- throw new Error('--force-clean-history requires exactly one explicit classification for every FAILED work Task');
29
- for (const task of failed) {
30
- const item = provided.get(task.id);
31
- if (item.targetTaskId === task.id || !current.tasks.some(target => target.id === item.targetTaskId))
32
- throw new Error(`Classification for ${task.id} must target a different durable Task in this Job`);
33
- classifyTaskResolution(task, item);
34
- }
35
- const unknown = current.tasks.filter(task => task.kind !== 'planner' && task.state === 'FAILED' && task.failureReason === 'worker_outcome_unknown');
36
- if (unknown.length)
37
- throw new Error(`--force-clean-history cannot proceed while FAILED Task execution outcomes are unknown: ${unknown.map(task => task.id).join(', ')}`);
38
- }
23
+ if (forceCleanHistory && concurrent)
24
+ throw new Error(`--force-clean-history requires no pending Plan (${concurrent.id})`);
39
25
  if (concurrent) {
40
26
  if (concurrent.executionId)
41
27
  throw new Error(`Planning round ${concurrent.id} is already PLANNING`);
@@ -43,6 +29,10 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
43
29
  }
44
30
  else {
45
31
  round = createPlanRound(current, { guidance, manual: true, timeoutMs });
32
+ if (forceCleanHistory) {
33
+ round.forceCleanHistory = true;
34
+ round.cleanHistoryTaskIds = current.tasks.filter(task => task.kind !== 'planner' && task.state === 'FAILED' && !task.resolutionClassification).map(task => task.id);
35
+ }
46
36
  current.plans = [...(current.plans ?? []), structuredClone(round)].sort((a, b) => a.round - b.round);
47
37
  }
48
38
  if (current.state === 'DRAFT') {
@@ -0,0 +1,33 @@
1
+ function record(value, field) {
2
+ if (!value || typeof value !== 'object' || Array.isArray(value))
3
+ throw new Error(`Planner ${field} must be an object`);
4
+ return value;
5
+ }
6
+ function requiredText(value, field) {
7
+ if (typeof value !== 'string' || value.trim().length === 0)
8
+ throw new Error(`Planner ${field} must be a non-empty string`);
9
+ return value;
10
+ }
11
+ export function parsePlannerHistoryCleanup(value) {
12
+ if (value === undefined)
13
+ return undefined;
14
+ if (!Array.isArray(value))
15
+ throw new Error('Planner history_cleanup must be an array when provided');
16
+ const ids = new Set();
17
+ return value.map((item, index) => {
18
+ const row = record(item, `history_cleanup[${index}]`);
19
+ const taskId = requiredText(row.taskId, `history_cleanup[${index}].taskId`);
20
+ const outcome = row.outcome;
21
+ const evidence = requiredText(row.evidence, `history_cleanup[${index}].evidence`);
22
+ const reason = requiredText(row.reason, `history_cleanup[${index}].reason`);
23
+ if (!['superseded', 'invalid', 'cancelled_review', 'unresolved'].includes(String(outcome)))
24
+ throw new Error(`Planner history_cleanup[${index}].outcome is invalid`);
25
+ if (ids.has(taskId))
26
+ throw new Error(`Planner history_cleanup contains duplicate Task ${taskId}`);
27
+ ids.add(taskId);
28
+ const targetTaskId = row.targetTaskId === undefined ? undefined : requiredText(row.targetTaskId, `history_cleanup[${index}].targetTaskId`);
29
+ if ((outcome === 'superseded' || outcome === 'invalid') && !targetTaskId)
30
+ throw new Error(`Planner history_cleanup[${index}].targetTaskId is required for ${String(outcome)}`);
31
+ return { taskId, outcome: outcome, evidence, reason, ...(targetTaskId ? { targetTaskId } : {}) };
32
+ });
33
+ }
@@ -136,6 +136,25 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
136
136
  let planCommitted = false;
137
137
  try {
138
138
  const parsed = parsePlannerResult(raw);
139
+ if (plan.forceCleanHistory) {
140
+ const cleanup = parsed.history_cleanup ?? [];
141
+ const expected = new Set(plan.cleanHistoryTaskIds ?? []);
142
+ if (cleanup.length !== expected.size || cleanup.some(item => !expected.delete(item.taskId)) || expected.size)
143
+ throw new Error('--force-clean-history requires exactly one Planner disposition for each failed Task in its durable snapshot');
144
+ for (const item of cleanup) {
145
+ const task = job.tasks.find(candidate => candidate.id === item.taskId);
146
+ if (!task)
147
+ throw new Error(`Clean history Task ${item.taskId} disappeared`);
148
+ if (task.failureReason === 'worker_outcome_unknown' && item.outcome !== 'unresolved')
149
+ throw new Error(`Unknown execution outcome for ${item.taskId} must remain unresolved without graph-verified proof`);
150
+ if ((item.outcome === 'superseded' || item.outcome === 'invalid') && (task.resolutionClassification?.kind !== item.outcome || task.resolutionClassification.targetTaskId !== item.targetTaskId || task.resolutionClassification.evidence !== item.evidence || task.resolutionClassification.reason !== item.reason))
151
+ throw new Error(`Clean history classification for ${item.taskId} was not applied through task classify with matching evidence`);
152
+ if (item.outcome === 'cancelled_review' && (task.agentName !== 'reviewer' || task.state !== 'CANCELLED'))
153
+ throw new Error(`Failed review ${item.taskId} must be cancelled through task cancel before Plan apply`);
154
+ if (item.outcome === 'unresolved' && (task.state !== 'FAILED' || task.resolutionClassification))
155
+ throw new Error(`Unresolved history item ${item.taskId} changed after Planner assessment`);
156
+ }
157
+ }
139
158
  const result = assignPlannerTaskIds(parsed, job.repository, job.id, plan.id);
140
159
  const executionContext = await ports.plannerContext(job, plan);
141
160
  validatePlannerHierarchy(job.tasks, result.next_tasks);
@@ -334,7 +353,7 @@ export async function launchPlanExecution(ports, job, plan) {
334
353
  let promise;
335
354
  try {
336
355
  const executionContext = await ports.plannerContext(job, plan);
337
- const context = { planExecution: { planId: plan.id, executionId }, job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: plan.previousPlanId, trigger: plan.trigger, executionContext, retryFeedback: plan.retryFeedback, guidance: plan.guidance };
356
+ const context = { planExecution: { planId: plan.id, executionId }, forceCleanHistory: plan.forceCleanHistory, cleanHistoryTaskIds: plan.cleanHistoryTaskIds, job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: plan.previousPlanId, trigger: plan.trigger, executionContext, retryFeedback: plan.retryFeedback, guidance: plan.guidance };
338
357
  promise = planner.plan(context, controller.signal);
339
358
  }
340
359
  catch (error) {
@@ -0,0 +1,26 @@
1
+ function boundedText(value, max = 4000) {
2
+ if (value === null || value === undefined || value.length <= max)
3
+ return value;
4
+ return value.slice(0, max) + `\n...[truncated ${value.length - max} chars]`;
5
+ }
6
+ export function plannerTaskContext(task) {
7
+ return {
8
+ id: task.id,
9
+ title: task.title,
10
+ kind: task.kind ?? 'work',
11
+ parent_task_id: task.parentTaskId,
12
+ dependencies: task.dependencies,
13
+ state: task.state,
14
+ failure_reason: task.failureReason,
15
+ input: task.kind === 'planner' ? undefined : boundedText(task.input),
16
+ result_summary: boundedText(task.result?.summary),
17
+ result_evidence: task.result?.data ? boundedText(JSON.stringify(task.result.data), 2000) : undefined,
18
+ execution_history: task.executionHistory?.map(item => ({ id: item.id, attempt: item.attempt, started_at: item.startedAt })),
19
+ platform_failure: task.platformFailure ? { classification: task.platformFailure.classification, evidence: boundedText(task.platformFailure.evidence), safe_to_reroute: task.platformFailure.safeToReroute } : undefined,
20
+ comments: task.comments?.slice(-10).map(comment => ({ author: comment.author, text: boundedText(comment.text, 1000), created_at: comment.createdAt })),
21
+ resolution_classification: task.resolutionClassification,
22
+ error: boundedText(task.error, 2000),
23
+ blocked_baseline: task.blockedBaseline,
24
+ unblock: task.unblock,
25
+ };
26
+ }
package/dist/planner.js CHANGED
@@ -2,7 +2,7 @@ import { buildReferenceFirstPrompt, renderAgentPrompt } from './agent-prompt-bui
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { parseTestInjection } from './e2e-injection.js';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { dirname, join } from 'node:path';
5
+ import { dirname, join, resolve as resolvePath } from 'node:path';
6
6
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
7
7
  import { validateResolutionProposal, unresolvedTask } from './workflow/task-resolution.js';
8
8
  import { executionContextPrompt, BUNDLED_AGENTS_ROOT } from './execution-context.js';
@@ -13,6 +13,9 @@ import { createTaskEntity } from './workflow/entity-creation.js';
13
13
  import { prepareReviewerThreadDelivery, chatgptThreadRequirements } from './reviewer-thread.js';
14
14
  import { spawnOwnedProcess } from './owned-process.js';
15
15
  import { requiredPlannerTaskTitle } from './planner-task-title.js';
16
+ import { parsePlannerHistoryCleanup } from './planner-history-cleanup.js';
17
+ import { plannerTaskContext } from './planner-task-context.js';
18
+ import { relayStoreDirectory } from './local-job-path.js';
16
19
  export function semanticTaskTree(tasks) {
17
20
  const work = tasks.filter(task => task.kind !== 'planner');
18
21
  const ids = new Set(work.map(task => task.id));
@@ -268,6 +271,7 @@ export function parsePlannerResult(value) {
268
271
  unblockKeys.add(key);
269
272
  return { repository, prNumber: Number(prNumber), jobId, taskId, resolutionMode: resolutionMode };
270
273
  });
274
+ const history_cleanup = parsePlannerHistoryCleanup(root.history_cleanup);
271
275
  const ids = new Set();
272
276
  const next_tasks = root.next_tasks.map((item, index) => {
273
277
  const task = record(item, `next_tasks[${index}]`);
@@ -320,7 +324,7 @@ export function parsePlannerResult(value) {
320
324
  continuation: task.continuation, maxAttempts, timeoutMs, resolution: resolutionProposal(task.resolution, `next_tasks[${index}].resolution`)
321
325
  };
322
326
  });
323
- return { objective_status: status, assessment: root.assessment, next_tasks, unblocks };
327
+ return { objective_status: status, assessment: root.assessment, next_tasks, unblocks, ...(history_cleanup ? { history_cleanup } : {}) };
324
328
  }
325
329
  export function validatePlannerProgress(job, result) {
326
330
  if (result.objective_status !== 'in_progress' || result.next_tasks.length > 0 || result.unblocks.length > 0)
@@ -420,6 +424,8 @@ export function plannerInput(context) {
420
424
  apply_cli: context.planExecution ? `agents-relay plan apply --repo ${context.job.repository} --pr ${context.job.prNumber} --id ${context.job.id} --plan-id ${context.planExecution.planId} --execution-id ${context.planExecution.executionId} --file PATH` : undefined,
421
425
  plan_execution: context.planExecution, retry_feedback: context.retryFeedback,
422
426
  caller_prompt: context.guidance,
427
+ force_clean_history: context.forceCleanHistory === true,
428
+ clean_history_task_ids: context.cleanHistoryTaskIds ?? [],
423
429
  previous_planner_task_id: context.previousPlannerTaskId,
424
430
  plan_trigger: context.trigger,
425
431
  legacy_change_cycle: context.job.changeCycle,
@@ -438,24 +444,11 @@ export function plannerInput(context) {
438
444
  })),
439
445
  project: project ? { name: project.name, path: project.path } : undefined,
440
446
  visible_agents: visibleAgents ?? [],
441
- tasks: context.tasks.map(task => ({
442
- id: task.id,
443
- title: task.title,
444
- kind: task.kind ?? 'work',
445
- parent_task_id: task.parentTaskId,
446
- state: task.state,
447
- input: task.kind === 'planner' ? undefined : boundedText(task.input),
448
- result_summary: boundedText(task.result?.summary),
449
- planner_result: task.plannerResult ? {
447
+ tasks: context.tasks.map(task => ({ ...plannerTaskContext(task), planner_result: task.plannerResult ? {
450
448
  objective_status: task.plannerResult.objective_status,
451
449
  assessment: boundedText(task.plannerResult.assessment),
452
450
  next_task_ids: task.plannerResult.next_tasks.map(item => item.id),
453
- } : undefined,
454
- resolution_classification: task.resolutionClassification,
455
- error: boundedText(task.error, 2000),
456
- blocked_baseline: task.blockedBaseline,
457
- unblock: task.unblock,
458
- })),
451
+ } : undefined })),
459
452
  });
460
453
  }
461
454
  export class CommandObjectivePlanner {
@@ -510,7 +503,8 @@ export class AgentObjectivePlanner {
510
503
  const resolvedContext = context.executionContext ? executionContextPrompt(context.executionContext) + '\n\n' : '';
511
504
  const prompt = renderAgentPrompt(await buildReferenceFirstPrompt({ role: 'planner', adapter: 'codex', system: (effectivePlanner ? `[Effective planner contract SHA-256: ${effectivePlanner.hash}]\n\n` : '') + instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim(), user: resolvedContext + '## Durable planner input\n\n' + plannerInput(context) }), 'codex');
512
505
  const runAgent = (input) => new Promise((resolve, reject) => {
513
- const child = spawnOwnedProcess(this.command, ['exec', '--json', '--', input], { invocation: 'planner.agent', signal, correlation: { jobId: context.job.id }, spawn: { cwd: context.executionContext?.project.path, stdio: ['ignore', 'pipe', 'pipe'] } });
506
+ // Plan apply writes unified relay.sqlite beside AGENTS_RELAY_STORE_DIR.
507
+ const child = spawnOwnedProcess(this.command, ['exec', '--json', '--sandbox', 'workspace-write', '--add-dir', resolvePath(dirname(relayStoreDirectory())), '--', input], { invocation: 'planner.agent', signal, correlation: { jobId: context.job.id }, spawn: { cwd: context.executionContext?.project.path, stdio: ['ignore', 'pipe', 'pipe'] } });
514
508
  let stdoutBuffer = '', finalMessage = '', stderrTail = '';
515
509
  const consume = (line) => {
516
510
  const message = parsePlannerAgentMessage(line);
@@ -273,8 +273,8 @@ export class Reconciler extends ReconcilerReviewBase {
273
273
  }
274
274
  return this.store.load(jobId);
275
275
  }
276
- async planJob(jobId, guidance, cleanHistory) {
277
- return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan), cleanHistory);
276
+ async planJob(jobId, guidance, forceCleanHistory = false) {
277
+ return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan), forceCleanHistory);
278
278
  }
279
279
  async maybeCreatePlanner(job) {
280
280
  const pending = pendingPlan(job);
@@ -109,8 +109,7 @@ export function createWorkspaceControl(deps) {
109
109
  if (command === 'job.plan') {
110
110
  const reconciler = makeReconciler(store, concurrency);
111
111
  try {
112
- const cleanHistory = Array.isArray(body.cleanHistory) ? body.cleanHistory : undefined;
113
- const planned = await reconciler.planJob(jobId, typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance : undefined, cleanHistory);
112
+ const planned = await reconciler.planJob(jobId, typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance : undefined, body.forceCleanHistory === true);
114
113
  await pool.reconcileLocalJob(jobId, 'control.job.plan');
115
114
  return planned.round;
116
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.158",
3
+ "version": "2.0.160",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,8 +19,8 @@
19
19
  "test:publish": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 --test-force-exit test/*.test.js",
20
20
  "test:fast": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=2 test/workflow-lifecycle.test.js test/workflow-v2-lifecycle-parity.test.js test/workflow-effects.test.js test/relay-store.test.js test/local-authority.test.js test/local-jobid-routing.test.js test/local-sqlite-jobid.test.js test/no-github.test.js test/scheduled-jobs.test.js test/reconcile-coordinator.test.js test/service-publication.test.js test/github-webhook.test.js",
21
21
  "test:full": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 test/*.test.js && npm run test:verification:l3",
22
- "test:verification:l1": "npm run build && node --test test/verification/harness.test.mjs test/verification/contract.test.mjs test/verification/graph-inventory.test.mjs test/verification/dashboard-drilldown.test.mjs test/verification/evidence-matrix.test.mjs test/verification/task-resolution-classification.test.mjs",
23
- "test:verification:l3": "npm run build && node --test --test-concurrency=1 test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs test/verification/objective-authority.test.mjs test/verification/l3-convergence.test.mjs test/verification/l3-review-convergence.test.mjs test/verification/l3-human-escalation.test.mjs test/verification/l3-escalation-notifier.test.mjs test/verification/l3-unknown-effect.test.mjs test/verification/l3-cli-actions.test.mjs test/verification/l3-planner-round.test.mjs test/verification/l3-task-resolution.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs test/verification/l3-full-path.test.mjs test/verification/l3-projection-recovery.test.mjs",
22
+ "test:verification:l1": "npm run build && node --test test/verification/harness.test.mjs test/verification/contract.test.mjs test/verification/graph-inventory.test.mjs test/verification/dashboard-drilldown.test.mjs test/verification/evidence-matrix.test.mjs test/verification/task-resolution-classification.test.mjs test/verification/planner-contract.test.mjs",
23
+ "test:verification:l3": "npm run build && node --test --test-concurrency=1 test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs test/verification/objective-authority.test.mjs test/verification/l3-convergence.test.mjs test/verification/l3-review-convergence.test.mjs test/verification/l3-human-escalation.test.mjs test/verification/l3-escalation-notifier.test.mjs test/verification/l3-unknown-effect.test.mjs test/verification/l3-cli-actions.test.mjs test/verification/l3-planner-round.test.mjs test/verification/l3-planner-cli.test.mjs test/verification/l3-task-resolution.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs test/verification/l3-full-path.test.mjs test/verification/l3-projection-recovery.test.mjs",
24
24
  "test:verification": "npm run test:verification:l1 && npm run test:verification:l2 && npm run test:verification:l3 && npm run test:verification:cross-a && node --test test/workflow-production-outbox.test.js && npm run test:verification:matrix",
25
25
  "e2e": "node e2e/runner/run.mjs",
26
26
  "test:verification:cross-a": "npm run build && RELAY_A_DIST=$PWD/dist node --test test/verification/action-ledger-cross-branch.test.mjs test/verification/outbox-cross-a.test.mjs",
@@ -4,7 +4,7 @@ You are the default objective planner for Agents Relay autonomous jobs.
4
4
 
5
5
  Before producing tasks, read and follow `../contracts/task-job-contract.md`.
6
6
 
7
- You are a planning agent. Relay invokes you with the durable Job, existing Tasks, previous planner rounds, and resolved project context. Your sole responsibility is to decompose the objective and create a valid Plan/Task Tree. Every proposal MUST be committed with the provided `agents-relay plan apply` CLI command. Write the proposed JSON to a temporary file and run that command with the exact Plan and execution IDs from `plan_execution` / `apply_cli`. If the CLI rejects it, correct the reported error and resubmit within the SAME Plan, up to three corrections. Do not edit SQLite or other durable storage directly. Do not treat a JSON/text response as a plan submission and do not return the proposal as your final answer. After a successful CLI action, return exactly `PLAN_APPLIED:<plan-id>:<execution-id>` using those exact IDs. Never invent that receipt. If no live execution identity or CLI action is available, fail rather than returning a proposal.
7
+ You are a planning agent. Relay invokes you with the durable Job, existing Tasks, previous planner rounds, and resolved project context. Your sole responsibility is to decompose the objective and create a valid Plan/Task Tree. Every proposal MUST be committed with the provided `agents-relay plan apply` CLI command. Write the proposed JSON to a temporary file and run that command with the exact Plan and execution IDs from `plan_execution` / `apply_cli`. If the CLI rejects it, correct the reported error and resubmit within the SAME Plan, up to three corrections. The CLI prints the persisted Plan JSON on success; that output is not your final receipt. Do not edit SQLite or other durable storage directly. Do not treat a JSON/text proposal as a plan submission and do not return the proposal as your final answer. After a successful CLI action, return exactly `PLAN_APPLIED:<plan-id>:<execution-id>` using those exact IDs. Never invent that receipt. If no live execution identity or CLI action is available, fail rather than returning a proposal.
8
8
 
9
9
  Relay persists each planning round as a first-class Plan PR comment/marker before generated Task comments. The Plan carries its trigger/context (including unblock/change facts when applicable), assessment, and generated Task IDs. Legacy `kind=planner` Task records may still exist as execution history, but they are not the durable semantic container for the planning decision. Returned work Tasks are not automatically children of the Plan; use `parentTaskId` only when the business Task graph needs hierarchy.
10
10
 
@@ -99,6 +99,12 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
99
99
  - **Resolution is mandatory for replacement work:** before returning any `next_tasks`, inspect every existing `FAILED` or `BLOCKED` work Task. If it lacks an active/waiting `resolution` or durable `resolutionClassification` and the proposed batch creates related/sibling work, explicitly cover it with a valid `resolution` on the new recovery Task. A superseded/invalid classification covers only planning validation; preserve the FAILED state and never infer success from the classification. For a same-Job recovery child, use `"resolution":{"path":"same_job_dependency","targetTaskId":"<existing failed/blocked task ID>","rootCause":"<confirmed cause or stated uncertainty>","solution":"<how the new task resolves the original>"}`. `targetTaskId` identifies the **existing unresolved Task**, not the new Task. Keep the recovered work semantically under the original artifact's subtree. Do not invent a resolution or silently cancel unrelated failed work.
100
100
  - **On `retry_feedback`:** treat the feedback as Relay's authoritative validation error for the previous output in this **same Plan**. Correct the JSON proposal and all uncovered resolution targets; do not replay the same sibling Tasks unchanged. Preserve the intent and evidence already found, and return one fully corrected result. If no safe resolution can be justified, do not invent a successful outcome.
101
101
 
102
+ ### Forced failed-history cleanup
103
+
104
+ When `force_clean_history` is true, inspect every ID in `clean_history_task_ids` against the authoritative Task records and dependency/evidence context before planning. Do not infer a result from text alone. For a failed review that is demonstrably stale, run the existing guarded `agents-relay task cancel` command. For a failed work Task with evidence that replacement work covers it, run the existing guarded `agents-relay task classify --classification superseded`; for proven invalid work, use `--classification invalid`. Supply a different durable `--target-task-id`, and the same concrete `--evidence` and `--reason` in your report. Use the normal repository, PR, and Job options from this invocation. Never change task state directly or claim an action succeeded unless the CLI command succeeds and a fresh `task show` confirms it.
105
+
106
+ If evidence is absent or execution outcome is unknown, leave the Task unresolved and do not classify it, cancel ordinary failed work, or claim success. Do not create related replacement work for an unresolved failure. After all selected guarded commands succeed, refresh authoritative Task state and submit the Plan through the standard `plan apply` command. Include exactly one `history_cleanup` entry for each `clean_history_task_ids` item: `{ "taskId", "outcome": "superseded"|"invalid"|"cancelled_review"|"unresolved", "evidence", "reason", "targetTaskId?" }`. For unresolved items, state what evidence is missing. Relay validates that reported mutations match durable SQLite state; an ordinary text response is never a Plan receipt.
107
+
102
108
  - Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
103
109
  - When a Task depends on mutable repository or product state, distinguish a frozen evidence target from the current execution head. A SHA may freeze that Task's own review/source evidence, but do not copy an upstream SHA into a dependent release, deploy, publication, or other follow-on Task unless the objective explicitly requires that same frozen target. After dependencies complete, downstream Tasks must resolve and verify the current durable Job/PR head before acting.
104
110
  - When fresh UI, browser, store, runtime, or visual evidence is material to the Task outcome, say so explicitly in the Task input and require evidence from the real current state. Existing screenshots, prepared assets, or prior observations are not substitutes for fresh evidence unless the objective explicitly allows reuse.
@@ -117,7 +123,7 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
117
123
 
118
124
  ## Output
119
125
 
120
- Return exactly one JSON object with `objective_status`, `assessment`, `next_tasks`, and optional top-level `unblocks`.
126
+ Return exactly one JSON object with `objective_status`, `assessment`, `next_tasks`, and optional top-level `unblocks`. In forced cleanup mode, also return required `history_cleanup` as specified above.
121
127
 
122
128
  For `objective_status: "in_progress"`, `assessment` must be Markdown containing:
123
129
 
@@ -1,16 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { arg, hasArg } from './cli-support.js';
3
- export async function cleanHistoryOption(args) {
4
- const force = hasArg([...args], '--force-clean-history');
5
- const path = arg([...args], '--clean-history-file');
6
- if (!force && path)
7
- throw new Error('--clean-history-file requires --force-clean-history');
8
- if (force && !path)
9
- throw new Error('--force-clean-history requires --clean-history-file with one classification per FAILED Task');
10
- if (!path)
11
- return undefined;
12
- const value = JSON.parse(await readFile(path, 'utf8'));
13
- if (!Array.isArray(value) || value.some(item => !item || typeof item !== 'object' || Array.isArray(item)))
14
- throw new Error('--clean-history-file must contain a JSON array of classification objects');
15
- return value;
16
- }