@awak-app/simy-cli 0.6.17 → 0.6.18

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/README.md CHANGED
@@ -86,6 +86,18 @@ and an already consumed remote result prevent automatic recovery. An unfinished
86
86
  worker or a missing, invalid or redacted structured result still requires
87
87
  reconciliation; no unknown external operation is automatically replayed.
88
88
 
89
+ After CI finishes successfully, the authenticated daemon refreshes the independent
90
+ review of the same saved candidate without dispatching its implementation worker.
91
+ The reviewed HEAD, fetched base, objective, preparation evidence and minimum
92
+ completion matrix must still match; changed candidates require explicit work.
93
+ Pending/failing checks, a dirty checkout and missing required checks do not trigger
94
+ this review. Human peer approval remains required when the charter requires it.
95
+ The refreshed review is checked against newly collected evidence before readiness
96
+ can pass. Stop cancels review continuation. A failed or interrupted refresh is not
97
+ repeated on every monitor tick; explicitly continue the same run to recover.
98
+ Older reviews without a saved CI basis can refresh only when their prior evidence
99
+ still establishes the complete reviewed identity; otherwise explicitly continue.
100
+
89
101
  For interrupted Agentic work, `GET /v1/agentic-loop/:id/recovery` provides a
90
102
  read-only objective and checkout fingerprint. Reconcile an uncertain side effect
