otacon 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +101 -64
  2. package/dist/cli/browser.js +55 -0
  3. package/dist/cli/browser.js.map +1 -0
  4. package/dist/cli/commands/clean.js +3 -2
  5. package/dist/cli/commands/clean.js.map +1 -1
  6. package/dist/cli/commands/config.js +62 -0
  7. package/dist/cli/commands/config.js.map +1 -0
  8. package/dist/cli/commands/doctor.js +41 -21
  9. package/dist/cli/commands/doctor.js.map +1 -1
  10. package/dist/cli/commands/install.js +53 -30
  11. package/dist/cli/commands/install.js.map +1 -1
  12. package/dist/cli/commands/open.js +14 -14
  13. package/dist/cli/commands/open.js.map +1 -1
  14. package/dist/cli/commands/start.js +1 -22
  15. package/dist/cli/commands/start.js.map +1 -1
  16. package/dist/cli/install/assets.js +35 -43
  17. package/dist/cli/install/assets.js.map +1 -1
  18. package/dist/cli/install/locations.js +15 -25
  19. package/dist/cli/install/locations.js.map +1 -1
  20. package/dist/cli/main.js +6 -1
  21. package/dist/cli/main.js.map +1 -1
  22. package/dist/daemon/app.js +281 -54
  23. package/dist/daemon/app.js.map +1 -1
  24. package/dist/daemon/approve.js +49 -11
  25. package/dist/daemon/approve.js.map +1 -1
  26. package/dist/daemon/store.js +39 -2
  27. package/dist/daemon/store.js.map +1 -1
  28. package/dist/daemon/threads.js +11 -0
  29. package/dist/daemon/threads.js.map +1 -1
  30. package/dist/daemon/ui.js +3 -0
  31. package/dist/daemon/ui.js.map +1 -1
  32. package/dist/shared/config.js +290 -45
  33. package/dist/shared/config.js.map +1 -1
  34. package/dist/shared/paths.js +33 -5
  35. package/dist/shared/paths.js.map +1 -1
  36. package/dist/shared/types.js +6 -2
  37. package/dist/shared/types.js.map +1 -1
  38. package/dist/shared/version.js +1 -1
  39. package/dist/ui/assets/{arc-HhPfdCPZ.js → arc-KT3ZnaMp.js} +1 -1
  40. package/dist/ui/assets/architecture-7EHR7CIX-Cw3I1lil.js +1 -0
  41. package/dist/ui/assets/{architectureDiagram-3BPJPVTR-D2PIxGOb.js → architectureDiagram-3BPJPVTR-DLu0UM7N.js} +1 -1
  42. package/dist/ui/assets/{blockDiagram-GPEHLZMM-DQ3Dn17h.js → blockDiagram-GPEHLZMM-B8wApEWC.js} +1 -1
  43. package/dist/ui/assets/{c4Diagram-AAUBKEIU-DxITrQgS.js → c4Diagram-AAUBKEIU-BNS5gmQS.js} +1 -1
  44. package/dist/ui/assets/channel-CkOta24Z.js +1 -0
  45. package/dist/ui/assets/{chunk-2J33WTMH-Du1JoPx5.js → chunk-2J33WTMH-CTY2etwY.js} +1 -1
  46. package/dist/ui/assets/{chunk-3OPIFGDE-Dn7x2Yqf.js → chunk-3OPIFGDE-DZM4Sz84.js} +1 -1
  47. package/dist/ui/assets/{chunk-4BX2VUAB-DVnrE-4n.js → chunk-4BX2VUAB-sGwrrXIO.js} +1 -1
  48. package/dist/ui/assets/{chunk-55IACEB6-BAhFAimA.js → chunk-55IACEB6-CGlNp76o.js} +1 -1
  49. package/dist/ui/assets/{chunk-5ZQYHXKU-0hEZptem.js → chunk-5ZQYHXKU-5zebJ3jW.js} +1 -1
  50. package/dist/ui/assets/{chunk-727SXJPM-C1FN_cI3.js → chunk-727SXJPM-DelmUpvy.js} +1 -1
  51. package/dist/ui/assets/{chunk-AQP2D5EJ-A656OBd4.js → chunk-AQP2D5EJ-DMVzBf3M.js} +1 -1
  52. package/dist/ui/assets/{chunk-BSJP7CBP-D8oMbjm8.js → chunk-BSJP7CBP-CZHrcpSu.js} +1 -1
  53. package/dist/ui/assets/{chunk-CSCIHK7Q-DjIL8GLi.js → chunk-CSCIHK7Q-C1efTp0O.js} +1 -1
  54. package/dist/ui/assets/{chunk-FMBD7UC4-Otblfqvz.js → chunk-FMBD7UC4-B6axGwgn.js} +1 -1
  55. package/dist/ui/assets/{chunk-KSCS5N6A-BOjTvm3H.js → chunk-KSCS5N6A-DSaxbrm0.js} +1 -1
  56. package/dist/ui/assets/{chunk-L5ZTLDWV-CaTLaw6L.js → chunk-L5ZTLDWV-D9ZKdVGx.js} +1 -1
  57. package/dist/ui/assets/{chunk-LZXEDZCA-Dq5p7qrD.js → chunk-LZXEDZCA-DI5_1s00.js} +2 -2
  58. package/dist/ui/assets/{chunk-ND2GUHAM-jZ_NNnWi.js → chunk-ND2GUHAM-DQQ4RVLE.js} +1 -1
  59. package/dist/ui/assets/{chunk-NZK2D7GU-U_7l_sCh.js → chunk-NZK2D7GU-9zJJaDpb.js} +1 -1
  60. package/dist/ui/assets/{chunk-O5CBEL6O-MewqqNB7.js → chunk-O5CBEL6O-DUOshAt2.js} +1 -1
  61. package/dist/ui/assets/chunk-QZHKN3VN-DqRxzBM_.js +1 -0
  62. package/dist/ui/assets/chunk-WU5MYG2G-B1Mk3aBp.js +1 -0
  63. package/dist/ui/assets/{chunk-XPW4576I-D5ArxNEF.js → chunk-XPW4576I-DQVL_GAl.js} +1 -1
  64. package/dist/ui/assets/classDiagram-4FO5ZUOK-BH-5P4jH.js +1 -0
  65. package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-BH-5P4jH.js +1 -0
  66. package/dist/ui/assets/{cose-bilkent-S5V4N54A-PFXzf7WV.js → cose-bilkent-S5V4N54A-C7m3VlSW.js} +1 -1
  67. package/dist/ui/assets/{dagre-BM42HDAG-xrCfjZuZ.js → dagre-BM42HDAG-CoU_T6EY.js} +1 -1
  68. package/dist/ui/assets/{diagram-2AECGRRQ-BFf-cyKY.js → diagram-2AECGRRQ-C_R7oNKE.js} +1 -1
  69. package/dist/ui/assets/{diagram-5GNKFQAL-kNPV4NfV.js → diagram-5GNKFQAL-UhE2W3Yi.js} +1 -1
  70. package/dist/ui/assets/{diagram-KO2AKTUF-ByC1IUwG.js → diagram-KO2AKTUF-D4KZCRld.js} +1 -1
  71. package/dist/ui/assets/{diagram-LMA3HP47-DZIJMPK0.js → diagram-LMA3HP47-CsOe8-S6.js} +1 -1
  72. package/dist/ui/assets/{diagram-OG6HWLK6-CSDED9A-.js → diagram-OG6HWLK6-Bkbs2V54.js} +1 -1
  73. package/dist/ui/assets/{dist-YwjsDswi.js → dist-BcowSnUl.js} +1 -1
  74. package/dist/ui/assets/{erDiagram-TEJ5UH35-yuzvjE6J.js → erDiagram-TEJ5UH35-DJXsykZz.js} +1 -1
  75. package/dist/ui/assets/eventmodeling-FCH6USID-DSDI1f5T.js +1 -0
  76. package/dist/ui/assets/{flowDiagram-I6XJVG4X-ApPtVyYM.js → flowDiagram-I6XJVG4X-Chguj9Xz.js} +1 -1
  77. package/dist/ui/assets/{ganttDiagram-6RSMTGT7-BeMLXtAr.js → ganttDiagram-6RSMTGT7-DXdpFesp.js} +1 -1
  78. package/dist/ui/assets/{gitGraph-WXDBUCRP-JmTTBa7j.js → gitGraph-WXDBUCRP-Doq1CcxB.js} +1 -1
  79. package/dist/ui/assets/{gitGraphDiagram-PVQCEYII-Cjjnjs71.js → gitGraphDiagram-PVQCEYII-uxGwHEPv.js} +1 -1
  80. package/dist/ui/assets/index-BsTJ9Ul_.css +1 -0
  81. package/dist/ui/assets/index-D-TSanrw.js +11 -0
  82. package/dist/ui/assets/{info-J43DQDTF-8vZ3gome.js → info-J43DQDTF-CgWT_d3M.js} +1 -1
  83. package/dist/ui/assets/{infoDiagram-5YYISTIA-CnMk1cA-.js → infoDiagram-5YYISTIA-B3oDA2Ct.js} +1 -1
  84. package/dist/ui/assets/{ishikawaDiagram-YF4QCWOH-Bl8z6huD.js → ishikawaDiagram-YF4QCWOH-C6l0lvC3.js} +1 -1
  85. package/dist/ui/assets/{journeyDiagram-JHISSGLW-DYIVfMpS.js → journeyDiagram-JHISSGLW-COf53NwC.js} +1 -1
  86. package/dist/ui/assets/{kanban-definition-UN3LZRKU-BnR0ZzOz.js → kanban-definition-UN3LZRKU-DaOPRBld.js} +1 -1
  87. package/dist/ui/assets/{line-DcBdQit6.js → line-new3jLu3.js} +1 -1
  88. package/dist/ui/assets/{linear-HKjRHFAO.js → linear-yA22knFB.js} +1 -1
  89. package/dist/ui/assets/{mermaid-parser.core-DkYXrPlA.js → mermaid-parser.core-D8JVCOKU.js} +2 -2
  90. package/dist/ui/assets/{mermaid.core-BmkfCI3b.js → mermaid.core-BCrPyVBK.js} +3 -3
  91. package/dist/ui/assets/{mindmap-definition-RKZ34NQL-sIAd4nDi.js → mindmap-definition-RKZ34NQL-CVcJTWsW.js} +1 -1
  92. package/dist/ui/assets/{packet-YPE3B663-BxbxcfXN.js → packet-YPE3B663-DlSwpK-G.js} +1 -1
  93. package/dist/ui/assets/{pie-LRSECV5Y-BJxazjNs.js → pie-LRSECV5Y-CyMpbo4C.js} +1 -1
  94. package/dist/ui/assets/{pieDiagram-4H26LBE5-BiOhc9GR.js → pieDiagram-4H26LBE5-Dy7day5R.js} +1 -1
  95. package/dist/ui/assets/{plan-view-CH6NzUDb.js → plan-view-CE2ha0qY.js} +3 -3
  96. package/dist/ui/assets/{quadrantDiagram-W4KKPZXB-CVyHbWgo.js → quadrantDiagram-W4KKPZXB-CVjVgbaQ.js} +1 -1
  97. package/dist/ui/assets/{radar-GUYGQ44K-D9ohbnbV.js → radar-GUYGQ44K-DLPfv0jT.js} +1 -1
  98. package/dist/ui/assets/{requirementDiagram-4Y6WPE33-Ba24_hqc.js → requirementDiagram-4Y6WPE33-y_0KOdN8.js} +1 -1
  99. package/dist/ui/assets/{sankeyDiagram-5OEKKPKP-CxD4wiPL.js → sankeyDiagram-5OEKKPKP-8T3b7meL.js} +1 -1
  100. package/dist/ui/assets/{sequenceDiagram-3UESZ5HK-7qA7lD61.js → sequenceDiagram-3UESZ5HK-Cn8DZiEr.js} +1 -1
  101. package/dist/ui/assets/{src-IM8AE8MK.js → src-BieOuieI.js} +1 -1
  102. package/dist/ui/assets/{stateDiagram-AJRCARHV-DNElRCuH.js → stateDiagram-AJRCARHV-nPEVGd3E.js} +1 -1
  103. package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-CKrff-KJ.js +1 -0
  104. package/dist/ui/assets/{timeline-definition-PNZ67QCA-ChYC4Grd.js → timeline-definition-PNZ67QCA-Dj_qK0VJ.js} +1 -1
  105. package/dist/ui/assets/{treeView-BLDUP644-Il0KnMi_.js → treeView-BLDUP644-G5Bsebqu.js} +1 -1
  106. package/dist/ui/assets/{treemap-LRROVOQU-CIiKcdRo.js → treemap-LRROVOQU-anUEJzTb.js} +1 -1
  107. package/dist/ui/assets/{vennDiagram-CIIHVFJN-Ulhkum9i.js → vennDiagram-CIIHVFJN-D71ne3dS.js} +1 -1
  108. package/dist/ui/assets/{wardley-L42UT6IY-BNd4ljz7.js → wardley-L42UT6IY-DrJilmE_.js} +1 -1
  109. package/dist/ui/assets/{wardleyDiagram-YWT4CUSO-BicXxh84.js → wardleyDiagram-YWT4CUSO-B66G4ayp.js} +1 -1
  110. package/dist/ui/assets/{xychartDiagram-2RQKCTM6-Duf-m_th.js → xychartDiagram-2RQKCTM6-CReSRxNG.js} +1 -1
  111. package/dist/ui/index.html +2 -2
  112. package/package.json +1 -1
  113. package/dist/ui/assets/architecture-7EHR7CIX-BPLblcyi.js +0 -1
  114. package/dist/ui/assets/channel-ipcU8ZNI.js +0 -1
  115. package/dist/ui/assets/chunk-QZHKN3VN-DzGPH44B.js +0 -1
  116. package/dist/ui/assets/chunk-WU5MYG2G-DyEIVjoo.js +0 -1
  117. package/dist/ui/assets/classDiagram-4FO5ZUOK-Byg2Hl9D.js +0 -1
  118. package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-Byg2Hl9D.js +0 -1
  119. package/dist/ui/assets/eventmodeling-FCH6USID-CZR4eNG-.js +0 -1
  120. package/dist/ui/assets/index-BFQVRcSI.js +0 -11
  121. package/dist/ui/assets/index-Bj_kTrwP.css +0 -1
  122. package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-D6qTYpY3.js +0 -1
