@naxodev/apnea 0.2.0 → 0.2.1

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 (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8283 -15058
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +389 -242
  32. package/extension/services/operation-lock.ts +418 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +274 -57
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -1,6 +1,10 @@
1
1
  import * as path from "node:path"
2
2
  import { Cause, Clock, Effect, Exit, Result } from "effect"
3
+ import { inferKind } from "../domain/artifact-kind.ts"
4
+ import { parseFrontMatter } from "../domain/frontmatter.ts"
5
+ import { looksLikeShellOnly } from "../domain/herdr.ts"
3
6
  import {
7
+ abs,
4
8
  packageRoot,
5
9
  phaseDir,
6
10
  planPath,
@@ -17,16 +21,22 @@ import {
17
21
  toolAllowed,
18
22
  type DispatchKind,
19
23
  } from "../domain/state-machine.ts"
20
- import { timeoutMsForKind } from "../domain/timeouts.ts"
24
+ import { deadlineAfter, timeoutMsForKind } from "../domain/timeouts.ts"
21
25
  import {
26
+ ArtifactInvalid,
22
27
  GateRefused,
23
28
  HerdrError,
24
29
  IllegalKind,
25
30
  type AppError,
26
31
  } from "../errors.ts"
27
32
  import type { Role } from "../domain/types.ts"
28
- import { ROLE_MODE } from "../domain/types.ts"
33
+ import {
34
+ LEGACY_CODE_REWORK,
35
+ LEGACY_PLAN_REWORK,
36
+ ROLE_MODE,
37
+ } from "../domain/types.ts"
29
38
  import { ok, type ToolResult } from "../result.ts"
39
+ import { validateArtifactCompletion } from "../schema/frontmatter.ts"
30
40
  import { Config } from "../services/config.ts"
31
41
  import { FileSystem } from "../services/file-system.ts"
32
42
  import { Herdr } from "../services/herdr.ts"
@@ -36,8 +46,10 @@ import { Vcs } from "../services/vcs.ts"
36
46
  export type DispatchParams = {
37
47
  kind: DispatchKind
38
48
  task_markdown?: string
39
- /** Increment round after CHANGES_REQUIRED (protocol: only then). */
49
+ /** @deprecated Assertion only. Persisted state owns rework through 0.2.x. */
40
50
  rework?: boolean
51
+ /** Reuse persisted pending ownership after a demonstrably dead delivery. */
52
+ redeliver?: boolean
41
53
  }
42
54
 
43
55
  function taskBody(opts: {
@@ -134,14 +146,14 @@ export const dispatchWorkflow = (
134
146
  const herdr = yield* Herdr
135
147
 
136
148
  const state = yield* store.require(root)
137
- const stateBeforeDispatch = structuredClone(state)
149
+ const redeliver = params.redeliver === true
138
150
 
139
151
  const allowed = toolAllowed(state.step, "dispatch_role")
140
152
  if (Result.isFailure(allowed)) {
141
153
  return yield* allowed.failure
142
154
  }
143
155
 
144
- if (state.pending_artifact != null) {
156
+ if (state.pending_artifact != null && !redeliver) {
145
157
  return yield* new GateRefused({
146
158
  gate: "dispatch_pending",
147
159
  message:
@@ -153,6 +165,110 @@ export const dispatchWorkflow = (
153
165
  },
154
166
  })
155
167
  }
168
+ if (state.pending_artifact == null && redeliver) {
169
+ return yield* new GateRefused({
170
+ gate: "redelivery",
171
+ message: "redeliver=true requires an existing pending dispatch",
172
+ })
173
+ }
174
+
175
+ const role = expectedRole(params.kind)
176
+ const pendingArtifact = state.pending_artifact
177
+ if (redeliver && pendingArtifact != null) {
178
+ const inferred = inferKind(pendingArtifact)
179
+ if (Result.isFailure(inferred) || inferred.success !== params.kind) {
180
+ return yield* new GateRefused({
181
+ gate: "redelivery",
182
+ message: `pending artifact does not belong to ${params.kind}`,
183
+ details: {
184
+ pending_artifact: pendingArtifact,
185
+ inferred_kind: Result.isSuccess(inferred)
186
+ ? inferred.success
187
+ : "unknown",
188
+ },
189
+ })
190
+ }
191
+ if (state.pending_role !== role) {
192
+ return yield* new GateRefused({
193
+ gate: "redelivery",
194
+ message: `pending role does not match ${params.kind}`,
195
+ details: { pending_role: state.pending_role, expected_role: role },
196
+ })
197
+ }
198
+ const pendingAbs = abs(pendingArtifact, root)
199
+ if (yield* fs.projectPathExists(root, pendingAbs)) {
200
+ const pendingText = yield* fs.readProjectFile(root, pendingAbs).pipe(
201
+ Effect.mapError(
202
+ (error) =>
203
+ new ArtifactInvalid({
204
+ artifact: pendingArtifact,
205
+ message: error.message,
206
+ }),
207
+ ),
208
+ )
209
+ const frontmatter = parseFrontMatter(pendingText)
210
+ const completion = validateArtifactCompletion(
211
+ inferred.success,
212
+ frontmatter,
213
+ pendingArtifact,
214
+ )
215
+ if (Result.isSuccess(completion) && completion.success !== null) {
216
+ return yield* new GateRefused({
217
+ gate: "redelivery",
218
+ message:
219
+ "pending artifact is already complete; call workflow_wait instead of redelivering",
220
+ details: {
221
+ artifact: pendingArtifact,
222
+ kind: inferred.success,
223
+ verdict: completion.success.frontmatter.verdict ?? null,
224
+ next: "workflow_wait",
225
+ guidance: "call workflow_wait to ingest the completed artifact",
226
+ },
227
+ })
228
+ }
229
+ }
230
+ if (state.pending_delivery === null) {
231
+ return yield* new GateRefused({
232
+ gate: "redelivery",
233
+ message:
234
+ "pending delivery mode is unknown; null-pane legacy ownership is ambiguous",
235
+ details: { pending_delivery: null, liveness: "ambiguous" },
236
+ })
237
+ }
238
+ if (
239
+ state.pending_delivery === "interactive" &&
240
+ state.pending_pane_id === null
241
+ ) {
242
+ return yield* new GateRefused({
243
+ gate: "redelivery",
244
+ message:
245
+ "interactive delivery has no persisted pane id; prior acceptance is ambiguous",
246
+ details: { pending_delivery: "interactive", liveness: "ambiguous" },
247
+ })
248
+ }
249
+ if (
250
+ state.pending_delivery === "manual" &&
251
+ state.pending_pane_id !== null
252
+ ) {
253
+ return yield* new GateRefused({
254
+ gate: "redelivery",
255
+ message: "manual delivery cannot own an interactive pane",
256
+ details: {
257
+ pending_delivery: "manual",
258
+ pane_id: state.pending_pane_id,
259
+ liveness: "ambiguous",
260
+ },
261
+ })
262
+ }
263
+ if (state.required_rework !== null || params.rework === true) {
264
+ return yield* new GateRefused({
265
+ gate: "redelivery",
266
+ message:
267
+ "redelivery reuses consumed ownership and cannot start or assert rework",
268
+ details: { required_rework: state.required_rework },
269
+ })
270
+ }
271
+ }
156
272
 
157
273
  const kinds = allowedKinds(state.step)
158
274
  if (!kinds.includes(params.kind)) {
@@ -163,40 +279,109 @@ export const dispatchWorkflow = (
163
279
  })
164
280
  }
165
281
 
166
- // Rework flag validation
167
- if (params.rework) {
168
- const okRework =
169
- (params.kind === "plan" && state.step === "planning") ||
170
- (params.kind === "code" && state.step === "coding") ||
171
- (params.kind === "phase_package" &&
172
- state.step === "phase_packaging" &&
173
- state.phase_package_rework) ||
174
- (params.kind === "plan_review" && state.step === "plan_review") ||
175
- (params.kind === "code_review" && state.step === "code_review")
176
- // After CHANGES_REQUIRED, step moves back to planning/coding; rework
177
- // dispatch is plan/code with rework=true.
178
- if (!okRework && !(params.kind === "plan" || params.kind === "code")) {
179
- return yield* new GateRefused({
180
- gate: "rework",
181
- message:
182
- "rework=true only valid for plan/code after CHANGES_REQUIRED, phase_package after package rework, or same-gate re-review",
183
- })
184
- }
282
+ const legacyPlanAssertion =
283
+ state[LEGACY_PLAN_REWORK] === true &&
284
+ state.step === "planning" &&
285
+ params.kind === "plan" &&
286
+ params.rework === true
287
+ const legacyCodeAssertion =
288
+ state[LEGACY_CODE_REWORK] === true &&
289
+ state.step === "coding" &&
290
+ params.kind === "code" &&
291
+ params.rework === true
292
+ if (legacyPlanAssertion) state.required_rework = "plan"
293
+ if (legacyCodeAssertion) state.required_rework = "code"
294
+
295
+ const requiredRework = state.required_rework
296
+ if (params.rework === true && requiredRework === null) {
297
+ return yield* new GateRefused({
298
+ gate: "rework",
299
+ message:
300
+ "rework=true is only a deprecated assertion; state has no required rework",
301
+ })
185
302
  }
303
+ if (requiredRework !== null && params.kind !== requiredRework) {
304
+ return yield* new GateRefused({
305
+ gate: "rework",
306
+ message: `state requires ${requiredRework} rework before dispatching ${params.kind}`,
307
+ details: { required_rework: requiredRework },
308
+ })
309
+ }
310
+ const isRework = !redeliver && requiredRework === params.kind
311
+ const stateBeforeDispatch = structuredClone(state)
186
312
 
187
- const role = expectedRole(params.kind)
188
313
  const cfg = yield* config.load(root)
189
314
  const roleTimeoutMs = timeoutMsForKind(params.kind, cfg.timeouts_ms)
190
315
  // Validate the orchestrator's current pane before creating task artifacts.
191
316
  // A stale inherited environment is equivalent to running outside Herdr;
192
317
  // other CLI failures remain typed errors and stop dispatch.
193
- const herdrAvailability = yield* herdr.availability
318
+ const availability = yield* Effect.result(herdr.availability)
319
+ if (Result.isFailure(availability)) {
320
+ if (redeliver && state.pending_pane_id != null) {
321
+ return yield* new GateRefused({
322
+ gate: "redelivery",
323
+ message: "cannot verify whether the prior pane is still live",
324
+ details: { pane_id: state.pending_pane_id, liveness: "ambiguous" },
325
+ })
326
+ }
327
+ return yield* availability.failure
328
+ }
329
+ const herdrAvailability = availability.success
330
+
331
+ if (redeliver && state.pending_pane_id != null) {
332
+ if (herdrAvailability !== "available") {
333
+ return yield* new GateRefused({
334
+ gate: "redelivery",
335
+ message:
336
+ "cannot prove the prior pane is dead while Herdr is unavailable",
337
+ details: { pane_id: state.pending_pane_id, liveness: "ambiguous" },
338
+ })
339
+ }
340
+ const pane = yield* Effect.exit(herdr.paneGet(state.pending_pane_id))
341
+ if (Exit.isFailure(pane)) {
342
+ return yield* new GateRefused({
343
+ gate: "redelivery",
344
+ message: "cannot verify whether the prior pane is still live",
345
+ details: { pane_id: state.pending_pane_id, liveness: "ambiguous" },
346
+ })
347
+ }
348
+ if (!pane.value.ok && pane.value.missing !== true) {
349
+ return yield* new GateRefused({
350
+ gate: "redelivery",
351
+ message: "prior pane lookup failed without proof that it is missing",
352
+ details: { pane_id: state.pending_pane_id, liveness: "ambiguous" },
353
+ })
354
+ }
355
+ if (pane.value.ok && pane.value.agent_status !== "done") {
356
+ const foreground = yield* Effect.exit(
357
+ herdr.paneForegroundNames(state.pending_pane_id),
358
+ )
359
+ const shellOnly =
360
+ Exit.isSuccess(foreground) && looksLikeShellOnly(foreground.value)
361
+ if (!shellOnly) {
362
+ const status = pane.value.agent_status ?? "unknown"
363
+ return yield* new GateRefused({
364
+ gate: "redelivery",
365
+ message: `prior pane is ${status}; refusing duplicate delivery`,
366
+ details: {
367
+ pane_id: state.pending_pane_id,
368
+ liveness:
369
+ status === "working" ||
370
+ status === "blocked" ||
371
+ status === "idle"
372
+ ? "live"
373
+ : "ambiguous",
374
+ },
375
+ })
376
+ }
377
+ }
378
+ }
194
379
 
195
380
  // --- Round numbers (increment ONLY on rework after CHANGES_REQUIRED) ---
196
381
  let round = 1
197
382
  if (params.kind === "plan" || params.kind === "plan_review") {
198
383
  const key = roundKey(0, "plan_review")
199
- if (params.rework && params.kind === "plan") {
384
+ if (isRework && params.kind === "plan") {
200
385
  // starting a new plan revision after CHANGES_REQUIRED → next review round
201
386
  setRound(state, key, getRound(state, key) + 1)
202
387
  } else if (!state.rounds[key]) {
@@ -209,12 +394,9 @@ export const dispatchWorkflow = (
209
394
  params.kind === "phase_package"
210
395
  ) {
211
396
  const key = codeReviewRoundKey(state.phase_index)
212
- if (params.rework && params.kind === "code") {
397
+ if (isRework && params.kind === "code") {
213
398
  setRound(state, key, getRound(state, key) + 1)
214
- } else if (
215
- params.kind === "phase_package" &&
216
- state.phase_package_rework
217
- ) {
399
+ } else if (isRework && params.kind === "phase_package") {
218
400
  setRound(state, key, getRound(state, key) + 1)
219
401
  } else if (!state.rounds[key]) {
220
402
  setRound(state, key, 1)
@@ -228,6 +410,7 @@ export const dispatchWorkflow = (
228
410
  ? roundKey(0, "plan_review")
229
411
  : codeReviewRoundKey(state.phase_index)
230
412
  if (
413
+ !redeliver &&
231
414
  (params.kind === "plan" ||
232
415
  params.kind === "code" ||
233
416
  params.kind === "phase_package" ||
@@ -264,7 +447,7 @@ export const dispatchWorkflow = (
264
447
  artifactAbs = path.join(d, "phase-package.md")
265
448
  extra =
266
449
  extra ||
267
- (state.phase_package_rework
450
+ (isRework || (redeliver && state.current_code_review !== null)
268
451
  ? `Revise the phase ${state.phase_index} package after code review \`${state.current_code_review}\`. Preserve approved-plan scope and address package findings.`
269
452
  : `Emit phase package for phase ${state.phase_index} only from approved plan \`${rel(planPath(root), root)}\`.`)
270
453
  break
@@ -304,6 +487,20 @@ export const dispatchWorkflow = (
304
487
  break
305
488
  }
306
489
 
490
+ const artifactRel = rel(artifactAbs, root)
491
+ if (redeliver && artifactRel !== pendingArtifact) {
492
+ return yield* new GateRefused({
493
+ gate: "redelivery",
494
+ message: "pending artifact is not the current phase and round target",
495
+ details: {
496
+ pending_artifact: pendingArtifact,
497
+ expected_artifact: artifactRel,
498
+ phase_index: state.phase_index,
499
+ round,
500
+ },
501
+ })
502
+ }
503
+
307
504
  // Resolve the brief BEFORE clear-before-dispatch. This block can refuse,
308
505
  // and the rename below is a mutation: refusing after it left the prior
309
506
  // artifact stranded at a timestamped .bak path, so a failed dispatch
@@ -359,15 +556,14 @@ export const dispatchWorkflow = (
359
556
  }
360
557
 
361
558
  // clear-before-dispatch
362
- yield* fs.mkdir(path.dirname(artifactAbs), { recursive: true })
559
+ yield* fs.mkdirProject(root, path.dirname(artifactAbs))
363
560
  let backupAbs: string | null = null
364
- if (yield* fs.exists(artifactAbs)) {
561
+ if (yield* fs.projectPathExists(root, artifactAbs)) {
365
562
  const backupMillis = yield* Clock.currentTimeMillis
366
563
  backupAbs = `${artifactAbs}.bak.${backupMillis}`
367
- yield* fs.rename(artifactAbs, backupAbs)
564
+ yield* fs.renameProjectFile(root, artifactAbs, backupAbs)
368
565
  }
369
566
 
370
- const artifactRel = rel(artifactAbs, root)
371
567
  const body = taskBody({
372
568
  kind: params.kind,
373
569
  role,
@@ -382,14 +578,14 @@ export const dispatchWorkflow = (
382
578
  tasksDir(root),
383
579
  `${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
384
580
  )
385
- while (yield* fs.exists(taskFile)) {
581
+ while (yield* fs.projectPathExists(root, taskFile)) {
386
582
  taskFileMillis += 1
387
583
  taskFile = path.join(
388
584
  tasksDir(root),
389
585
  `${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
390
586
  )
391
587
  }
392
- yield* fs.writeFile(taskFile, body)
588
+ yield* fs.writeProjectFile(root, taskFile, body)
393
589
  const taskRef = {
394
590
  task: rel(taskFile, root),
395
591
  artifact: artifactRel,
@@ -410,19 +606,25 @@ export const dispatchWorkflow = (
410
606
  const rollbackLaunch = (restoreState = true) =>
411
607
  Effect.gen(function* () {
412
608
  const errors: string[] = []
413
- const attempt = (label: string, operation: Effect.Effect<void>) =>
609
+ const attempt = (
610
+ label: string,
611
+ operation: Effect.Effect<void, unknown>,
612
+ ) =>
414
613
  Effect.gen(function* () {
415
614
  const exit = yield* Effect.exit(operation)
416
615
  if (Exit.isFailure(exit)) {
417
616
  errors.push(`${label}: ${Cause.pretty(exit.cause)}`)
418
617
  }
419
618
  })
420
- yield* attempt("remove task", fs.remove(taskFile))
421
- yield* attempt("remove replacement artifact", fs.remove(artifactAbs))
619
+ yield* attempt("remove task", fs.removeProjectFile(root, taskFile))
620
+ yield* attempt(
621
+ "remove replacement artifact",
622
+ fs.removeProjectFile(root, artifactAbs),
623
+ )
422
624
  if (backupAbs != null) {
423
625
  yield* attempt(
424
626
  "restore prior artifact",
425
- fs.rename(backupAbs, artifactAbs),
627
+ fs.renameProjectFile(root, backupAbs, artifactAbs),
426
628
  )
427
629
  }
428
630
  if (restoreState) {
@@ -434,19 +636,26 @@ export const dispatchWorkflow = (
434
636
  return errors
435
637
  })
436
638
 
437
- const markPending = (launchedAt: number): void => {
639
+ const markPending = (
640
+ launchedAt: number,
641
+ delivery: "manual" | "interactive",
642
+ ): void => {
438
643
  state.pending_artifact = artifactRel
439
644
  state.pending_role = role
645
+ state.pending_delivery = delivery
440
646
  state.pending_started_at = launchedAt
441
- state.pending_deadline_ms = launchedAt + roleTimeoutMs
442
- if (params.kind === "phase_package") state.phase_package_rework = false
647
+ state.pending_deadline_ms = deadlineAfter(launchedAt, roleTimeoutMs)
648
+ if (isRework) state.required_rework = null
443
649
  resetRecoveryLadder(state)
444
650
  }
445
651
 
446
652
  // Persist ownership before crossing an external launch boundary. If this
447
653
  // process dies after Herdr accepts work, a retry must not start a duplicate.
448
654
  const preparedAt = yield* Clock.currentTimeMillis
449
- markPending(preparedAt)
655
+ markPending(
656
+ preparedAt,
657
+ herdrAvailability === "unavailable" ? "manual" : "interactive",
658
+ )
450
659
  state.pending_pane_id = null
451
660
  state.pending_pane_label = null
452
661
  const preparedState = yield* Effect.exit(store.save(state, root))
@@ -497,14 +706,22 @@ export const dispatchWorkflow = (
497
706
  )
498
707
  if (Result.isFailure(launched)) {
499
708
  if (launched.failure.details?.delivery === "unknown") {
500
- const paneId = String(launched.failure.details.pane_id)
501
- const paneLabel = String(launched.failure.details.pane_label)
502
- state.pending_pane_id = paneId
503
- state.pending_pane_label = paneLabel
504
- state.role_panes[role] = {
505
- pane_id: paneId,
506
- label: paneLabel,
507
- profile_fingerprint: profileFingerprint,
709
+ const paneId = launched.failure.details.pane_id
710
+ const paneLabel = launched.failure.details.pane_label
711
+ // Only a pane-backed save actually preserves pending ownership; a
712
+ // split failure never reached the state machine, so claiming
713
+ // `pending_preserved` here would send operators to redeliver against
714
+ // ownership that was never written.
715
+ const preserved =
716
+ typeof paneId === "string" && typeof paneLabel === "string"
717
+ if (preserved) {
718
+ state.pending_pane_id = paneId
719
+ state.pending_pane_label = paneLabel
720
+ state.role_panes[role] = {
721
+ pane_id: paneId,
722
+ label: paneLabel,
723
+ profile_fingerprint: profileFingerprint,
724
+ }
508
725
  }
509
726
  yield* store.save(state, root)
510
727
  return yield* new HerdrError({
@@ -516,7 +733,7 @@ export const dispatchWorkflow = (
516
733
  ...(launched.failure.details ?? {}),
517
734
  task_attempted: taskRef.task,
518
735
  artifact: artifactRel,
519
- pending_preserved: true,
736
+ pending_preserved: preserved,
520
737
  },
521
738
  })
522
739
  }
@@ -555,7 +772,7 @@ export const dispatchWorkflow = (
555
772
  // the top of the workflow charged that startup against the role's own
556
773
  // deadline and burned wait's 12s liveness grace before the first poll.
557
774
  const launchedAt = yield* Clock.currentTimeMillis
558
- markPending(launchedAt)
775
+ markPending(launchedAt, "interactive")
559
776
  yield* store.save(state, root)
560
777
 
561
778
  return ok(
@@ -1,4 +1,5 @@
1
1
  import * as path from "node:path"
2
+ import * as os from "node:os"
2
3
  import { Effect, Result } from "effect"
3
4
  import { globalConfigPath, projectConfigPath } from "../domain/paths.ts"
4
5
  import {
@@ -9,7 +10,7 @@ import {
9
10
  } from "../domain/setup.ts"
10
11
  import { ConfigError, type AppError } from "../errors.ts"
11
12
  import { ok, type ToolResult } from "../result.ts"
12
- import { decodeGlobalConfig } from "../schema/config.ts"
13
+ import { decodeGlobalConfig, decodeProjectConfig } from "../schema/config.ts"
13
14
  import { FileSystem } from "../services/file-system.ts"
14
15
 
15
16
  export type SetupParams = {
@@ -70,6 +71,8 @@ export type SetupDeps = {
70
71
  onPath: (bin: string) => boolean
71
72
  /** Prepare host-specific role resources, or return null when none are needed. */
72
73
  materializeRoleAgentDir: () => string | null
74
+ /** Trusted account home. Production uses node:os.homedir(). */
75
+ trustedHome?: () => string
73
76
  }
74
77
 
75
78
  /**
@@ -84,24 +87,6 @@ export const setupWorkflow = (
84
87
  Effect.gen(function* () {
85
88
  const fs = yield* FileSystem
86
89
 
87
- const readJsonSafe = (
88
- filePath: string,
89
- ): Effect.Effect<Record<string, unknown>> =>
90
- Effect.gen(function* () {
91
- const present = yield* fs.exists(filePath)
92
- if (!present) return {}
93
- const text = yield* fs.readFile(filePath)
94
- try {
95
- const v = JSON.parse(text)
96
- if (v && typeof v === "object" && !Array.isArray(v)) {
97
- return v as Record<string, unknown>
98
- }
99
- return {}
100
- } catch {
101
- return {}
102
- }
103
- })
104
-
105
90
  const has: Detected = {
106
91
  pi: deps.onPath("pi"),
107
92
  claude: deps.onPath("claude"),
@@ -118,27 +103,64 @@ export const setupWorkflow = (
118
103
  })
119
104
  }
120
105
 
121
- const gPath = globalConfigPath()
122
- yield* fs.mkdir(path.dirname(gPath), { recursive: true })
106
+ const trustedHome = deps.trustedHome?.() ?? os.homedir()
107
+ const gPath = globalConfigPath(trustedHome)
123
108
 
124
- const prev = yield* readJsonSafe(gPath)
125
109
  const force = params.force === true
110
+ let replacedMalformedGlobal = false
111
+ let prev: Record<string, unknown> = {}
112
+ if (yield* fs.exists(gPath)) {
113
+ const text = yield* fs.readTrustedGlobalFile(trustedHome, gPath)
114
+ try {
115
+ const parsed: unknown = JSON.parse(text)
116
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
117
+ throw new Error("global config must be a JSON object")
118
+ }
119
+ prev = parsed as Record<string, unknown>
120
+ } catch (error) {
121
+ if (!force) {
122
+ return yield* new ConfigError({
123
+ message: `existing global config is malformed; refusing to replace it without --force: ${error instanceof Error ? error.message : String(error)}`,
124
+ path: gPath,
125
+ })
126
+ }
127
+ replacedMalformedGlobal = true
128
+ }
129
+ }
126
130
  const globalConfig = buildGlobalConfig({ has, prev, force })
127
131
 
128
132
  const serialized = `${JSON.stringify(globalConfig, null, 2)}\n`
129
- yield* fs.writeFile(gPath, serialized)
130
133
 
131
134
  let projectPath: string | null = null
132
135
  if (params.project) {
133
136
  const pPath = projectConfigPath(root)
137
+ if (yield* fs.projectPathExists(root, pPath)) {
138
+ const text = yield* fs.readProjectFile(root, pPath)
139
+ let parsed: unknown
140
+ try {
141
+ parsed = JSON.parse(text)
142
+ } catch (error) {
143
+ return yield* new ConfigError({
144
+ message: `invalid JSON at ${pPath}: ${error instanceof Error ? error.message : String(error)}`,
145
+ path: pPath,
146
+ })
147
+ }
148
+ const decoded = decodeProjectConfig(parsed)
149
+ if (Result.isFailure(decoded)) return yield* decoded.failure
150
+ }
151
+ projectPath = pPath
152
+ }
153
+
154
+ yield* fs.writeTrustedGlobalFile(trustedHome, gPath, serialized)
155
+
156
+ if (projectPath) {
134
157
  // project: roles only — never cmd
135
158
  const projectCfg = { roles: pickRoles(has) }
136
159
  yield* fs.writeProjectFile(
137
160
  root,
138
- pPath,
161
+ projectPath,
139
162
  `${JSON.stringify(projectCfg, null, 2)}\n`,
140
163
  )
141
- projectPath = pPath
142
164
  }
143
165
 
144
166
  const missing = detectionNotes(has)
@@ -162,15 +184,14 @@ export const setupWorkflow = (
162
184
  let agentsMdPath: string | null = null
163
185
  if (params.agents_md) {
164
186
  const target = path.join(root, "AGENTS.md")
165
- const present = yield* fs.exists(target)
166
- const existing = present ? yield* fs.readFile(target) : null
167
- yield* fs.writeFile(target, mergeAgentsMd(existing))
187
+ const present = yield* fs.projectPathExists(root, target)
188
+ const existing = present ? yield* fs.readProjectFile(root, target) : null
189
+ yield* fs.writeProjectFile(root, target, mergeAgentsMd(existing))
168
190
  agentsMdPath = target
169
191
  }
170
192
 
171
- // A failed decode after writing is a note, not a failure — a user with a
172
- // malformed pre-existing config must still get a written config and an
173
- // actionable note, never a hard refusal where today there is none.
193
+ // Generated defaults can still inherit malformed profile or role shapes
194
+ // from valid JSON. Report those separately from JSON parse refusals.
174
195
  const decoded = decodeGlobalConfig(globalConfig)
175
196
  if (Result.isFailure(decoded)) {
176
197
  missing.push(
@@ -190,5 +211,11 @@ export const setupWorkflow = (
190
211
  if (params.agents_md) {
191
212
  data.agents_md = agentsMdPath
192
213
  }
193
- return ok(`wrote global config ${gPath}`, data)
214
+ if (replacedMalformedGlobal) data.replaced_malformed_global = true
215
+ return ok(
216
+ replacedMalformedGlobal
217
+ ? `replaced malformed global config ${gPath}`
218
+ : `wrote global config ${gPath}`,
219
+ data,
220
+ )
194
221
  })