91
103
  with `POST` to that endpoint (`action: reconcile`, authoritative
@@ -821,6 +833,11 @@ not a GitHub hook that mechanically intercepts every external close command.
821
833
 
822
834
  ### Submit repaired local delivery evidence to SQM
823
835
 
836
+ The [integrated delivery state model](src/orchestrator/delivery/references/state-model.md)
837
+ connects delivery milestones, repair/wait/resume branches and actual controller
838
+ states. Both executor and auditor receive its path. It documents existing control
839
+ and evidence duties; it adds no scheduler or transition-enforcement mechanism.
840
+
824
841
  The bundled Delivery Loop maintains an external acceptance checklist with separate
825
842
  user-journey and technical-invariant results. It refreshes the table after each
826
843
  evaluated action and marks evidence pending when the code or runtime context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.17",
3
+ "version": "0.6.18",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -496,9 +496,8 @@ export async function startAgent({
496
496
  return inspection;
497
497
  };
498
498
 
499
- const requireRunProvider = async (run) => {
499
+ const requireRunProvider = async (run, { backend = run.request.backend } = {}) => {
500
500
  const scope = await authorizeDesktopWork();
501
- const backend = run.request.backend;
502
501
  const inspection = await inspectReadyBackend(backend, { force: true });
503
502
  await authorizeDesktopWork(scope);
504
503
  await requireLinkedProvider(backend, inspectedLinks.get(inspection));
@@ -507,17 +506,17 @@ export async function startAgent({
507
506
  code: `desktop_executor_${inspection.status}`,
508
507
  });
509
508
  }
510
- await bindRunAuthorization(run, scope, inspection);
509
+ await bindRunAuthorization(run, scope, inspection, backend);
511
510
  };
512
511
 
513
512
  const authorizeProviderDispatch = async (backend, scope, link) => {
514
513
  await authorizeDesktopWork(scope);
515
514
  await requireLinkedProvider(backend, link);
516
515
  };
517
- const bindRunAuthorization = async (run, scope, inspection) => {
516
+ const bindRunAuthorization = async (run, scope, inspection, backend = run.request.backend) => {
518
517
  const links = await readProviderLinks(sessionRoot);
519
- links[run.request.backend] = inspectedLinks.get(inspection);
520
- await authorizeProviderDispatch(run.request.backend, scope, links[run.request.backend]);
518
+ links[backend] = inspectedLinks.get(inspection);
519
+ await authorizeProviderDispatch(backend, scope, links[backend]);
521
520
  runAuthorizations.set(run, { scope, links });
522
521
  };
523
522
  const providerRuntimeOptions = (task, options = {}, scope, link) => {
@@ -550,6 +549,22 @@ export async function startAgent({
550
549
  executeIndependentAudit: wrap(runOptions.executeIndependentAudit, "SIMY_AUDIT_JSON:") };
551
550
  };
552
551
 
552
+ const recheckAuthorizedCodingRun = (run, options = {}) => {
553
+ const scope = desktopScopeRevision();
554
+ return recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence, ...options,
555
+ executeIndependentAudit: context => withUpdateWork(updateManager,
556
+ () => codingRuntimeOptions(run).executeIndependentAudit(context)),
557
+ authorizeReview: async () => {
558
+ await authorizeDesktopWork(scope);
559
+ if (!isSessionValid(session, Date.now(), apiOrigin) || !sameRunScope(run.session, session)) {
560
+ throw new Error("The original authorized coding run session changed.");
561
+ }
562
+ await requireRunProvider(run, { backend: run.snapshot.charter.audit_backend });
563
+ await authorizeDesktopWork(scope);
564
+ },
565
+ });
566
+ };
567
+
553
568
  let organizationCancellation = Promise.resolve();
554
569
  const suspendOrganizationWork = () => {
555
570
  durableLocalTaskWorker?.pause?.();
@@ -796,7 +811,7 @@ export async function startAgent({
796
811
  for (const run of registry.list()) {
797
812
  if (!["pr_ready_for_review", "merge_ready"].includes(run.status) || run.operation || run.child) continue;
798
813
  try {
799
- await (dependencies.recheckCodingRun || recheckLocalCodingRun)(run);
814
+ await (dependencies.recheckCodingRun || recheckAuthorizedCodingRun)(run);
800
815
  } catch (error) {
801
816
  await markCodingRunMonitorFailure(run, error);
802
817
  }
@@ -2538,7 +2553,7 @@ export async function startAgent({
2538
2553
  json(res, 409, { error: "run is not ready for PR evidence recheck" });
2539
2554
  return;
2540
2555
  }
2541
- const snapshot = await recheckLocalCodingRun(run);
2556
+ const snapshot = await recheckAuthorizedCodingRun(run);
2542
2557
  json(res, 200, { ok: true, run_id: run.id, state: snapshot.state });
2543
2558
  return;
2544
2559
  }
@@ -2792,6 +2807,12 @@ export async function startAgent({
2792
2807
  !Number.isFinite(ticket.expiresAt) || ticket.expiresAt <= Date.now() || ticket.expiresAt > Date.now() + 60_000) throw new Error("Desktop sign-in changed");
2793
2808
  return { code: ticket.code, expiresAt: ticket.expiresAt, userId: ticket.userId, orgSlug: ticket.orgSlug };
2794
2809
  },
2810
+ // This is an in-process capability for the native host. It is deliberately
2811
+ // absent from status frames and never written to disk by the host.
2812
+ getLocalCliApiSession() {
2813
+ if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) return null;
2814
+ return { token: session.token, api_base_url: session.api_base_url };
2815
+ },
2795
2816
  getDesktopStatus,
2796
2817
  stopDesktopTask,
2797
2818
  stopDesktopTasks,
@@ -2912,7 +2933,7 @@ export async function startAgent({
2912
2933
  recheck: (run) =>
2913
2934
  withUpdateWork(updateManager, async () => {
2914
2935
  await authorizeDesktopWork();
2915
- return recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence });
2936
+ return recheckAuthorizedCodingRun(run, { collectEvidence: runOptions.collectEvidence });
2916
2937
  }),
2917
2938
  },
2918
2939
  };
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.6.17";
16
+ export const CLI_VERSION = "0.6.18";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -12,7 +12,8 @@ const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization
12
12
  'provider_disconnect', 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
13
  export function desktopActionAllowed(access, command) {
14
14
  return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
15
- command?.action === 'session_set_auto_continue' && command.enabled === false;
15
+ command?.action === 'session_set_auto_continue' && command.enabled === false ||
16
+ command?.action === 'session_set_email_reports' && command.enabled === false;
16
17
  }
17
18
  export function requireDesktopAccess(access) {
18
19
  if (!desktopAccessReady(access)) throw Object.assign(new Error('Sign in to an organization with SQM and Developer access before using SIMY.'), { code: 'desktop_access_required' });
@@ -37,6 +37,8 @@ import { openAuthorizationUrl } from "./browser.js";
37
37
  import { inspectBackendExecutable } from "./backend-executable.js";
38
38
  import { inspectSqmAgentSkill } from "./sqm/agent-skill.js";
39
39
  import { startWorkflowUpdates, syncInstalledWorkflows } from "./sqm/workflow-update.js";
40
+ import { createNativeDevelopmentReportsApi, normalizeNativeDevelopmentReportSave } from "./native-development-reports.js";
41
+ import { projectNativeDevelopmentReportCompletion, sendNativeDevelopmentReportCompletion } from "./native-development-report-completion.js";
40
42
  import {
41
43
  acquireDesktopProcessLock,
42
44
  assertNoLegacyCliDaemon,
@@ -147,10 +149,30 @@ export async function startDesktopHost({ input = process.stdin, output = process
147
149
  let statisticsSelection = { key: null, generation: 0 };
148
150
  const stopNotifier = createStopNotifier({ emit: event => { if (!closed && !relay) emit(event); } });
149
151
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
152
+ let nativeDevelopmentReports = { state: 'loading', enrollments: [] };
153
+ let nativeDevelopmentReportsScope = null;
154
+ let nativeDevelopmentReportsRequest = null;
155
+ let nativeDevelopmentReportsGeneration = 0;
156
+ let nativeDevelopmentReportsRetryAt = 0;
157
+ let nativeDevelopmentReportsMutation = false;
158
+ const nativeCompletionSuccess = new Set();
159
+ const nativeCompletionRejected = new Set();
160
+ const nativeCompletionInFlight = new Map();
161
+ const nativeCompletionRetryAt = new Map();
150
162
  const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
151
163
  const setAccessSnapshot = async access => {
152
164
  const previousScope = accessScope(currentAccess);
165
+ const previouslyReady = desktopAccessReady(currentAccess);
153
166
  currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
167
+ if (previousScope !== accessScope(currentAccess) || previouslyReady && !desktopAccessReady(currentAccess)) {
168
+ // A privilege loss also invalidates consent reads, even when the
169
+ // credential/organization revision itself did not change.
170
+ nativeDevelopmentReports = { state: 'loading', enrollments: [] };
171
+ nativeDevelopmentReportsScope = null;
172
+ nativeDevelopmentReportsGeneration++;
173
+ nativeDevelopmentReportsRetryAt = 0;
174
+ nativeCompletionInFlight.clear();
175
+ }
154
176
  if (previousScope !== accessScope(currentAccess)) {
155
177
  statisticsCache.clear();
156
178
  activeStatisticsRead?.controller.abort();
@@ -201,6 +223,76 @@ export async function startDesktopHost({ input = process.stdin, output = process
201
223
  const failure = results.find(result => result.status === 'rejected');
202
224
  if (failure) throw failure.reason;
203
225
  };
226
+ const localCliApiSession = () => dependencies.getLocalCliApiSession?.() || agent?.getLocalCliApiSession?.() || null;
227
+ const nativeReportsApi = (fixedSession = null) => (dependencies.createNativeDevelopmentReportsApi || createNativeDevelopmentReportsApi)({
228
+ webOrigin,
229
+ readSession: async () => fixedSession || localCliApiSession(),
230
+ fetchImpl: dependencies.fetch || globalThis.fetch,
231
+ });
232
+ const refreshNativeDevelopmentReports = async ({ force = false } = {}) => {
233
+ if (!desktopAccessReady(currentAccess)) return nativeDevelopmentReports;
234
+ if (nativeDevelopmentReportsMutation) return nativeDevelopmentReports;
235
+ const requestedScope = accessScope(currentAccess);
236
+ if (!force && nativeDevelopmentReportsScope === requestedScope && nativeDevelopmentReports.state === 'ready') return nativeDevelopmentReports;
237
+ if (!force && Date.now() < nativeDevelopmentReportsRetryAt) return nativeDevelopmentReports;
238
+ if (nativeDevelopmentReportsRequest?.scope === requestedScope) return nativeDevelopmentReportsRequest.promise;
239
+ nativeDevelopmentReports = { state: 'loading', enrollments: nativeDevelopmentReportsScope === requestedScope ? nativeDevelopmentReports.enrollments : [] };
240
+ nativeDevelopmentReportsScope = requestedScope;
241
+ const generation = nativeDevelopmentReportsGeneration;
242
+ const job = { scope: requestedScope, generation, promise: null };
243
+ job.promise = nativeReportsApi().list().then(enrollments => {
244
+ if (accessScope(currentAccess) === requestedScope && nativeDevelopmentReportsGeneration === generation) {
245
+ nativeDevelopmentReports = { state: 'ready', enrollments };
246
+ nativeDevelopmentReportsRetryAt = 0;
247
+ }
248
+ return nativeDevelopmentReports;
249
+ }).catch(() => {
250
+ if (accessScope(currentAccess) === requestedScope && nativeDevelopmentReportsGeneration === generation) {
251
+ nativeDevelopmentReports = { state: 'unavailable', enrollments: [] };
252
+ nativeDevelopmentReportsRetryAt = Date.now() + 30_000;
253
+ }
254
+ return nativeDevelopmentReports;
255
+ }).finally(() => {
256
+ if (nativeDevelopmentReportsRequest === job) nativeDevelopmentReportsRequest = null;
257
+ if (!closed && !relay) void publish().catch(() => {});
258
+ });
259
+ nativeDevelopmentReportsRequest = job;
260
+ return job.promise;
261
+ };
262
+ const submitNativeDevelopmentReportCompletions = ({ records, scope, deviceSession }) => {
263
+ if (!deviceSession?.token || !deviceSession?.api_base_url || nativeDevelopmentReports.state !== "ready" || nativeDevelopmentReportsScope !== scope) return;
264
+ const generation = nativeDevelopmentReportsGeneration;
265
+ for (const enrollment of nativeDevelopmentReports.enrollments) {
266
+ if (enrollment.provider !== "codex" || enrollment.enabled !== true) continue;
267
+ const record = records.find(item => item.provider === enrollment.provider && item.session?.toLowerCase() === enrollment.provider_session_id?.toLowerCase());
268
+ const completion = projectNativeDevelopmentReportCompletion({ enrollment, record });
269
+ if (!completion) continue;
270
+ const key = [scope, completion.provider, completion.provider_session_id, completion.task_id, completion.enrollment_revision, completion.managed_revision, completion.acceptance_digest].join(":");
271
+ // A terminal response is tied to the consent identity, rather than a
272
+ // particular proof revision: a new consent revision is required before
273
+ // the desktop may offer that server a different attestation.
274
+ const rejectionKey = [scope, completion.provider, completion.provider_session_id, completion.task_id, completion.enrollment_revision].join(":");
275
+ if (nativeCompletionSuccess.has(key) || nativeCompletionRejected.has(rejectionKey) || nativeCompletionInFlight.has(key) || Date.now() < (nativeCompletionRetryAt.get(key) || 0)) continue;
276
+ const job = Promise.resolve().then(async () => {
277
+ if (closed || relay || nativeDevelopmentReportsGeneration !== generation || accessScope(currentAccess) !== scope) return null;
278
+ const receipt = await sendNativeDevelopmentReportCompletion({ api: nativeReportsApi(deviceSession), enrollment, record });
279
+ if (receipt && nativeDevelopmentReportsGeneration === generation && accessScope(currentAccess) === scope) {
280
+ nativeCompletionSuccess.add(key);
281
+ nativeCompletionRetryAt.delete(key);
282
+ }
283
+ return receipt;
284
+ }).catch(error => {
285
+ if (nativeDevelopmentReportsGeneration === generation && accessScope(currentAccess) === scope) {
286
+ // These are immutable authorization/contract rejections. A new
287
+ // enrollment revision or scope produces a fresh rejection identity.
288
+ if ([400, 403, 409].includes(error?.status)) nativeCompletionRejected.add(rejectionKey);
289
+ else nativeCompletionRetryAt.set(key, Date.now() + 30_000);
290
+ }
291
+ return null;
292
+ }).finally(() => nativeCompletionInFlight.delete(key));
293
+ nativeCompletionInFlight.set(key, job);
294
+ }
295
+ };
204
296
  let organizationAnalysesSuspended = false;
205
297
  let organizationAnalysisDrain = Promise.resolve();
206
298
  let organizationAnalysisEpoch = 0;
@@ -482,6 +574,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
482
574
  ? structuredClone(lastStatus.deliveryHooks) : { runs: [], providers: {} };
483
575
  status.sessionSupervision = sameScope && lastStatus?.sessionSupervision
484
576
  ? structuredClone(lastStatus.sessionSupervision) : { sessions: [], providers: {}, refreshedAt: null, initialLoading: desktopAccessReady(currentAccess) };
577
+ // Complete fast frames must retain the current saved preference so OFF
578
+ // remains available while unrelated conversation enrichment is waiting.
579
+ status.nativeDevelopmentReports = sameScope && nativeDevelopmentReportsScope === accessScope(currentAccess)
580
+ ? structuredClone(nativeDevelopmentReports) : { state: 'loading', enrollments: [] };
485
581
  // Cached enrichment must follow the current connection preference even
486
582
  // while a full projection is waiting on records or remote metadata.
487
583
  status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
@@ -538,10 +634,24 @@ export async function startDesktopHost({ input = process.stdin, output = process
538
634
  status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
539
635
  if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
540
636
  if (connectionRevision !== requestedConnectionRevision || !sameAccess(sampledAccess, agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess())) { publishAgain = true; return; }
637
+ status.nativeDevelopmentReports = { state: 'loading', enrollments: [] };
541
638
  if (!closed && !relay) emitStatus(status);
542
639
  return;
543
640
  }
544
641
  await analysisManager.updateProviderStatuses?.(status.providers);
642
+ // Listing only reads rows previously registered by this device/account/org;
643
+ // it never passes the locally discovered catalog to Web.
644
+ if (agent.getLocalCliApiSession || dependencies.getLocalCliApiSession) {
645
+ void refreshNativeDevelopmentReports();
646
+ }
647
+ status.nativeDevelopmentReports = structuredClone(nativeDevelopmentReports);
648
+ // This is an asynchronous local attestation. It neither changes a
649
+ // Delivery Loop nor places receipt or device data in status frames.
650
+ submitNativeDevelopmentReportCompletions({
651
+ records: allRecords,
652
+ scope: requestedScope,
653
+ deviceSession: localCliApiSession(),
654
+ });
545
655
  stopNotifier.observe(records);
546
656
  const projection = await actionAssociations.beginProjection?.({ records });
547
657
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
@@ -1080,6 +1190,92 @@ export async function startDesktopHost({ input = process.stdin, output = process
1080
1190
  // forced request could invalidate a concurrent manual recommendation.
1081
1191
  void publish().catch(() => {});
1082
1192
  return;
1193
+ } else if (action === "session_set_email_reports") {
1194
+ if (typeof command.enabled !== 'boolean') throw new Error('Choose whether development report emails are enabled.');
1195
+ if (command.enabled) {
1196
+ if (command.provider !== 'codex') throw new Error('Development report email is not available for this provider yet.');
1197
+ // Initial consent binds only to a currently observed local session.
1198
+ // The title remains caller-provided; the host never reads transcript
1199
+ // content to populate it.
1200
+ await selectedSession(command, { preferObserved: true });
1201
+ }
1202
+ if (command.enabled) await authorizeWork(commandScope);
1203
+ // Validate before the recovery path: an invalid local command is not
1204
+ // an ambiguous remote write and must never be accepted from a list.
1205
+ const request = normalizeNativeDevelopmentReportSave({
1206
+ provider: command.provider,
1207
+ provider_session_id: command.session,
1208
+ request_title: command.requestTitle,
1209
+ enabled: command.enabled,
1210
+ expected_revision: command.expectedRevision,
1211
+ });
1212
+ const deviceSession = localCliApiSession();
1213
+ if (!deviceSession) throw new Error('SIMY desktop sign-in is required.');
1214
+ // Keep the credential tuple fixed across PUT and its readback. A
1215
+ // changed account/device scope must fail instead of confirming one
1216
+ // user’s write with another user’s registry.
1217
+ const reportsApi = nativeReportsApi(deviceSession);
1218
+ const sameIdentity = enrollment => enrollment.provider === request.provider &&
1219
+ enrollment.provider_session_id === String(request.provider_session_id || '').toLowerCase();
1220
+ const matchesSaved = enrollment => sameIdentity(enrollment) && enrollment.enabled === request.enabled &&
1221
+ (!request.enabled || enrollment.request_title === String(request.request_title || '').trim());
1222
+ const confirmed = enrollments => request.enabled
1223
+ ? enrollments.some(matchesSaved)
1224
+ : !enrollments.some(sameIdentity) || enrollments.some(matchesSaved);
1225
+ const mutationGeneration = ++nativeDevelopmentReportsGeneration;
1226
+ nativeDevelopmentReportsMutation = true;
1227
+ let recoveredCurrent = false;
1228
+ const invalidateMutation = () => {
1229
+ if (nativeDevelopmentReportsGeneration !== mutationGeneration || accessScope(currentAccess) !== commandScope) return;
1230
+ nativeDevelopmentReports = { state: 'unavailable', enrollments: [] };
1231
+ nativeDevelopmentReportsRetryAt = Date.now() + 30_000;
1232
+ };
1233
+ try {
1234
+ try {
1235
+ await reportsApi.save(request);
1236
+ } catch (error) {
1237
+ // The server may have committed before a connection failure. Read
1238
+ // back the device-scoped registry before reporting an error.
1239
+ const recovered = await reportsApi.list().catch(() => null);
1240
+ if (!recovered) {
1241
+ invalidateMutation();
1242
+ throw error;
1243
+ }
1244
+ // A conflict contains useful current revision state. Publish it so
1245
+ // the next explicit choice starts from the server's revision.
1246
+ if (nativeDevelopmentReportsGeneration === mutationGeneration && accessScope(currentAccess) === commandScope) {
1247
+ nativeDevelopmentReports = { state: 'ready', enrollments: recovered };
1248
+ nativeDevelopmentReportsRetryAt = 0;
1249
+ recoveredCurrent = true;
1250
+ }
1251
+ if (!confirmed(recovered)) throw error;
1252
+ }
1253
+ const enrollments = await reportsApi.list();
1254
+ if (!confirmed(enrollments)) throw new Error('Development report email settings could not be confirmed.');
1255
+ if (command.enabled) await authorizeWork(commandScope);
1256
+ const currentDeviceSession = localCliApiSession();
1257
+ if (!currentDeviceSession || currentDeviceSession.token !== deviceSession.token || currentDeviceSession.api_base_url !== deviceSession.api_base_url) {
1258
+ throw Object.assign(new Error('Desktop account or organization changed.'), { code: 'desktop_access_required' });
1259
+ }
1260
+ if (nativeDevelopmentReportsGeneration !== mutationGeneration) throw new Error('Development report email settings changed; refresh and try again.');
1261
+ nativeDevelopmentReportsScope = accessScope(currentAccess);
1262
+ nativeDevelopmentReports = { state: 'ready', enrollments };
1263
+ nativeDevelopmentReportsRetryAt = 0;
1264
+ // Fence a GET that was in flight before this completed mutation.
1265
+ nativeDevelopmentReportsGeneration++;
1266
+ emit({ type: "result", id: command.id, ok: true });
1267
+ void publish().catch(() => {});
1268
+ return;
1269
+ } catch (error) {
1270
+ // A completed PUT without a confirmable readback must not leave a
1271
+ // prior ready revision cached forever. Preserve a 409 readback,
1272
+ // which is itself the current server state.
1273
+ if (!recoveredCurrent) invalidateMutation();
1274
+ throw error;
1275
+ } finally {
1276
+ nativeDevelopmentReportsMutation = false;
1277
+ if (!closed && !relay) void publish().catch(() => {});
1278
+ }
1083
1279
  } else if (action === "session_deadline_decide") {
1084
1280
  if (command.cancel === true) { emit({ type: "result", id: command.id, ok: true }); return; }
1085
1281
  const candidate = await selectedSession(command);
@@ -1150,7 +1346,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1150
1346
  void execute();
1151
1347
  } else if (command.action === "my_ai_session") {
1152
1348
  myAiSessionQueue = myAiSessionQueue.then(execute).catch(() => {});
1153
- } else if (command.action === "session_set_auto_continue") {
1349
+ } else if (["session_set_auto_continue", "session_set_email_reports"].includes(command.action)) {
1154
1350
  preferenceQueue = preferenceQueue.then(execute).catch(() => {});
1155
1351
  } else if (command.action === 'session_statistics') {
1156
1352
  void execute();
@@ -0,0 +1,143 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import {
4
+ NATIVE_DEVELOPMENT_REPORT_COMPLETIONS_PATH,
5
+ normalizeNativeDevelopmentReportCompletionReceipt,
6
+ } from "./native-development-reports.js";
7
+
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const SHA256 = /^[0-9a-f]{64}$/;
10
+ const SOURCES = new Set(["user", "file_exists", "file_contains", "file_equals"]);
11
+ const MAX_CRITERIA = 50;
12
+
13
+ // This creates a device-authenticated local attestation. It is deliberately a
14
+ // projection of the enrolled record and never asserts server-side verification.
15
+ export function projectNativeDevelopmentReportCompletion({ enrollment, record } = {}) {
16
+ if (!isEnabledEnrollment(enrollment) || !isExactCompletedRecord(record, enrollment)) return null;
17
+
18
+ const run = record.run;
19
+ const managed = run.managed;
20
+ const criteria = managed.acceptanceCriteria;
21
+ const frozenAt = currentFrozenEnrollmentAt(run, criteria);
22
+ if (frozenAt === null || !Array.isArray(criteria) || criteria.length === 0 || criteria.length > MAX_CRITERIA) return null;
23
+
24
+ const projectedCriteria = [];
25
+ for (const criterion of criteria) {
26
+ const projected = projectCriterion(criterion, managed.verified, managed.criterionObservations, frozenAt);
27
+ if (!projected) return null;
28
+ projectedCriteria.push(projected);
29
+ }
30
+ projectedCriteria.sort((left, right) => left.id_hash.localeCompare(right.id_hash));
31
+ if (new Set(projectedCriteria.map((criterion) => criterion.id_hash)).size !== projectedCriteria.length) {
32
+ return null;
33
+ }
34
+
35
+ const verifiedAtMs = Math.max(...projectedCriteria.map((criterion) => Date.parse(criterion.checked_at)));
36
+ if (!Number.isFinite(verifiedAtMs)) return null;
37
+ const checkpoint = run.checkpoint;
38
+ return Object.freeze({
39
+ provider: enrollment.provider,
40
+ provider_session_id: enrollment.provider_session_id.toLowerCase(),
41
+ enrollment_revision: enrollment.revision,
42
+ task_id: managed.taskId.toLowerCase(),
43
+ managed_revision: run.revision,
44
+ checkpoint_digest: digest(checkpoint),
45
+ acceptance_digest: digest(projectedCriteria),
46
+ verified_at: new Date(verifiedAtMs).toISOString(),
47
+ criteria: projectedCriteria,
48
+ });
49
+ }
50
+
51
+ export async function sendNativeDevelopmentReportCompletion({ api, enrollment, record } = {}) {
52
+ if (!api || typeof api.submitCompletion !== "function") {
53
+ throw new TypeError("A native development reports API with submitCompletion is required");
54
+ }
55
+ const completion = projectNativeDevelopmentReportCompletion({ enrollment, record });
56
+ if (!completion) return null;
57
+ const receipt = await api.submitCompletion(completion);
58
+ return normalizeNativeDevelopmentReportCompletionReceipt(receipt, completion);
59
+ }
60
+
61
+ function isEnabledEnrollment(enrollment) {
62
+ return Boolean(
63
+ enrollment &&
64
+ enrollment.enabled === true &&
65
+ enrollment.provider === "codex" &&
66
+ UUID.test(enrollment.provider_session_id || "") &&
67
+ Number.isSafeInteger(enrollment.revision) &&
68
+ enrollment.revision > 0,
69
+ );
70
+ }
71
+
72
+ function isExactCompletedRecord(record, enrollment) {
73
+ const run = record?.run;
74
+ const managed = run?.managed;
75
+ return Boolean(
76
+ record &&
77
+ record.provider === enrollment.provider &&
78
+ typeof record.session === "string" &&
79
+ record.session.toLowerCase() === enrollment.provider_session_id.toLowerCase() &&
80
+ run?.state === "completed" &&
81
+ run.checkpoint?.status === "completed" &&
82
+ run.recoveryCause !== "process_exit" &&
83
+ managed &&
84
+ managed.thread?.provider === record.provider &&
85
+ managed.thread?.id === record.session &&
86
+ UUID.test(managed.taskId || "") &&
87
+ Number.isSafeInteger(run.revision) &&
88
+ run.revision >= 0,
89
+ );
90
+ }
91
+
92
+ function currentFrozenEnrollmentAt(run, criteria) {
93
+ const enrollment = run.managed?.enrollment;
94
+ const frozenAt = typeof enrollment?.frozenAt === "number" ? enrollment.frozenAt : NaN;
95
+ if (!enrollment || !Number.isFinite(frozenAt) || frozenAt < 0 ||
96
+ enrollment.objective !== run.objective ||
97
+ enrollment.acceptanceDigest !== loopDigest(criteria) ||
98
+ canonicalJson(enrollment.deliveryLevel ?? null) !== canonicalJson(run.managed.deliveryLevel ?? null)) return null;
99
+ return frozenAt;
100
+ }
101
+
102
+ function projectCriterion(criterion, verified, observations, frozenAt) {
103
+ if (!criterion || typeof criterion !== "object" || typeof criterion.id !== "string" || !criterion.id) {
104
+ return null;
105
+ }
106
+ const expectedSource = sourceForCheck(criterion.check?.type);
107
+ const evidence = verified?.[criterion.id];
108
+ const checkedAt = normalizeIso(evidence?.checkedAt);
109
+ if (!expectedSource || !checkedAt || Date.parse(checkedAt) < frozenAt || evidence?.source !== expectedSource) return null;
110
+
111
+ const observation = observations?.[criterion.id];
112
+ if (observation && (observation.passed !== true || observation.criterion_id !== criterion.id)) return null;
113
+ if (expectedSource !== "user" && !SHA256.test(evidence?.sha256 || "")) return null;
114
+ return Object.freeze({
115
+ id_hash: digest(criterion.id),
116
+ source: expectedSource,
117
+ checked_at: checkedAt,
118
+ evidence_digest: digest({ evidence, observation: observation || null }),
119
+ });
120
+ }
121
+
122
+ function sourceForCheck(type) {
123
+ return type === "manual" ? "user" : SOURCES.has(type) ? type : null;
124
+ }
125
+
126
+ function normalizeIso(value) {
127
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
128
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
129
+ }
130
+
131
+ function digest(value) {
132
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
133
+ }
134
+
135
+ function loopDigest(value) {
136
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
137
+ }
138
+
139
+ function canonicalJson(value) {
140
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
141
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
142
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
143
+ }
@@ -0,0 +1,137 @@
1
+ import { webApiErrorMessage, webApiHeaders, webApiUrl } from "./web-api.js";
2
+
3
+ export const NATIVE_DEVELOPMENT_REPORTS_PATH = "native-development-reports";
4
+ export const NATIVE_DEVELOPMENT_REPORT_COMPLETIONS_PATH = "native-development-report-completions";
5
+
6
+ const PROVIDERS = new Set(["codex", "claude"]);
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const CONTROL = /[\x00-\x1f\x7f]/;
9
+
10
+ export class NativeDevelopmentReportsError extends Error {
11
+ constructor(message, { code = "native_development_reports_unavailable", status = null } = {}) {
12
+ super(message);
13
+ this.name = "NativeDevelopmentReportsError";
14
+ this.code = code;
15
+ this.status = status;
16
+ }
17
+ }
18
+
19
+ export function normalizeNativeDevelopmentReportEnrollment(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidResponse();
21
+ const keys = Object.keys(value).sort();
22
+ const expected = ["enabled", "provider", "provider_session_id", "request_title", "revision"];
23
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) throw invalidResponse();
24
+ if (!PROVIDERS.has(value.provider) || !UUID.test(value.provider_session_id) ||
25
+ typeof value.request_title !== "string" || value.request_title.length > 160 || CONTROL.test(value.request_title) ||
26
+ typeof value.enabled !== "boolean" || !Number.isSafeInteger(value.revision) || value.revision < 0 ||
27
+ (value.enabled && value.revision === 0)) throw invalidResponse();
28
+ return Object.freeze({
29
+ provider: value.provider,
30
+ provider_session_id: value.provider_session_id.toLowerCase(),
31
+ request_title: value.request_title,
32
+ enabled: value.enabled,
33
+ revision: value.revision,
34
+ });
35
+ }
36
+
37
+ export function normalizeNativeDevelopmentReportSave(value) {
38
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new NativeDevelopmentReportsError("Invalid development report request.", { code: "invalid_native_development_report_request" });
39
+ const keys = Object.keys(value).sort();
40
+ const expected = ["enabled", "expected_revision", "provider", "provider_session_id", "request_title"];
41
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) throw new NativeDevelopmentReportsError("Invalid development report request.", { code: "invalid_native_development_report_request" });
42
+ if (!PROVIDERS.has(value.provider) || !UUID.test(value.provider_session_id) || typeof value.enabled !== "boolean" ||
43
+ !Number.isSafeInteger(value.expected_revision) || value.expected_revision < 0 ||
44
+ typeof value.request_title !== "string") throw new NativeDevelopmentReportsError("Invalid development report request.", { code: "invalid_native_development_report_request" });
45
+ const requestTitle = value.request_title.trim();
46
+ if (requestTitle.length > 160 || CONTROL.test(requestTitle) || (value.enabled && requestTitle.length === 0)) {
47
+ throw new NativeDevelopmentReportsError("Invalid development report title.", { code: "invalid_native_development_report_request" });
48
+ }
49
+ return Object.freeze({
50
+ provider: value.provider,
51
+ provider_session_id: value.provider_session_id.toLowerCase(),
52
+ request_title: requestTitle,
53
+ enabled: value.enabled,
54
+ expected_revision: value.expected_revision,
55
+ });
56
+ }
57
+
58
+ export function normalizeNativeDevelopmentReportCompletionReceipt(value, completion) {
59
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
60
+ Object.keys(value).sort().join(",") !== "enrollment_revision,receipt_id,status,task_id") {
61
+ throw invalidResponse();
62
+ }
63
+ if (!UUID.test(value.receipt_id) || !UUID.test(value.task_id) ||
64
+ !Number.isSafeInteger(value.enrollment_revision) || value.enrollment_revision < 1 ||
65
+ !["pending", "sent", "suppressed"].includes(value.status) ||
66
+ !completion || value.task_id.toLowerCase() !== completion.task_id.toLowerCase() ||
67
+ value.enrollment_revision !== completion.enrollment_revision) {
68
+ throw invalidResponse();
69
+ }
70
+ return Object.freeze({
71
+ receipt_id: value.receipt_id.toLowerCase(),
72
+ task_id: value.task_id.toLowerCase(),
73
+ enrollment_revision: value.enrollment_revision,
74
+ status: value.status,
75
+ });
76
+ }
77
+
78
+ export function createNativeDevelopmentReportsApi({ webOrigin, readSession, fetchImpl = globalThis.fetch } = {}) {
79
+ if (typeof readSession !== "function") throw new TypeError("readSession is required");
80
+ if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl is required");
81
+
82
+ const sessionForRequest = async () => {
83
+ const session = await readSession();
84
+ if (!session?.token || !session?.api_base_url) {
85
+ throw new NativeDevelopmentReportsError("SIMY desktop sign-in is required.", { code: "device_session_unavailable" });
86
+ }
87
+ return session;
88
+ };
89
+ const request = async (method, body = undefined, path = NATIVE_DEVELOPMENT_REPORTS_PATH) => {
90
+ const session = await sessionForRequest();
91
+ let response;
92
+ try {
93
+ response = await fetchImpl(webApiUrl(path, {
94
+ webOrigin, apiBaseUrl: session.api_base_url,
95
+ }), {
96
+ method,
97
+ headers: webApiHeaders({ token: session.token }, body === undefined ? {} : { "Content-Type": "application/json" }),
98
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
99
+ signal: AbortSignal.timeout(10_000),
100
+ cache: "no-store",
101
+ });
102
+ } catch {
103
+ throw new NativeDevelopmentReportsError("Development report settings could not be reached.");
104
+ }
105
+ const payload = await response.json().catch(() => null);
106
+ if (!response.ok) {
107
+ throw new NativeDevelopmentReportsError(webApiErrorMessage(payload, response.status), {
108
+ code: response.status === 409 ? "conflict" : "native_development_reports_unavailable",
109
+ status: response.status,
110
+ });
111
+ }
112
+ return payload;
113
+ };
114
+ return {
115
+ async list() {
116
+ const payload = await request("GET");
117
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
118
+ Object.keys(payload).length !== 1 || !Array.isArray(payload.enrollments)) throw invalidResponse();
119
+ return payload.enrollments.map(normalizeNativeDevelopmentReportEnrollment);
120
+ },
121
+ async save(value) {
122
+ const requestValue = normalizeNativeDevelopmentReportSave(value);
123
+ const payload = await request("PUT", requestValue);
124
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
125
+ Object.keys(payload).length !== 1) throw invalidResponse();
126
+ return normalizeNativeDevelopmentReportEnrollment(payload.enrollment);
127
+ },
128
+ async submitCompletion(value) {
129
+ const payload = await request("POST", value, NATIVE_DEVELOPMENT_REPORT_COMPLETIONS_PATH);
130
+ return normalizeNativeDevelopmentReportCompletionReceipt(payload, value);
131
+ },
132
+ };
133
+ }
134
+
135
+ function invalidResponse() {
136
+ return new NativeDevelopmentReportsError("Development report settings returned an invalid response.", { code: "invalid_native_development_reports_response" });
137
+ }
@@ -5,8 +5,13 @@ export const ACCEPTANCE_EVIDENCE_PATH = fileURLToPath(
5
5
  new URL("./delivery/references/acceptance-evidence.md", import.meta.url),
6
6
  );
7
7
 
8
+ export const DELIVERY_STATE_MODEL_PATH = fileURLToPath(
9
+ new URL("./delivery/references/state-model.md", import.meta.url),
10
+ );
11
+
8
12
  export function buildChecklistContinuationInstruction({ auditor = false } = {}) {
9
13
  return section("checklist_continuation_contract", [
14
+ `Read the integrated delivery state model: ${DELIVERY_STATE_MODEL_PATH}. Keep proven delivery milestones, controller execution states, and final dispositions distinct. Follow the repair/reverify and evidence-invalidation branches; a PR readiness state never proves deployment or overall completion. The model documents the existing controller and executor duties, not an automatic scheduler or new transition validator.`,
10
15
  `The table is a progress checkpoint, not the deliverable or a stopping condition. Read: ${ACCEPTANCE_EVIDENCE_PATH}`,
11
16
  "Maintain the complete 依頼チェックリスト with columns #, ご依頼, 現状, 判定 in the evidence record after chartering, each evaluated attempt, and in the final receipt. User-facing reporting follows workflow_reporting_contract; show the full table only when requested or when it is the most useful requested answer, never as mandatory commentary. Preserve stable criterion IDs, all rows and both applicable evidence layers. ✅ requires current valid passing evidence for every applicable layer; ❌ means an observed failure; 🟡 means pending, missing, stale, blocked or unverifiable evidence. Retain counts and highest proven state in that record.",
12
17
  "After observing any non-green row, classify its cause and immediately take the next safe authorized action: implement missing behavior, repair a failure, exercise an untested path, collect missing evidence, or rerun stale verification. Re-observe the same criterion against the current candidate, run the validator, update the table, and repeat until every applicable requested outcome and gate for the authorized endpoint is verified. A next-action list without performing feasible actions is not completion. Never require another continue request merely because a table was shown.",
@@ -406,6 +406,10 @@ decision when the trade-off materially changes.
406
406
 
407
407
  ## Keep delivery states distinct
408
408
 
409
+ Use the [integrated state model](references/state-model.md) for repair, wait,
410
+ resume, evidence invalidation and endpoint boundaries. It distinguishes these
411
+ delivery milestones from actual CLI controller states and final dispositions.
412
+
409
413
  Track and report the highest state actually proven:
410
414
 
411
415
  `chartered -> reproduction_verified -> implemented -> locally_verified -> pr_ci_green -> integration_verified -> user_like_local_verified -> merged -> deployed_exact_revision -> health_verified -> live_verified -> cleanup_verified -> done`
@@ -0,0 +1,187 @@
1
+ # Delivery Loop の統合状態モデル
2
+
3
+ 現行CLIの正本。対象は CLI Agentic Loop (`src/orchestrator/loop.js`) と、
4
+ そのexecutorが従うDelivery workflow。ローカルhook型Loopとは区別する。
5
+ 図は新しいruntime enumや自動スケジューラを追加しない。
6
+
7
+ ## 1. 利用者が判断する全体図
8
+
9
+ 目的は、元の依頼を満たした証拠が揃うまで、安全に実装・検証を続けること。
10
+ 「今どこまで証明できたか」「次に何をするか」「今回の終了理由」を分けて保持する。
11
+ 以下はexecutor/auditorの行動契約であり、各矢印がCLIに自動実装済みという意味ではない。
12
+
13
+ ```mermaid
14
+ flowchart TD
15
+ Contract[目的・体験・成功条件・権限・終了地点を固定] --> Baseline[現状と再現を確認・リスク相応のレビュー計画]
16
+ Baseline --> Work[実装・修正・不足証拠の取得]
17
+ Work --> Verify[現在の版で検証・チェックリスト更新]
18
+ Verify --> Review[独立レビュー・元の依頼と証拠を照合]
19
+ Review --> Decision{未達の原因と次の行動}
20
+ Decision -->|安全に修正・再検証できる| Work
21
+ Decision -->|CI等が進行中| Wait[上限のある待機・再確認]
22
+ Wait --> Verify
23
+ Decision -->|本当の権限・入力不足/進捗停止| Hold[未完了を記録・独立して進められる作業は続行]
24
+ Hold -->|不足解消・必要な承認・再開| Verify
25
+ Decision -->|全行と適用ゲートが合格| Endpoint{固定した終了地点}
26
+ Endpoint -->|PRまで| PR[PRの検証結果・残る非対象作業を記録]
27
+ Endpoint -->|配備までの権限あり| Release[マージ・exact revision配備・段階的な公開]
28
+ Release --> Live[health・実利用経路・成果とguardrailを観測]
29
+ Live -->|未達・回帰| Recovery[公開保留/権限内のrollback・修正]
30
+ Recovery --> Work
31
+ Live -->|合格| Cleanup[cleanupと最終証拠を確認・As-Is更新]
32
+ PR --> Final[元の依頼・全行・終了地点を最終照合]
33
+ Cleanup --> Final
34
+ Final -->|現在の証拠で全条件合格| Complete[依頼範囲の完了を報告]
35
+ Final -->|不足・失効| Decision
36
+ ```
37
+
38
+ - チェックリストの◎相当は現行表記の **✅**。適用する体験・技術の両層に、
39
+ 現在の候補と条件に束縛された合格証拠が必要。🟡・❌は完了ではない。
40
+ - 目的・体験ID・受入条件は保持する。全行を消さずに修正→検証→更新を繰り返す。
41
+ 個別の軽微な指摘の扱いは実際の監査判定に従い、未達の受入条件を軽微扱いして免除しない。
42
+ - PRのみの依頼はmerge/deployを許可しない。一方、配備までの依頼をPR作成で終えない。
43
+ - 自動待機を装わない。利用できるbounded wait/recheckまたは承認済みmonitorを使い、
44
+ 継続不能なら具体的な制約と再開条件を残す。停止指示は尊重する。
45
+ - 修正によってhead/base/設定/依存版/環境/成功条件が変われば、影響する証拠を失効させて
46
+ 再検証する。過去の合格履歴は消さず、現在も合格とは扱わない。
47
+
48
+ ## 2. 証明済みの到達段階と品質ゲート
49
+
50
+ 次の13段階は成果のmilestoneであり、下節のcontroller stateとは別。
51
+ 順番だけで昇格せず、適用証拠を確認する。非該当は理由を明示する。
52
+
53
+ ```mermaid
54
+ flowchart LR
55
+ chartered --> reproduction_verified --> implemented --> locally_verified
56
+ locally_verified --> pr_ci_green --> integration_verified --> user_like_local_verified
57
+ user_like_local_verified --> merged --> deployed_exact_revision
58
+ deployed_exact_revision --> health_verified --> live_verified --> cleanup_verified --> done
59
+ ```
60
+
61
+ | 昇格箇所 | 保持すべき品質ゲート |
62
+ |---|---|
63
+ | chartered / reproduction_verified | 元の依頼・完全な利用体験・As-Is差分・成功契約・権限・レビュー計画 |
64
+ | implemented / locally_verified | 修正、体験全体と技術的不変条件、必要な独立レビュー、現行証拠 |
65
+ | PR準備と検証 | PR preparationの8行と4条件付きsafeguard、最新remote base/current head、CI・E2E・skip開示・差分・cleanup。明示opt-in時のSQM |
66
+ | integration_verified / user_like_local_verified | 統合候補と実利用同等の経路。CI greenだけでは代替しない |
67
+ | merged / deployed_exact_revision | 各操作の権限、正確な配備版、rolloutのpromote/hold/rollback条件 |
68
+ | health_verified / live_verified | 稼働確認と利用者の成果を区別し、必要な観測windowとguardrailを検証 |
69
+ | cleanup_verified / done | 適用する後片付け・最終受入条件・receipt。検証済みAs-Isを更新 |
70
+
71
+ この表はworkflow上の要求。各条件の機械検証範囲は既存validatorとcollectorに従い、
72
+ 図だけを根拠に検証済みとはしない。詳細は [state and receipts](state-and-receipts.md)、
73
+ [minimum completion](minimum-completion.md)、[acceptance evidence](acceptance-evidence.md)。
74
+ PR範囲の完了を報告しても、未配備のmilestoneを `done` に進めない。
75
+
76
+ ## 3. 現行controllerの実際の制御
77
+
78
+ 以下は `loop.js` / `audit.js` と保存済みworker結果の復帰経路。`Gate`、`Receipt`、`RecheckGate`、`Failure` は説明用の判定節点。
79
+ 新規runの契約不備は `waiting_human`。resume時も目的・minimum契約を再検査する。
80
+
81
+ ```mermaid
82
+ flowchart TD
83
+ queued --> Gate{目的・minimum契約の検査}
84
+ Gate -->|不備| waiting_human
85
+ Gate -->|新規| risk_classifying
86
+ risk_classifying -->|charter不備| waiting_human
87
+ risk_classifying -->|有効| chartering
88
+ chartering --> Receipt{保存済みworker結果の検査}
89
+ Gate -->|resume・既存attempt・契約有効| Receipt
90
+ Receipt -->|保存結果なし| dispatching
91
+ Receipt -->|保存結果が不正| waiting_human
92
+ Receipt -->|有効な完了結果・同じattemptを復帰| collecting_evidence
93
+ dispatching --> coding --> collecting_evidence --> auditing
94
+ coding -->|完了結果の保存失敗・復帰対象外で照合待ち| failed
95
+ collecting_evidence & auditing & independent_auditing -->|有効な保存結果あり・controller例外| failed
96
+ collecting_evidence -->|保存結果あり・収集の再試行も失敗| waiting_human
97
+ independent_auditing -->|保存結果あり・証拠再収集の再試行も失敗| waiting_human
98
+ auditing -->|合格| independent_auditing
99
+ auditing -->|不合格| Failure{失敗原因・進捗・再試行判定}
100
+ independent_auditing -->|証拠を再収集し監査・独立レビュー不合格| Failure
101
+ independent_auditing -->|両方合格・PR条件未達| pr_ready_for_review
102
+ independent_auditing -->|両方合格・PR条件全合格| merge_ready
103
+ Failure -->|修正可能| re_instructing
104
+ re_instructing --> dispatching
105
+ Failure -->|同一失敗/成果の進捗なし| blocked
106
+ Failure -->|人の入力が必要/連続して評価不能| waiting_human
107
+ pr_ready_for_review -->|callerまたはdaemonのrecheck| RecheckGate{目的契約とレビューの目的digest}
108
+ merge_ready -->|callerまたはdaemonのrecheck| RecheckGate
109
+ RecheckGate -->|不備| waiting_human
110
+ RecheckGate -->|有効| checking_pr
111
+ checking_pr -->|現在の証拠・レビュー束縛を含むPR条件未達| pr_ready_for_review
112
+ checking_pr -->|現在のPR条件全合格| merge_ready
113
+ blocked -->|原因解消・許可されたresume| Gate
114
+ waiting_human -->|入力・許可されたresume| Gate
115
+ failed -->|復旧対象の完了結果あり・runnerで復元| waiting_human
116
+ waiting_human -->|中断復元・有効な完了結果・daemonの認証と作業権限確認| Gate
117
+ ```
118
+
119
+ - 実行中のstop確認点で `stopped` に戻る。図は割込みを全辺に描いていない。
120
+ 停止を自動解除しない。`local_stop_requested` を保持し、手動停止をdaemonの復帰対象にしない。
121
+ - `handleFailedAudit` は失敗原因を決定論的に分岐する。修正可能な失敗は
122
+ `re_instructing` から次のattemptへ。成果評価不能は成果の停滞と同一視しない。
123
+ - `budget.js` は現在 `local_limits_removed: true`。token/回数は観測・通知の値で、
124
+ `canStartProvider` は消費量で止めず `codingAttemptCeiling` は無限。
125
+ 旧版の「3回/予算切れ→終了」を現行の通常経路として復活させない。
126
+ 残っているbudget停止用コードは現行の到達可能な通常経路として描いていない。
127
+ 外部providerの制約や停止・承認・進捗保護を無視してよいという意味ではない。
128
+ - `runCodingLoop` はPR readinessを返すと終了する。既存attemptがあり、保存済み
129
+ `completed_worker_result` がない非resume呼出しは `checking_pr` で再評価し、
130
+ 再実装・新しい独立レビューは行わない。修正や新しいレビューが必要なら、
131
+ 適切なresumeで新attemptを実行する。保存済み結果がある場合は下記の復帰経路になる。
132
+ 既存の認証済みdaemonは稼働中、作業実行が許可され、他のoperation/childがない
133
+ PR-ready runを定期recheckする(`agent.js` / `runner.js`)。daemon不在・認証切れでは
134
+ この監視を期待できず、recheck自体は修正attemptを開始しない。
135
+ この資料は追加のmonitorや自動resumeを起動しない。
136
+ - 有効な `completed_worker_result` があれば、同じattemptのworkerを再実行せず
137
+ `collecting_evidence` から監査・独立監査をやり直す(`completed_worker_result_recovered`)。
138
+ 保存結果は受入証拠ではなく、run・目的digest・attempt等への束縛を再検査する。
139
+ 不正な結果は `WORKER_RESULT_UNVERIFIED` で `waiting_human`。
140
+ 保存結果がある証拠収集は最大2回再試行し、失敗が続けば結果を保持したまま
141
+ `EVIDENCE_COLLECTION_UNAVAILABLE` で `waiting_human`。再開時も同じattemptから再収集する。
142
+ 完了結果の永続化失敗は `worker_result_persistence_failed` をrunnerへ伝え、
143
+ `failed` と照合待ちを記録する。保存に失敗した結果を安全な復帰用receiptと扱わない。
144
+ - 既存daemonは、中断状態(または `controller_result_recovery` 付きの `failed`)を
145
+ 復元すると、目的・結果・actor束縛・認証・作業権限を確認し、停止要求や他のoperation/childが
146
+ なければ `recoverCompletedLocalCodingRun` で検証を自動復帰する。
147
+ すべての `waiting_human` を自動解除する機能ではなく、通常のPR recheckとも別経路。
148
+ この既存の復帰制御を説明する資料であり、新しいdaemonやLoopの起動を許可するものではない。
149
+ - recheck前の目的契約不正、または評価済み成果の `outcome_progress.objective_digest` 不一致は
150
+ `waiting_human`。head/base等に対する `evidence_identity_sha256` の失効・不足は
151
+ PR readinessの未達として `pr_ready_for_review` になり、同じ扱いではない。
152
+ - `pr_ready_for_review` はpending状態で、名前だけでは実装の合格さえ保証しない。
153
+ recheckで古い証拠が失効した場合もこの状態になる。checksとpending reasonsを読む。
154
+ - `merge_ready` と `done_gate_passed` は現在のPR readiness判定。
155
+ マージ実行、配備、元の依頼全体の完了を証明しない。
156
+
157
+ ## 4. 最終報告と証拠の失効
158
+
159
+ `verified_complete / best_available / blocked / failed / cancelled` はworkflowの
160
+ 終了理由。controller enumと混ぜたり、すべてを `done` に変換したりしない。
161
+ `verified_complete` は固定した依頼範囲の全条件が現在の証拠で満たされた場合のみ。
162
+
163
+ | 変化・停止 | 必要な行動 |
164
+ |---|---|
165
+ | head/base/環境/依存版の変化 | 影響する証拠をpendingに戻し、同じ受入IDで再取得・独立照合 |
166
+ | 承認された目的変更 | successor契約として履歴保持。旧目的のレビューを再利用しない |
167
+ | CIや依存処理の実行中 | bounded wait/recheck。重複jobを起動せず、未変化だけでblockedにしない |
168
+ | 人の権限・入力が本当に不足 | affected IDs、根拠、必要入力、owner、再開条件を記録。独立作業は継続 |
169
+ | 同じ失敗・成果停滞 | 原因と別の安全な方針を検討し、controllerの保護を迂回せず再開 |
170
+ | 中止 | チェックポイントと最高到達段階を保持し、未完了として報告 |
171
+
172
+ これは既存制御の説明とexecutor/auditorへの契約であり、追加の遷移強制validatorではない。
173
+
174
+ ## 5. 過去版との比較と採用理由
175
+
176
+ | 版 | 採用する点 | 単独では足りない点 |
177
+ |---|---|---|
178
+ | [9月8日 Slack仕様](https://specialist-doctors.slack.com/archives/C04URHQBJV7/p1788832579923809) | 13 milestones、単一controller、証拠・終了理由の分離 | 当時の回数・予算停止を現行に適用できない |
179
+ | [9月14日 #79](https://github.com/SpecialistDoctors-Inc/simy-cli/pull/79) | 未達→修正→再検証、確認不能からの復帰、minimumと全体完了の区別 | 体験全体・PR準備・rolloutの詳細が足りない |
180
+ | [9月16日 #97](https://github.com/SpecialistDoctors-Inc/simy-cli/pull/97) | 体験・レビュー計画・PR preparation・段階的配備・As-Is更新 | 主経路中心で、待機・修正・再開や実装との対応が読み取りづらい |
181
+ | 現行CLI 0.6.11 / 基準commit `3b9b453` | 実際の修正ループ、完了済みworker結果からの検証復帰、証拠再取得、進捗保護、PR recheck | workflowの13 milestonesとcontroller stateを一つの状態として読むと誤解する |
182
+ | 本統合版 | 全体判断図+到達段階と品質ゲート+実装制御図を相互参照 | 図や指示による保証範囲を超えた自動制御は追加しない |
183
+
184
+ 8月31日・9月2日のSlack ZIP添付は存在と名称のみ確認でき、本文を取得できなかったため、
185
+ 内容の優劣を断定していない。上表は読めた一次資料と現行ソースに基づく選定。
186
+ 未マージPRの機能は現行として含めない。更新時は `loop.js`、`audit.js`、`budget.js`、
187
+ `outcome-monitor.js`、`checklist-continuation.js`、`coding-run-recovery.js`、`runner.js`、`agent.js` と突合し、3層を一緒に見直す。
@@ -28,6 +28,10 @@ async function runCodingLoopOwned({
28
28
  shouldStop = () => false,
29
29
  consumeHumanGuidance = () => "",
30
30
  }) {
31
+ if (resume && snapshot.attempts.at(-1)?.ci_review_refresh_pending) {
32
+ return recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm, executeIndependentAudit,
33
+ shouldStop, onUpdate, retryCiReview: true });
34
+ }
31
35
  if (!resume && snapshot.attempts.length > 0 && !snapshot.metadata?.completed_worker_result) {
32
36
  return recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm, onUpdate });
33
37
  }
@@ -263,39 +267,7 @@ async function runCodingLoopOwned({
263
267
  });
264
268
  await publish(snapshot, "independent_auditing", onUpdate);
265
269
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
266
- const auditInstruction = buildIndependentAuditInstruction(snapshot.charter, attempt);
267
- const reviewedMatrix = attempt.observed_evidence?.minimum_completion?.matrix_sha256;
268
- const reviewedEvidenceIdentity = independentAuditEvidenceIdentity(attempt);
269
- const reviewedObjectiveDigest = snapshot.charter.thread_state?.objective_contract?.current_digest;
270
- const reviewedObjectiveVersion = snapshot.charter.thread_state?.objective_contract?.version;
271
- const auditExecution = await safeExecution(() =>
272
- executeIndependentAudit({
273
- attemptNumber,
274
- backend: snapshot.charter.audit_backend,
275
- instruction: auditInstruction,
276
- charter: snapshot.charter,
277
- attempt,
278
- }),
279
- );
280
- if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
281
- attempt.independent_audit = buildIndependentAudit(
282
- auditExecution,
283
- snapshot.charter,
284
- attempt,
285
- );
286
- attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
287
- attempt.budget = refreshBudgetState(snapshot);
288
- recordBudgetWarnings(snapshot, attempt);
289
- if (attempt.budget.token_exhausted) {
290
- await stopForBudget({ snapshot, attempt, reason: "token_budget_exhausted", onUpdate });
291
- return snapshot;
292
- }
293
-
294
- attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
295
- attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
296
- attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
297
- attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
298
- attempt.independent_audit.objective_version = reviewedObjectiveVersion;
270
+ if (await reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate })) return snapshot;
299
271
 
300
272
  // Re-collect after the read-only auditor to catch any mutated HEAD or working tree.
301
273
  attempt.observed_evidence = await safeEvidence(collectEvidence, attempt, { retries: snapshot.metadata?.completed_worker_result ? 2 : 0, shouldStop });
@@ -369,7 +341,63 @@ async function stopCodingLoop(snapshot, onUpdate) {
369
341
  return snapshot;
370
342
  }
371
343
 
372
- async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = null, onUpdate }) {
344
+ async function reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate }) {
345
+ const auditInstruction = buildIndependentAuditInstruction(snapshot.charter, attempt);
346
+ const reviewedMatrix = attempt.observed_evidence?.minimum_completion?.matrix_sha256;
347
+ const reviewedEvidenceIdentity = independentAuditEvidenceIdentity(attempt);
348
+ const reviewedChecks = structuredClone(attempt.observed_evidence?.github?.checks || []);
349
+ const reviewedObjectiveDigest = snapshot.charter.thread_state?.objective_contract?.current_digest;
350
+ const reviewedObjectiveVersion = snapshot.charter.thread_state?.objective_contract?.version;
351
+ const auditExecution = await safeExecution(() =>
352
+ executeIndependentAudit({
353
+ attemptNumber: attempt.attempt_number,
354
+ backend: snapshot.charter.audit_backend,
355
+ instruction: auditInstruction,
356
+ charter: snapshot.charter,
357
+ attempt,
358
+ }),
359
+ );
360
+ if (shouldStop()) { await stopCodingLoop(snapshot, onUpdate); return true; }
361
+ attempt.independent_audit = buildIndependentAudit(
362
+ auditExecution,
363
+ snapshot.charter,
364
+ attempt,
365
+ );
366
+ attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
367
+ attempt.budget = refreshBudgetState(snapshot);
368
+ recordBudgetWarnings(snapshot, attempt);
369
+ if (attempt.budget.token_exhausted) {
370
+ await stopForBudget({ snapshot, attempt, reason: "token_budget_exhausted", onUpdate });
371
+ return true;
372
+ }
373
+
374
+ attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
375
+ attempt.independent_audit.reviewed_checks = reviewedChecks;
376
+ attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
377
+ attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
378
+ attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
379
+ attempt.independent_audit.objective_version = reviewedObjectiveVersion;
380
+ return false;
381
+ }
382
+
383
+ // Only a CI-only change to the already reviewed candidate may automatically
384
+ // renew its review. Head/base/preparation/acceptance changes need explicit work.
385
+ function ciReviewRefreshIdentity(attempt, previousEvidence) {
386
+ const reviewed = attempt.independent_audit;
387
+ const github = attempt.observed_evidence?.github;
388
+ if (!reviewed?.passed || !reviewed.evidence_identity_sha256 || !github?.available ||
389
+ !attempt.observed_evidence?.local?.available || !attempt.observed_evidence.local.working_tree_clean ||
390
+ attempt.observed_evidence.local.errors?.length || github.error || !github.required_checks_present || !github.checks_passing ||
391
+ !github.checks?.length || github.checks.some(check => check.state !== 'success' && check.state !== 'skipped')) return null;
392
+ const identity = independentAuditEvidenceIdentity(attempt);
393
+ if (identity === reviewed.evidence_identity_sha256) return null;
394
+ const prior = { ...attempt, observed_evidence: { ...attempt.observed_evidence,
395
+ github: { ...github, checks: reviewed.reviewed_checks || previousEvidence?.github?.checks } } };
396
+ return independentAuditEvidenceIdentity(prior) === reviewed.evidence_identity_sha256 ? identity : null;
397
+ }
398
+
399
+ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = null, executeIndependentAudit, shouldStop = () => false, onUpdate, retryCiReview = false }) {
400
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
373
401
  if (!verifyObjective(snapshot.charter).valid) {
374
402
  snapshot.implementation_gate_passed = false;
375
403
  snapshot.done_gate_passed = false;
@@ -391,6 +419,7 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
391
419
  code: "OBJECTIVE_EVIDENCE_STALE", severity: "blocker", target: "objective_contract",
392
420
  explanation: "Make a new attempt and independently review evidence against the current objective.", repairability: "manual",
393
421
  }]);
422
+ if (retryCiReview) attempt.ci_review_refresh_pending = false;
394
423
  await publish(snapshot, "waiting_human", onUpdate);
395
424
  return snapshot;
396
425
  }
@@ -398,16 +427,72 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
398
427
  attempt_number: attempt.attempt_number,
399
428
  });