@@ -11,13 +11,13 @@
11
11
  // and the waiter is canceled without consuming anything.
12
12
  import { Hono } from "hono";
13
13
  import { isAbsolute, join } from "node:path";
14
- import { loadConfig } from "../shared/config.js";
15
- import { otaconPort } from "../shared/paths.js";
14
+ import { CONFIG_SCHEMA, loadConfig, readScopeValues, validateScopeInput, } from "../shared/config.js";
15
+ import { globalConfigPath, otaconPort, repoConfigPath, repoLocalConfigPath, } from "../shared/paths.js";
16
16
  import { parseQuestionSpec } from "../shared/question-spec.js";
17
17
  import { TERMINAL_STATUSES } from "../shared/types.js";
18
18
  import { VERSION } from "../shared/version.js";
19
19
  import { appendActivity, latestNote, readActivity } from "./activity.js";
20
- import { composeArtifact, localDate, pickArtifactRelPath } from "./approve.js";
20
+ import { composeArtifact, localDate, pickHomePath, pickProjectRelPath } from "./approve.js";
21
21
  import { createDesktopNotifier } from "./desktop-notify.js";
22
22
  import { diffPlans } from "./diff.js";
23
23
  import { lint } from "./linter/index.js";
@@ -25,7 +25,7 @@ import { Notifier } from "./notify.js";
25
25
  import { Presence } from "./presence.js";
