otacon 0.1.1 → 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.
- package/README.md +101 -64
- package/dist/cli/browser.js +55 -0
- package/dist/cli/browser.js.map +1 -0
- package/dist/cli/commands/clean.js +3 -2
- package/dist/cli/commands/clean.js.map +1 -1
- package/dist/cli/commands/config.js +62 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/doctor.js +41 -21
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/install.js +53 -30
- package/dist/cli/commands/install.js.map +1 -1
- package/dist/cli/commands/open.js +14 -14
- package/dist/cli/commands/open.js.map +1 -1
- package/dist/cli/commands/start.js +1 -22
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/install/assets.js +35 -43
- package/dist/cli/install/assets.js.map +1 -1
- package/dist/cli/install/locations.js +15 -25
- package/dist/cli/install/locations.js.map +1 -1
- package/dist/cli/main.js +6 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/daemon/app.js +281 -54
- package/dist/daemon/app.js.map +1 -1
- package/dist/daemon/approve.js +49 -11
- package/dist/daemon/approve.js.map +1 -1
- package/dist/daemon/store.js +39 -2
- package/dist/daemon/store.js.map +1 -1
- package/dist/daemon/threads.js +11 -0
- package/dist/daemon/threads.js.map +1 -1
- package/dist/daemon/ui.js +3 -0
- package/dist/daemon/ui.js.map +1 -1
- package/dist/shared/config.js +290 -45
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/paths.js +33 -5
- package/dist/shared/paths.js.map +1 -1
- package/dist/shared/types.js +6 -2
- package/dist/shared/types.js.map +1 -1
- package/dist/shared/version.js +1 -1
- package/dist/ui/assets/{arc-HhPfdCPZ.js → arc-KT3ZnaMp.js} +1 -1
- package/dist/ui/assets/architecture-7EHR7CIX-Cw3I1lil.js +1 -0
- package/dist/ui/assets/{architectureDiagram-3BPJPVTR-D2PIxGOb.js → architectureDiagram-3BPJPVTR-DLu0UM7N.js} +1 -1
- package/dist/ui/assets/{blockDiagram-GPEHLZMM-DQ3Dn17h.js → blockDiagram-GPEHLZMM-B8wApEWC.js} +1 -1
- package/dist/ui/assets/{c4Diagram-AAUBKEIU-DxITrQgS.js → c4Diagram-AAUBKEIU-BNS5gmQS.js} +1 -1
- package/dist/ui/assets/channel-CkOta24Z.js +1 -0
- package/dist/ui/assets/{chunk-2J33WTMH-Du1JoPx5.js → chunk-2J33WTMH-CTY2etwY.js} +1 -1
- package/dist/ui/assets/{chunk-3OPIFGDE-Dn7x2Yqf.js → chunk-3OPIFGDE-DZM4Sz84.js} +1 -1
- package/dist/ui/assets/{chunk-4BX2VUAB-DVnrE-4n.js → chunk-4BX2VUAB-sGwrrXIO.js} +1 -1
- package/dist/ui/assets/{chunk-55IACEB6-BAhFAimA.js → chunk-55IACEB6-CGlNp76o.js} +1 -1
- package/dist/ui/assets/{chunk-5ZQYHXKU-0hEZptem.js → chunk-5ZQYHXKU-5zebJ3jW.js} +1 -1
- package/dist/ui/assets/{chunk-727SXJPM-C1FN_cI3.js → chunk-727SXJPM-DelmUpvy.js} +1 -1
- package/dist/ui/assets/{chunk-AQP2D5EJ-A656OBd4.js → chunk-AQP2D5EJ-DMVzBf3M.js} +1 -1
- package/dist/ui/assets/{chunk-BSJP7CBP-D8oMbjm8.js → chunk-BSJP7CBP-CZHrcpSu.js} +1 -1
- package/dist/ui/assets/{chunk-CSCIHK7Q-DjIL8GLi.js → chunk-CSCIHK7Q-C1efTp0O.js} +1 -1
- package/dist/ui/assets/{chunk-FMBD7UC4-Otblfqvz.js → chunk-FMBD7UC4-B6axGwgn.js} +1 -1
- package/dist/ui/assets/{chunk-KSCS5N6A-BOjTvm3H.js → chunk-KSCS5N6A-DSaxbrm0.js} +1 -1
- package/dist/ui/assets/{chunk-L5ZTLDWV-CaTLaw6L.js → chunk-L5ZTLDWV-D9ZKdVGx.js} +1 -1
- package/dist/ui/assets/{chunk-LZXEDZCA-Dq5p7qrD.js → chunk-LZXEDZCA-DI5_1s00.js} +2 -2
- package/dist/ui/assets/{chunk-ND2GUHAM-jZ_NNnWi.js → chunk-ND2GUHAM-DQQ4RVLE.js} +1 -1
- package/dist/ui/assets/{chunk-NZK2D7GU-U_7l_sCh.js → chunk-NZK2D7GU-9zJJaDpb.js} +1 -1
- package/dist/ui/assets/{chunk-O5CBEL6O-MewqqNB7.js → chunk-O5CBEL6O-DUOshAt2.js} +1 -1
- package/dist/ui/assets/chunk-QZHKN3VN-DqRxzBM_.js +1 -0
- package/dist/ui/assets/chunk-WU5MYG2G-B1Mk3aBp.js +1 -0
- package/dist/ui/assets/{chunk-XPW4576I-D5ArxNEF.js → chunk-XPW4576I-DQVL_GAl.js} +1 -1
- package/dist/ui/assets/classDiagram-4FO5ZUOK-BH-5P4jH.js +1 -0
- package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-BH-5P4jH.js +1 -0
- package/dist/ui/assets/{cose-bilkent-S5V4N54A-PFXzf7WV.js → cose-bilkent-S5V4N54A-C7m3VlSW.js} +1 -1
- package/dist/ui/assets/{dagre-BM42HDAG-xrCfjZuZ.js → dagre-BM42HDAG-CoU_T6EY.js} +1 -1
- package/dist/ui/assets/{diagram-2AECGRRQ-BFf-cyKY.js → diagram-2AECGRRQ-C_R7oNKE.js} +1 -1
- package/dist/ui/assets/{diagram-5GNKFQAL-kNPV4NfV.js → diagram-5GNKFQAL-UhE2W3Yi.js} +1 -1
- package/dist/ui/assets/{diagram-KO2AKTUF-ByC1IUwG.js → diagram-KO2AKTUF-D4KZCRld.js} +1 -1
- package/dist/ui/assets/{diagram-LMA3HP47-DZIJMPK0.js → diagram-LMA3HP47-CsOe8-S6.js} +1 -1
- package/dist/ui/assets/{diagram-OG6HWLK6-CSDED9A-.js → diagram-OG6HWLK6-Bkbs2V54.js} +1 -1
- package/dist/ui/assets/{dist-YwjsDswi.js → dist-BcowSnUl.js} +1 -1
- package/dist/ui/assets/{erDiagram-TEJ5UH35-yuzvjE6J.js → erDiagram-TEJ5UH35-DJXsykZz.js} +1 -1
- package/dist/ui/assets/eventmodeling-FCH6USID-DSDI1f5T.js +1 -0
- package/dist/ui/assets/{flowDiagram-I6XJVG4X-ApPtVyYM.js → flowDiagram-I6XJVG4X-Chguj9Xz.js} +1 -1
- package/dist/ui/assets/{ganttDiagram-6RSMTGT7-BeMLXtAr.js → ganttDiagram-6RSMTGT7-DXdpFesp.js} +1 -1
- package/dist/ui/assets/{gitGraph-WXDBUCRP-JmTTBa7j.js → gitGraph-WXDBUCRP-Doq1CcxB.js} +1 -1
- package/dist/ui/assets/{gitGraphDiagram-PVQCEYII-Cjjnjs71.js → gitGraphDiagram-PVQCEYII-uxGwHEPv.js} +1 -1
- package/dist/ui/assets/index-BsTJ9Ul_.css +1 -0
- package/dist/ui/assets/index-D-TSanrw.js +11 -0
- package/dist/ui/assets/{info-J43DQDTF-8vZ3gome.js → info-J43DQDTF-CgWT_d3M.js} +1 -1
- package/dist/ui/assets/{infoDiagram-5YYISTIA-CnMk1cA-.js → infoDiagram-5YYISTIA-B3oDA2Ct.js} +1 -1
- package/dist/ui/assets/{ishikawaDiagram-YF4QCWOH-Bl8z6huD.js → ishikawaDiagram-YF4QCWOH-C6l0lvC3.js} +1 -1
- package/dist/ui/assets/{journeyDiagram-JHISSGLW-DYIVfMpS.js → journeyDiagram-JHISSGLW-COf53NwC.js} +1 -1
- package/dist/ui/assets/{kanban-definition-UN3LZRKU-BnR0ZzOz.js → kanban-definition-UN3LZRKU-DaOPRBld.js} +1 -1
- package/dist/ui/assets/{line-DcBdQit6.js → line-new3jLu3.js} +1 -1
- package/dist/ui/assets/{linear-HKjRHFAO.js → linear-yA22knFB.js} +1 -1
- package/dist/ui/assets/{mermaid-parser.core-DkYXrPlA.js → mermaid-parser.core-D8JVCOKU.js} +2 -2
- package/dist/ui/assets/{mermaid.core-BmkfCI3b.js → mermaid.core-BCrPyVBK.js} +3 -3
- package/dist/ui/assets/{mindmap-definition-RKZ34NQL-sIAd4nDi.js → mindmap-definition-RKZ34NQL-CVcJTWsW.js} +1 -1
- package/dist/ui/assets/{packet-YPE3B663-BxbxcfXN.js → packet-YPE3B663-DlSwpK-G.js} +1 -1
- package/dist/ui/assets/{pie-LRSECV5Y-BJxazjNs.js → pie-LRSECV5Y-CyMpbo4C.js} +1 -1
- package/dist/ui/assets/{pieDiagram-4H26LBE5-BiOhc9GR.js → pieDiagram-4H26LBE5-Dy7day5R.js} +1 -1
- package/dist/ui/assets/{plan-view-CH6NzUDb.js → plan-view-CE2ha0qY.js} +3 -3
- package/dist/ui/assets/{quadrantDiagram-W4KKPZXB-CVyHbWgo.js → quadrantDiagram-W4KKPZXB-CVjVgbaQ.js} +1 -1
- package/dist/ui/assets/{radar-GUYGQ44K-D9ohbnbV.js → radar-GUYGQ44K-DLPfv0jT.js} +1 -1
- package/dist/ui/assets/{requirementDiagram-4Y6WPE33-Ba24_hqc.js → requirementDiagram-4Y6WPE33-y_0KOdN8.js} +1 -1
- package/dist/ui/assets/{sankeyDiagram-5OEKKPKP-CxD4wiPL.js → sankeyDiagram-5OEKKPKP-8T3b7meL.js} +1 -1
- package/dist/ui/assets/{sequenceDiagram-3UESZ5HK-7qA7lD61.js → sequenceDiagram-3UESZ5HK-Cn8DZiEr.js} +1 -1
- package/dist/ui/assets/{src-IM8AE8MK.js → src-BieOuieI.js} +1 -1
- package/dist/ui/assets/{stateDiagram-AJRCARHV-DNElRCuH.js → stateDiagram-AJRCARHV-nPEVGd3E.js} +1 -1
- package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-CKrff-KJ.js +1 -0
- package/dist/ui/assets/{timeline-definition-PNZ67QCA-ChYC4Grd.js → timeline-definition-PNZ67QCA-Dj_qK0VJ.js} +1 -1
- package/dist/ui/assets/{treeView-BLDUP644-Il0KnMi_.js → treeView-BLDUP644-G5Bsebqu.js} +1 -1
- package/dist/ui/assets/{treemap-LRROVOQU-CIiKcdRo.js → treemap-LRROVOQU-anUEJzTb.js} +1 -1
- package/dist/ui/assets/{vennDiagram-CIIHVFJN-Ulhkum9i.js → vennDiagram-CIIHVFJN-D71ne3dS.js} +1 -1
- package/dist/ui/assets/{wardley-L42UT6IY-BNd4ljz7.js → wardley-L42UT6IY-DrJilmE_.js} +1 -1
- package/dist/ui/assets/{wardleyDiagram-YWT4CUSO-BicXxh84.js → wardleyDiagram-YWT4CUSO-B66G4ayp.js} +1 -1
- package/dist/ui/assets/{xychartDiagram-2RQKCTM6-Duf-m_th.js → xychartDiagram-2RQKCTM6-CReSRxNG.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/architecture-7EHR7CIX-BPLblcyi.js +0 -1
- package/dist/ui/assets/channel-ipcU8ZNI.js +0 -1
- package/dist/ui/assets/chunk-QZHKN3VN-DzGPH44B.js +0 -1
- package/dist/ui/assets/chunk-WU5MYG2G-DyEIVjoo.js +0 -1
- package/dist/ui/assets/classDiagram-4FO5ZUOK-Byg2Hl9D.js +0 -1
- package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-Byg2Hl9D.js +0 -1
- package/dist/ui/assets/eventmodeling-FCH6USID-CZR4eNG-.js +0 -1
- package/dist/ui/assets/index-BFQVRcSI.js +0 -11
- package/dist/ui/assets/index-Bj_kTrwP.css +0 -1
- package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-D6qTYpY3.js +0 -1
package/dist/daemon/app.js
CHANGED
|
@@ -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,
|
|
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
|
|
295
|
-
//
|
|
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
|
|
298
|
-
// .otacon/archive/ (recoverable) — `otacon clean`
|
|
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
|
-
|
|
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)
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
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 (
|
|
936
|
-
return c.
|
|
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
|
-
|
|
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.
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1220
|
+
path,
|
|
1221
|
+
home,
|
|
995
1222
|
unresolved,
|
|
996
1223
|
implement,
|
|
997
1224
|
});
|