400
429
  await publish(snapshot, "checking_pr", onUpdate);
430
+ const previousEvidence = attempt.observed_evidence;
431
+ // Recover an old review's CI basis only while the saved full identity still
432
+ // proves it. Later monitoring observations cannot reconstruct that history.
433
+ if (!attempt.independent_audit?.reviewed_checks &&
434
+ attempt.independent_audit?.evidence_identity_sha256 === independentAuditEvidenceIdentity(attempt) &&
435
+ Array.isArray(previousEvidence?.github?.checks)) {
436
+ attempt.independent_audit.reviewed_checks = structuredClone(previousEvidence.github.checks);
437
+ }
401
438
  attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
439
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
402
440
  attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm, "full");
441
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
442
+ if (retryCiReview && attempt.observed_evidence?.local?.available && attempt.observed_evidence?.github?.available &&
443
+ independentAuditEvidenceIdentity(attempt) !== attempt.ci_review_refresh_identity) {
444
+ attempt.ci_review_refresh_pending = false;
445
+ snapshot.final_audit = humanGate("The candidate changed while its CI review was interrupted.", [{
446
+ code: "CI_REVIEW_CANDIDATE_CHANGED", severity: "blocker", target: "independent_audit",
447
+ explanation: "Reconcile the changed HEAD, base or evidence, then explicitly continue work; the prior review cannot certify this candidate.", repairability: "manual",
448
+ }]);
449
+ await publish(snapshot, "waiting_human", onUpdate);
450
+ return snapshot;
451
+ }
452
+ const retryIdentity = retryCiReview && attempt.ci_review_refresh_pending &&
453
+ independentAuditEvidenceIdentity(attempt) === attempt.ci_review_refresh_identity &&
454
+ attempt.observed_evidence?.local?.available && attempt.observed_evidence.local.working_tree_clean &&
455
+ !attempt.observed_evidence.local.errors?.length && attempt.observed_evidence?.github?.available &&
456
+ !attempt.observed_evidence.github.error && attempt.observed_evidence.github.checks_passing &&
457
+ attempt.observed_evidence.github.required_checks_present ? attempt.ci_review_refresh_identity : null;
458
+ const refreshIdentity = retryIdentity || ciReviewRefreshIdentity(attempt, previousEvidence);
459
+ if (executeIndependentAudit && refreshIdentity && (retryIdentity || attempt.ci_review_refresh_identity !== refreshIdentity)) {
460
+ const budget = canStartProvider(snapshot, { attemptNumber: attempt.attempt_number, phase: "reviewer" });
461
+ if (!budget.allowed) { await stopForBudget({ snapshot, attempt, reason: budget.reason, onUpdate }); return snapshot; }
462
+ // Persist before dispatch so an interrupted/failed review cannot repeat on
463
+ // every monitor tick. Explicit resume retries only this bound review.
464
+ attempt.ci_review_refresh_identity = refreshIdentity;
465
+ attempt.ci_review_refresh_pending = true;
466
+ appendEvent(snapshot, "independent_auditing", "CI completed; refreshing independent review of the same candidate.", {
467
+ attempt_number: attempt.attempt_number, evidence_identity: refreshIdentity,
468
+ });
469
+ await publish(snapshot, "independent_auditing", onUpdate);
470
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
471
+ if (await reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate })) return snapshot;
472
+ if (!attempt.independent_audit.passed &&
473
+ !attempt.independent_audit.findings.some(finding => finding.code === "INDEPENDENT_AUDIT_UNAVAILABLE")) {
474
+ // A completed adverse review belongs to the normal repair path. Only
475
+ // unavailable/interrupted reviews need a reviewer-only retry.
476
+ attempt.ci_review_refresh_pending = false;
477
+ }
478
+ attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
479
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
480
+ attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm, "full");
481
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
482
+ }
403
483
  attempt.audit = await auditAttempt(snapshot.charter, attempt, {
404
484
  previousAttempt: snapshot.attempts.at(-2) ?? null,
405
485
  sqm: attempt.sqm,
406
486
  });