26
26
  import { SessionQueue } from "./queue.js";
27
27
  import { writeFileAtomic } from "./store.js";
28
- import { answerQuestion, appendThreads, applyRevisionToThreads, commentThreadStates, readThreads, } from "./threads.js";
28
+ import { answerQuestion, appendThreads, applyRevisionToThreads, commentThreadStates, openCommentThreads, readThreads, } from "./threads.js";
29
29
  import { answerEntry, appendEntries, appendEntry, readTranscript } from "./transcript.js";
30
30
  import { registerUiRoutes } from "./ui.js";
31
31
  /** Hard ceiling on ?wait= (seconds); agents ask for 540 under their 600s Bash cap. */
@@ -41,6 +41,21 @@ const timeoutEvent = (c) => c.json({ event: "timeout" });
41
41
  // `implementing` is non-terminal — it deliberately re-opens the mutating verbs
42
42
  // while the agent builds the approved plan, so it does NOT trip this wall.)
43
43
  const sessionOver = (c, id) => c.json({ error: { code: "E_SESSION_OVER", message: `session ${id} is over (terminal)` } }, 409);
44
+ // `implementing` is non-terminal so it slips past sessionOver, but a build is
45
+ // under way: submit would clobber the approved plan, and a re-approve would
46
+ // re-write the artifact. Both verbs refuse with this shared 409.
47
+ const alreadyImplementing = (c, id) => c.json({ error: { code: "E_ALREADY_IMPLEMENTING", message: `session ${id} is already implementing` } }, 409);
48
+ // `finalizing` is non-terminal (the agent's fold-in submit must still mutate),
49
+ // but it is a locked window: only that solo fold-in pass may touch the session.
50
+ // A reviewer comment here would clobber the status back to `revising` while
51
+ // `pendingApproval` stayed armed (a later clean submit would then silently
52
+ // finalize) and hand the agent an un-swept thread that wedges L5. Refuse it.
53
+ const alreadyFinalizing = (c, id) => c.json({
54
+ error: {
55
+ code: "E_ALREADY_FINALIZING",
56
+ message: `session ${id} is finalizing; the agent is folding in comments`,
57
+ },
58
+ }, 409);
44
59
  async function readJsonBody(c) {
45
60
  try {
46
61
  const parsed = await c.req.json();
@@ -236,6 +251,52 @@ export function createApp(options) {
236
251
  }
237
252
  return c.json(event.payload);
238
253
  }
254
+ /**
255
+ * Finalize an approval (DESIGN.md §6 step 6/7, §12). Writes the composed
256
+ * artifact (with the comment-&-approve `## Review notes` when `reviewNotes` are
257
+ * present). It ALWAYS writes the canonical home copy
258
+ * (`~/.otacon/sessions/<id>/`, the permanent archive).
259
+ * On **Save** (implement=false) it ALSO writes a project copy under the repo's
260
+ * configured `plans.dir`, and the event `path` points there. On **Implement**
261
+ * (implement=true) it writes home only, and `path` equals `home`. The home
262
+ * write is the crash-safe finalize point — file(s) before the status flip.
263
+ * Then flip the session to `approved` or `implementing`, disarm any deferred
264
+ * approval, and queue the `approved` wake-up. Shared by plain/force approve and
265
+ * the deferred fold-in submit so the artifact and event shapes are identical on
266
+ * every path. Returns the event's `path` and absolute `home`.
267
+ */
268
+ const finalizeApproval = (session, opts) => {
269
+ const artifact = composeArtifact(opts.markdown, {
270
+ revision: opts.revision,
271
+ entries: readTranscript(store.transcriptPath(session.id)),
272
+ reviewNotes: opts.reviewNotes,
273
+ });
274
+ const date = localDate();
275
+ const home = pickHomePath(session.id, session.title, date);
276
+ writeFileAtomic(home, artifact);
277
+ // Save writes a project copy and reports it; Implement builds from home, so
278
+ // nothing is written into the project and `path` is the home copy.
279
+ let path = home;
280
+ if (!opts.implement) {
281
+ const plansDir = loadConfig(session.repo).plans.dir;
282
+ const relPath = pickProjectRelPath(session.repo, plansDir, session.title, date);
283
+ writeFileAtomic(join(session.repo, relPath), artifact);
284
+ path = relPath;
285
+ }
286
+ const updated = store.updateSession(session.id, {
287
+ status: opts.implement ? "implementing" : "approved",
288
+ });
289
+ // Disarm after the flip: a crash between them leaves a stale flag on an
290
+ // already-terminal/building session (harmless — no further submit finalizes),
291
+ // never a finalizing session that lost its flag (which would re-open review).
292
+ store.clearPendingApproval(session.id);
293
+ const payload = opts.implement
294
+ ? { event: "approved", session: session.id, path, home, implement: true }
295
+ : { event: "approved", session: session.id, path, home };
296
+ queueFor(session.id).enqueue(payload, store.bumpCounter(session.id, "eventSeq"));
297
+ publishSession(updated); // after the enqueue, so the summary carries the fresh pending count
298
+ return { path, home };
299
+ };
239
300
  const app = new Hono();
240
301
  // Loopback binding doesn't stop a malicious webpage from firing fetch() at
241
302
  // 127.0.0.1 (no-cors requests are delivered even though the response is
@@ -252,6 +313,58 @@ export function createApp(options) {
252
313
  app.onError((error, c) => c.json({ error: { code: "E_INTERNAL", message: error.message } }, 500));
253
314
  app.notFound((c) => c.json({ error: { code: "E_NOT_FOUND", message: `no route: ${c.req.method} ${c.req.path}` } }, 404));
254
315
  app.get("/api/health", (c) => c.json({ app: "otacond", version: VERSION, pid: process.pid }));
316
+ // The Settings UI's config surface (DESIGN.md §6). GET returns the full
317
+ // schema plus each scope's current sparse, coerced values. The `user` scope
318
+ // (~/.otacon/config.json) is always present; the project scopes only when an
319
+ // absolute `repo` is named — User config needs no repo, so an absent/empty/
320
+ // non-absolute `repo` omits them (matching the isAbsolute guard POST applies
321
+ // to the write). `project` is the team-shared <repo>/.otacon/config.json;
322
+ // `project.local` is the personal <repo>/.otacon/config.local.json override.
323
+ app.get("/api/config", (c) => {
324
+ const repo = c.req.query("repo");
325
+ const scopes = {
326
+ user: { path: globalConfigPath(), values: readScopeValues(globalConfigPath()) },
327
+ };
328
+ if (repo !== undefined && repo !== "" && isAbsolute(repo)) {
329
+ const projectPath = repoConfigPath(repo);
330
+ const localPath = repoLocalConfigPath(repo);
331
+ scopes.project = { path: projectPath, values: readScopeValues(projectPath), repo };
332
+ scopes["project.local"] = { path: localPath, values: readScopeValues(localPath), repo };
333
+ }
334
+ return c.json({ schema: CONFIG_SCHEMA, scopes });
335
+ });
336
+ // POST replaces one scope file with the sanitized sparse values
337
+ // (DECISIONS.md "Config POST replaces"). A field the UI cleared is absent
338
+ // from `values` and so is dropped from the file — it reverts to inherited.
339
+ // `scope` must be "user", "project", or "project.local"; both project scopes
340
+ // require a `repo` (400 otherwise). Validation failures return 422 with
341
+ // per-field errors and write nothing. The same-origin guard above (covering
342
+ // every non-GET /api/*) protects this mutating call.
343
+ app.post("/api/config", async (c) => {
344
+ const body = (await readJsonBody(c)) ?? {};
345
+ const { scope, repo } = body;
346
+ if (scope !== "user" && scope !== "project" && scope !== "project.local") {
347
+ return badRequest(c, 'scope must be "user", "project", or "project.local"');
348
+ }
349
+ let path;
350
+ if (scope === "user") {
351
+ path = globalConfigPath();
352
+ }
353
+ else {
354
+ if (typeof repo !== "string" || !isAbsolute(repo)) {
355
+ return badRequest(c, "project scope requires an absolute repo path");
356
+ }
357
+ path = scope === "project" ? repoConfigPath(repo) : repoLocalConfigPath(repo);
358
+ }
359
+ const result = validateScopeInput(body.values);
360
+ if (result.errors.length > 0) {
361
+ return c.json({ fieldErrors: result.errors }, 422);
362
+ }
363
+ // Replace, don't merge: writeFileAtomic mkdir -p's the parent, so the
364
+ // already-present .otacon/ (or a missing ~/.otacon/) is handled either way.
365
+ writeFileAtomic(path, JSON.stringify(result.values, null, 2));
366
+ return c.json({ values: result.values });
367
+ });
255
368
  app.post("/api/shutdown", (c) => {
256
369
  // Fire the hook only once the response is out (or the client is already
257
370
  // gone): main.ts exits the process in it, and a timing guess would race
@@ -291,11 +404,12 @@ export function createApp(options) {
291
404
  return notFound(c, `unknown session: ${c.req.param("id")}`);
292
405
  return c.json(summarize(session));
293
406
  });
294
- // DELETE removes a session from the registry, status-branched on whether it
295
- // has committed value (DESIGN.md §6, §12). **Terminal** (approved, plus
407
+ // DELETE removes a session from the registry, status-branched on whether its
408
+ // plan is already preserved (DESIGN.md §6, §12). **Terminal** (approved, plus
296
409
  // implemented/implement_failed once a build finishes): its plan + transcript
297
- // are committed under docs/plans/, so the working dir is *archived* to
298
- // .otacon/archive/ (recoverable) — `otacon clean` and the UI's delete of an
410
+ // are in the home archive (~/.otacon/sessions/<id>/, never touched here), so the
411
+ // working dir is *archived* to .otacon/archive/ (recoverable) — `otacon clean`
412
+ // and the UI's delete of an
299
413
  // over session both take this path. Gated on TERMINAL_STATUSES so this split
300
414
  // agrees with the UI's `over` (which passes `approved={isOver(status)}` to the
301
415
  // confirm sheet) — otherwise an `implemented` delete would promise archival
@@ -408,6 +522,16 @@ export function createApp(options) {
408
522
  let content = await c.req.text();
409
523
  if (sessionEnded(session.id))
410
524
  return sessionOver(c, session.id);
525
+ // A submit cannot land mid-build. `implementing` is non-terminal (it re-opens
526
+ // progress/ask/wait/answer, DESIGN.md §6) so it slips past sessionEnded — but
527
+ // submit is not in that verb set, and a revision here would clobber the
528
+ // approved plan. This also serializes the double-finalize race: a comment-&-
529
+ // approve fold-in that flips to `implementing` is the winner, and a second
530
+ // submit racing it is refused here (an `approved` finalize is caught by
531
+ // sessionEnded above instead).
532
+ if (store.getSession(session.id)?.status === "implementing") {
533
+ return alreadyImplementing(c, session.id);
534
+ }
411
535
  bumpContact(session.id);
412
536
  let resolutions = {};
413
537
  if (c.req.header("content-type")?.includes("json")) {
@@ -461,15 +585,60 @@ export function createApp(options) {
461
585
  replies,
462
586
  revision,
463
587
  });
588
+ // The accepted revision and its settled threads go out the same way on both
589
+ // the fold-in and the ordinary path — but at different points relative to the
590
+ // status flip (finalizeApproval vs publishSession), so each branch fires this.
591
+ const publishRevision = () => {
592
+ notifier.publish({
593
+ type: "revision",
594
+ session: session.id,
595
+ data: { session: session.id, revision, changelog },
596
+ });
597
+ for (const thread of changedThreads)
598
+ publishThread(session.id, thread);
599
+ };
600
+ // Deferred approval (comment & approve, DESIGN.md §6, §12): a send-to-agent
601
+ // approve armed `pendingApproval` and parked the session in `finalizing`.
602
+ // This clean submit is the agent's fold-in pass — L5 has just vouched that
603
+ // every open comment carries a resolution — so finalize now instead of
604
+ // returning to in_review. The swept threads (re-read post-resolution) become
605
+ // the committed `## Review notes`, so the unreviewed fold-in stays auditable.
606
+ const pending = state.pendingApproval;
607
+ if (pending) {
608
+ const swept = new Set(pending.threads);
609
+ const reviewNotes = readThreads(store.threadsPath(session.id))
610
+ .filter((t) => t.kind === "comment" && swept.has(t.id))
611
+ .map((t) => ({
612
+ thread: t.id,
613
+ section: t.anchor?.section ?? null,
614
+ body: t.body,
615
+ resolution: t.resolution?.body ?? "",
616
+ }));
617
+ const { path, home } = finalizeApproval(session, {
618
+ revision,
619
+ markdown: content,
620
+ implement: pending.implement,
621
+ reviewNotes,
622
+ });
623
+ // The fold-in produced a real revision and resolved threads; publish them
624
+ // so the rail/diff stay honest (the implement variant keeps the screen
625
+ // live, a plain finalize flips it to the approved notice).
626
+ publishRevision();
627
+ return c.json({
628
+ ok: true,
629
+ session: session.id,
630
+ revision,
631
+ status: pending.implement ? "implementing" : "approved",
632
+ path,
633
+ home,
634
+ finalized: true,
635
+ warnings: result.warnings,
636
+ resolved: Object.keys(replies),
637
+ });
638
+ }
464
639
  const updated = store.updateSession(session.id, { status: "in_review" });
465
640
  publishSession(updated);
466
- notifier.publish({
467
- type: "revision",
468
- session: session.id,
469
- data: { session: session.id, revision, changelog },
470
- });
471
- for (const thread of changedThreads)
472
- publishThread(session.id, thread);
641
+ publishRevision();
473
642
  // The ball is back in the user's court: a fresh revision awaits review.
474
643
  maybeNotify(session, { kind: "revision", revision });
475
644
  return c.json({
@@ -562,6 +731,12 @@ export function createApp(options) {
562
731
  const body = (await readJsonBody(c)) ?? {};
563
732
  if (sessionEnded(session.id))
564
733
  return sessionOver(c, session.id);
734
+ // A `finalizing` session is locked to the agent's solo fold-in pass — a new
735
+ // comment here would clobber it back to `revising` with `pendingApproval`
736
+ // still armed and hand the agent an un-swept thread that wedges L5 (D7).
737
+ if (store.getSession(session.id)?.status === "finalizing") {
738
+ return alreadyFinalizing(c, session.id);
739
+ }
565
740
  bumpContact(session.id);
566
741
  const rawItems = body.items;
567
742
  if (!Array.isArray(rawItems) || rawItems.length === 0) {
@@ -908,15 +1083,26 @@ export function createApp(options) {
908
1083
  publishSession(session); // latestActivity for the chip; fresh contact for the dot
909
1084
  return c.json({ ok: true, session: session.id, note: text });
910
1085
  });
911
- // Approve ends the planning session (DESIGN.md §6 step 6, §12): the daemon
912
- // writes docs/plans/YYYY-MM-DD-<slug>.md (final revision, status: approved,
913
- // grill transcript appended) and queues the `approved` event for the parked
914
- // agent to commit the file. Plain Approve flips the session `approved` —
915
- // terminal, every mutating verb then refuses. **Approve & Implement**
916
- // ({implement:true}) instead flips it to the non-terminal `implementing` and
917
- // sets `implement:true` on the event: the agent commits the plan exactly as
918
- // plain Approve, then proceeds to build it (DESIGN.md §12). Unresolved
919
- // threads refuse 409 unless {force}.
1086
+ // Approve ends the planning session (DESIGN.md §6 step 6/7, §12). Writes the
1087
+ // composed artifact (final revision, status: approved, grill transcript
1088
+ // appended). The canonical copy ALWAYS lands in the home store
1089
+ // (~/.otacon/sessions/<id>/). **Save** (plain Approve, implement=false) ALSO
1090
+ // writes a project copy under the repo's `plans.dir` and sets the event `path`
1091
+ // there; the session flips to `approved` (terminal) and the agent reports where
1092
+ // the plan landed before it stops.
1093
+ // **Implement** ({implement:true}) writes the home copy only, sets `path`=home,
1094
+ // flips to the non-terminal `implementing`, and the agent builds from the home
1095
+ // copy. The event always carries `home` (the absolute canonical path).
1096
+ //
1097
+ // Unresolved threads refuse 409 carrying the count; the UI's warn stage then
1098
+ // offers two ways past it: **{force:true}** finalizes now and drops the open
1099
+ // threads (today's behavior), or **comment & approve** — {sendOpenComments:true}
1100
+ // — defers the finalize, flipping to the non-terminal `finalizing` and handing
1101
+ // the agent every open comment thread (a `final:true` comments batch) for one
1102
+ // solo fold-in pass; its next clean submit finalizes (carrying the implement
1103
+ // choice). Mid-finalize, a fresh {sendOpenComments} is refused E_ALREADY_FINALIZING,
1104
+ // but {force:true} stays open as the manual escape (force-drop the current
1105
+ // revision).
920
1106
  app.post("/api/sessions/:id/approve", async (c) => {
921
1107
  const session = sessionFor(c);
922
1108
  if (!session)
@@ -928,17 +1114,13 @@ export function createApp(options) {
928
1114
  // and refuses instead of writing a second (-2 suffixed) artifact.
929
1115
  if (sessionEnded(session.id))
930
1116
  return sessionOver(c, session.id);
1117
+ const currentStatus = store.getSession(session.id)?.status;
931
1118
  // `implementing` is non-terminal, so it slips past sessionEnded — but a
932
1119
  // build is already under way, and re-approving would re-write the artifact
933
1120
  // and re-queue the wake-up. Refuse it explicitly (the second tap on an
934
1121
  // Approve & Implement, or a stray approve while the agent builds).
935
- if (store.getSession(session.id)?.status === "implementing") {
936
- return c.json({
937
- error: {
938
- code: "E_ALREADY_IMPLEMENTING",
939
- message: `session ${session.id} is already implementing`,
940
- },
941
- }, 409);
1122
+ if (currentStatus === "implementing") {
1123
+ return alreadyImplementing(c, session.id);
942
1124
  }
943
1125
  if (body.force !== undefined && typeof body.force !== "boolean") {
944
1126
  return badRequest(c, "force must be a boolean");
@@ -946,7 +1128,21 @@ export function createApp(options) {
946
1128
  if (body.implement !== undefined && typeof body.implement !== "boolean") {
947
1129
  return badRequest(c, "implement must be a boolean");
948
1130
  }
949
- const implement = body.implement === true;
1131
+ if (body.sendOpenComments !== undefined && typeof body.sendOpenComments !== "boolean") {
1132
+ return badRequest(c, "sendOpenComments must be a boolean");
1133
+ }
1134
+ const force = body.force === true;
1135
+ const sendOpenComments = body.sendOpenComments === true;
1136
+ // A second send-to-agent while the fold-in is in flight is refused; "Commit
1137
+ // anyway" (force) stays open as the manual escape from a hung finalize (D7).
1138
+ if (currentStatus === "finalizing" && !force) {
1139
+ return c.json({
1140
+ error: {
1141
+ code: "E_ALREADY_FINALIZING",
1142
+ message: `session ${session.id} is already finalizing; approve with {"force":true} to commit anyway`,
1143
+ },
1144
+ }, 409);
1145
+ }
950
1146
  const state = store.readState(session.id);
951
1147
  if (state.revision === 0) {
952
1148
  return c.json({
@@ -956,42 +1152,73 @@ export function createApp(options) {
956
1152
  },
957
1153
  }, 409);
958
1154
  }
1155
+ // A force escape mid-finalize honors the variant the user originally chose
1156
+ // (carried on pendingApproval); a fresh approve reads the body's flag.
1157
+ const implement = currentStatus === "finalizing"
1158
+ ? (state.pendingApproval?.implement ?? false)
1159
+ : body.implement === true;
1160
+ const threads = readThreads(store.threadsPath(session.id));
1161
+ const openComments = openCommentThreads(threads);
959
1162
  // Unresolved = comment threads with no resolution + user questions with no
960
- // answer — the same open items the rail shows. The 409 carries the count;
961
- // the UI warns and retries with {force:true} on confirm.
962
- const unresolved = readThreads(store.threadsPath(session.id)).filter((t) => t.kind === "comment" ? t.resolution === undefined : t.answer === undefined).length;
963
- if (unresolved > 0 && body.force !== true) {
1163
+ // answer — the same open items the rail shows.
1164
+ const unresolved = threads.filter((t) => t.kind === "comment" ? t.resolution === undefined : t.answer === undefined).length;
1165
+ // Comment & approve: defer the finalize and hand the agent every open comment
1166
+ // thread for one solo fold-in pass its next clean submit finalizes. Only
1167
+ // when there is something to fold in, and not already finalizing (a force then
1168
+ // falls through to the escape below).
1169
+ if (sendOpenComments && currentStatus !== "finalizing" && openComments.length > 0) {
1170
+ const counters = store.bumpCounters(session.id, { batch: 1, eventSeq: 1 });
1171
+ const batch = `b${counters.batch}`;
1172
+ const items = openComments.map((t) => ({
1173
+ thread: t.id,
1174
+ anchor: t.anchor,
1175
+ body: t.body,
1176
+ }));
1177
+ store.setPendingApproval(session.id, { implement, threads: items.map((i) => i.thread) });
1178
+ const updated = store.updateSession(session.id, { status: "finalizing" });
1179
+ const payload = {
1180
+ event: "comments",
1181
+ session: session.id,
1182
+ batch,
1183
+ items,
1184
+ final: true,
1185
+ };
1186
+ queue.enqueue(payload, counters.eventSeq);
1187
+ publishSession(updated); // after the enqueue, so the summary carries the fresh pending count
1188
+ return c.json({
1189
+ ok: true,
1190
+ session: session.id,
1191
+ finalizing: true,
1192
+ sent: items.map((i) => i.thread),
1193
+ implement,
1194
+ });
1195
+ }
1196
+ // The 409 carries both counts: `unresolved` (the warning's total) and
1197
+ // `openComments` (whether comment & approve has anything to fold in, so the
1198
+ // UI can offer "Send to agent" only when it would do something).
1199
+ if (unresolved > 0 && !force) {
964
1200
  return c.json({
965
1201
  error: {
966
1202
  code: "E_UNRESOLVED_THREADS",
967
- message: `session has ${unresolved} unresolved thread(s); approve with {"force":true} to override`,
1203
+ message: `session has ${unresolved} unresolved thread(s); approve with {"force":true} to override, or {"sendOpenComments":true} to fold open comments in`,
968
1204
  },
969
1205
  unresolved,
1206
+ openComments: openComments.length,
970
1207
  }, 409);
971
1208
  }
972
- const artifact = composeArtifact(store.readRevision(session.id, state.revision), {
1209
+ // Finalize now (plain/force approve, or the force escape mid-finalize): no
1210
+ // review notes — a force drop leaves the open threads unaddressed.
1211
+ const { path, home } = finalizeApproval(session, {
973
1212
  revision: state.revision,
974
- entries: readTranscript(store.transcriptPath(session.id)),
975
- });
976
- const relPath = pickArtifactRelPath(session.repo, session.title, localDate());
977
- // Artifact on disk first, then the status flip (the registry is the commit
978
- // point — same ordering argument as createSession), then the wake-up. The
979
- // artifact + `path` are identical on both branches: the agent commits the
980
- // plan the same way; `implement` only tells it whether to keep building.
981
- writeFileAtomic(join(session.repo, relPath), artifact);
982
- const updated = store.updateSession(session.id, {
983
- status: implement ? "implementing" : "approved",
1213
+ markdown: store.readRevision(session.id, state.revision),
1214
+ implement,
984
1215
  });
985
- const payload = implement
986
- ? { event: "approved", session: session.id, path: relPath, implement: true }
987
- : { event: "approved", session: session.id, path: relPath };
988
- queue.enqueue(payload, store.bumpCounter(session.id, "eventSeq"));
989
- publishSession(updated); // after the enqueue, so the summary carries the fresh pending count
990
1216
  return c.json({
991
1217
  ok: true,
992
1218
  session: session.id,
993
1219
  revision: state.revision,
994
- path: relPath,
1220
+ path,
1221
+ home,
995
1222
  unresolved,
996
1223
  implement,
997
1224
  });