487
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
407
488
  attempt.outcome_progress = assessOutcomeProgress(
408
489
  snapshot.charter, attempt, snapshot.attempts.at(-2) ?? null,
409
490
  outcomeOptions(snapshot),
410
491
  );
492
+ if (attempt.ci_review_refresh_pending && attempt.audit.passed && attempt.independent_audit?.passed &&
493
+ attempt.independent_audit.evidence_identity_sha256 === independentAuditEvidenceIdentity(attempt)) {
494
+ attempt.ci_review_refresh_pending = false;
495
+ }
411
496
  snapshot.implementation_gate_passed = attempt.audit.passed;
412
497
  attempt.pr_readiness = evaluatePrReadiness(snapshot.charter, attempt);
413
498
  attempt.pr_gate = attempt.pr_readiness;
package/src/runner.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  normalizeWorkflowStartIntent,
52
52
  } from "./workflow-observation-sender.js";
53
53
  import { saveCodingRunSnapshot } from "./coding-run-store.js";
54
- import { acquireDesktopProcessLock } from "./desktop-process-lock.js";
54
+ import { acquireDesktopProcessLock, PROCESS_CONFLICT_CODE } from "./desktop-process-lock.js";
55
55
  import {
56
56
  assertRecoveryActor, completedWorkerResult, recoveryActorBinding, refreshRecoveryAudit, refreshRecoveryBudgetAudit,
57
57
  } from "./coding-run-recovery.js";
@@ -924,7 +924,24 @@ async function cleanupLocalAttachments(run) {
924
924
  }
925
925
  }
926
926
 
927
- export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
927
+ export async function recheckLocalCodingRun(run, options = {}) {
928
+ if (!run?.snapshot?.attempts?.length) return run?.snapshot || null;
929
+ if (!["pr_ready_for_review", "merge_ready"].includes(run.status)) return run.snapshot;
930
+ if (run.operation || run.child || run.stopRequested) return run.snapshot;
931
+ const operation = (async () => {
932
+ assertRecoveryActor(run);
933
+ const lease = run.localStateRoot ? await acquireDesktopProcessLock(
934
+ path.join(run.localStateRoot, ".controllers", executionTextSha256(run.id)),
935
+ ) : null;
936
+ try { return await recheckLocalCodingRunOwned(run, options); }
937
+ finally { await lease?.release(); }
938
+ })();
939
+ run.operation = operation;
940
+ try { return await operation; }
941
+ finally { if (run.operation === operation) run.operation = null; }
942
+ }
943
+
944
+ async function recheckLocalCodingRunOwned(run, { collectEvidence, executeIndependentAudit, authorizeReview } = {}) {
928
945
  if (!run?.snapshot?.attempts?.length) return run?.snapshot || null;
929
946
  if (!["pr_ready_for_review", "merge_ready"].includes(run.status)) return run.snapshot;
930
947
  const repositoryPath = await resolveRepositoryPath(run.request);
@@ -956,6 +973,15 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
956
973
  await recheckPrReadiness({
957
974
  snapshot: run.snapshot,
958
975
  collectEvidence: evidenceCollector,
976
+ executeIndependentAudit: async context => {
977
+ await authorizeReview?.();
978
+ assertRecoveryActor(run);
979
+ if (run.stopRequested) throw new Error("Review cancelled by the local operator.");
980
+ return executeIndependentAudit ? executeIndependentAudit(context) : executeProcessAttempt({
981
+ ...context, repositoryPath, run, marker: "SIMY_AUDIT_JSON:",
982
+ });
983
+ },
984
+ shouldStop: () => run.stopRequested,
959
985
  checkSqm: sqmChecker,
960
986
  onUpdate: async (snapshot) => {
961
987
  run.snapshot = snapshot;
@@ -966,6 +992,8 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
966
992
  }
967
993
 
968
994
  export async function markCodingRunMonitorFailure(run, error) {
995
+ // A losing controller never owns the right to persist its stale snapshot.
996
+ if (run.stopRequested || run.status === "stopped" || error?.code === PROCESS_CONFLICT_CODE) return run.snapshot;
969
997
  const message = error instanceof Error ? error.message : String(error);
970
998
  run.snapshot.done_gate_passed = false;
971
999
  run.snapshot.final_audit = {
@@ -1026,6 +1054,7 @@ export async function executeProcessAttempt({
1026
1054
  model: phase === "reviewer" ? charter?.audit_model : charter?.model,
1027
1055
  reasoningEffort: phase === "reviewer" ? charter?.audit_reasoning_effort : charter?.reasoning_effort,
1028
1056
  });
1057
+ if (run.stopRequested) throw new Error("Execution cancelled by the local operator.");
1029
1058
  const startedAt = new Date().toISOString();
1030
1059
  const stdout = [];
1031
1060
  const stderr = [];
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { maintainWindowsUpdate, selectedWindowsRoot } from './windows-update.js';
6
+ import { desktopArguments } from './windows-inbound.js';
6
7
  const installation = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
7
8
  const stateRoot = path.join(process.env.LOCALAPPDATA, 'SIMY CLI', 'updates');
8
9
  const argv = process.argv.slice(2);
@@ -22,7 +23,7 @@ if (updating) {
22
23
  const desktop = argv[0] === '--desktop';
23
24
  if (desktop && !existsSync(path.join(root, 'simy-desktop.exe'))) root = installation;
24
25
  const child = spawn(path.join(root, desktop ? 'simy-desktop.exe' : 'runtime/node.exe'),
25
- desktop ? ['--selected'] : [path.join(root, 'cli/src/index.js'), ...argv], {
26
+ desktop ? desktopArguments(argv) : [path.join(root, 'cli/src/index.js'), ...argv], {
26
27
  stdio: 'inherit', env: { ...process.env, ...(desktop ? {} : { SIMY_AUTO_UPDATE: '0' }) },
27
28
  });
28
29
  // Windows console signals are already delivered to both processes.
@@ -0,0 +1,9 @@
1
+ // Keep the URI handoff separate from update and launch code so its exact
2
+ // command shape is covered by a platform-neutral test. desktop.cs remains the
3
+ // authority that validates the URI before it changes a visible selection.
4
+ export function desktopArguments(argv) {
5
+ // This internal marker distinguishes the desktop spawned by the update
6
+ // bootstrap from the bootstrap itself. It carries no inbound URI.
7
+ if (argv.length === 1) return ['--selected'];
8
+ return ['--selected', ...argv.slice(1)];
9
+ }