@gajae-code/coding-agent 0.5.1 → 0.5.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 (98) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/dist/types/cli/setup-cli.d.ts +8 -1
  4. package/dist/types/commands/setup.d.ts +7 -0
  5. package/dist/types/config/file-lock.d.ts +24 -2
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/config/settings-schema.d.ts +62 -0
  9. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  10. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  12. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  13. package/dist/types/modes/interactive-mode.d.ts +1 -1
  14. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  15. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  16. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  17. package/dist/types/modes/theme/theme.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/session/history-storage.d.ts +2 -2
  20. package/dist/types/session/session-manager.d.ts +10 -1
  21. package/dist/types/setup/credential-import.d.ts +79 -0
  22. package/dist/types/task/executor.d.ts +1 -0
  23. package/dist/types/task/render.d.ts +1 -1
  24. package/dist/types/tools/subagent-render.d.ts +7 -1
  25. package/dist/types/tools/subagent.d.ts +21 -0
  26. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  27. package/dist/types/web/search/index.d.ts +4 -4
  28. package/dist/types/web/search/provider.d.ts +16 -20
  29. package/dist/types/web/search/providers/base.d.ts +2 -1
  30. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  31. package/dist/types/web/search/types.d.ts +14 -2
  32. package/package.json +7 -7
  33. package/scripts/build-binary.ts +7 -0
  34. package/src/cli/args.ts +2 -0
  35. package/src/cli/fast-help.ts +2 -0
  36. package/src/cli/setup-cli.ts +138 -3
  37. package/src/commands/setup.ts +5 -1
  38. package/src/commands/ultragoal.ts +3 -1
  39. package/src/config/file-lock-gc.ts +14 -2
  40. package/src/config/file-lock.ts +54 -12
  41. package/src/config/model-profile-activation.ts +15 -3
  42. package/src/config/model-profiles.ts +15 -15
  43. package/src/config/model-registry.ts +21 -1
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +62 -0
  46. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  47. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  48. package/src/gjc-runtime/launch-tmux.ts +3 -4
  49. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  50. package/src/gjc-runtime/state-runtime.ts +2 -1
  51. package/src/gjc-runtime/state-writer.ts +254 -7
  52. package/src/gjc-runtime/tmux-gc.ts +2 -1
  53. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  54. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  55. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  56. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  57. package/src/harness-control-plane/owner.ts +3 -2
  58. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  59. package/src/hooks/skill-state.ts +121 -2
  60. package/src/internal-urls/docs-index.generated.ts +13 -9
  61. package/src/lsp/defaults.json +1 -0
  62. package/src/main.ts +14 -4
  63. package/src/modes/acp/acp-agent.ts +4 -2
  64. package/src/modes/bridge/bridge-mode.ts +2 -1
  65. package/src/modes/components/history-search.ts +5 -2
  66. package/src/modes/components/model-selector.ts +26 -0
  67. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  68. package/src/modes/controllers/selector-controller.ts +80 -1
  69. package/src/modes/interactive-mode.ts +11 -1
  70. package/src/modes/rpc/rpc-mode.ts +132 -18
  71. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  72. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  73. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  74. package/src/modes/theme/defaults/claude-code.json +100 -0
  75. package/src/modes/theme/defaults/codex.json +100 -0
  76. package/src/modes/theme/defaults/index.ts +6 -0
  77. package/src/modes/theme/defaults/opencode.json +102 -0
  78. package/src/modes/theme/theme.ts +2 -2
  79. package/src/modes/types.ts +1 -1
  80. package/src/prompts/agents/executor.md +5 -2
  81. package/src/sdk.ts +12 -1
  82. package/src/session/agent-session.ts +22 -11
  83. package/src/session/history-storage.ts +32 -11
  84. package/src/session/session-manager.ts +70 -18
  85. package/src/setup/credential-import.ts +429 -0
  86. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  87. package/src/task/executor.ts +7 -1
  88. package/src/task/render.ts +18 -7
  89. package/src/tools/ask.ts +4 -2
  90. package/src/tools/cron.ts +1 -1
  91. package/src/tools/subagent-render.ts +119 -29
  92. package/src/tools/subagent.ts +147 -7
  93. package/src/tools/ultragoal-ask-guard.ts +39 -0
  94. package/src/web/search/index.ts +25 -25
  95. package/src/web/search/provider.ts +178 -87
  96. package/src/web/search/providers/base.ts +2 -1
  97. package/src/web/search/providers/openai-compatible.ts +151 -0
  98. package/src/web/search/types.ts +47 -22
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import type { Stats } from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
5
+ import { type FileLockOptions, withFileLock } from "../config/file-lock";
4
6
  import type { ActiveSubskillEntry, SkillActiveEntry, SkillActiveState } from "../skill-state/active-state";
5
7
  import {
6
8
  type AuditEntry,
@@ -80,6 +82,12 @@ export interface StateWriterOptions {
80
82
  cwd?: string;
81
83
  receipt?: StateWriterReceiptContext;
82
84
  audit?: StateWriterAuditContext;
85
+ /**
86
+ * Cross-process lock tuning for read-modify-write paths that route through
87
+ * `withWorkflowStateLock` / `updateJsonAtomic`. Omit for the hardened
88
+ * `withFileLock` defaults.
89
+ */
90
+ lock?: FileLockOptions;
83
91
  }
84
92
 
85
93
  export interface DeleteIfOwnedOptions extends StateWriterOptions {
@@ -113,7 +121,7 @@ export interface GenericHardPruneTarget {
113
121
  export interface GenericHardPruneSelectorContext {
114
122
  path: string;
115
123
  category: WriterCategory | string;
116
- stat: Awaited<ReturnType<typeof fs.stat>>;
124
+ stat: Stats;
117
125
  readJson: () => Promise<unknown>;
118
126
  }
119
127
 
@@ -388,6 +396,57 @@ export async function writeJsonAtomic(
388
396
  return filePath;
389
397
  }
390
398
 
399
+ async function readPersistedPhase(filePath: string): Promise<string | undefined> {
400
+ try {
401
+ const existing = await readJsonIfPresent(filePath);
402
+ if (!isPlainObject(existing)) return undefined;
403
+ // Only an *active* prior envelope is a transition source. A cleared / handed-off
404
+ // envelope (`active: false`, terminal phase such as `complete` / `handoff`) is outside
405
+ // active workflow progression, so reactivation from it (e.g. a fresh kickoff) must not
406
+ // be reported as an invalid transition.
407
+ if (existing.active !== true) return undefined;
408
+ const phase = existing.current_phase;
409
+ return typeof phase === "string" ? phase : undefined;
410
+ } catch {
411
+ // Best-effort diagnostic read: a corrupt/unreadable prior envelope simply yields no
412
+ // `from` phase, so the transition invariant degrades to a no-op rather than failing
413
+ // the sanctioned write it is observing.
414
+ return undefined;
415
+ }
416
+ }
417
+
418
+ async function recordInvalidWorkflowTransition(args: {
419
+ filePath: string;
420
+ skill: CanonicalGjcWorkflowSkill;
421
+ fromPhase: string;
422
+ toPhase: string;
423
+ options?: StateWriterOptions;
424
+ }): Promise<void> {
425
+ const { filePath, skill, fromPhase, toPhase, options } = args;
426
+ // Audit-only diagnostic: a successful sanctioned write must NOT emit to stderr — callers
427
+ // may treat any stderr output as failure or parse stdout/stderr as machine output. The
428
+ // `invalid_transition_detected` audit entry is the durable, non-intrusive evidence that an
429
+ // internal write skipped a manifest edge.
430
+ const cwd = path.resolve(options?.audit?.cwd ?? options?.cwd ?? process.cwd());
431
+ try {
432
+ await appendAuditEntry(cwd, {
433
+ ts: new Date().toISOString(),
434
+ skill,
435
+ category: "state",
436
+ verb: "invalid_transition_detected",
437
+ owner: options?.audit?.owner ?? "gjc-runtime",
438
+ mutation_id: options?.audit?.mutationId ?? `${skill}:invalid-transition:${new Date().toISOString()}`,
439
+ from_phase: fromPhase,
440
+ to_phase: toPhase,
441
+ forced: false,
442
+ paths: [filePath],
443
+ });
444
+ } catch {
445
+ // Audit logging is best-effort diagnostics; never fail a sanctioned write because the
446
+ // audit append failed (e.g. cwd is not a writable project root).
447
+ }
448
+ }
449
+
391
450
  export async function writeWorkflowEnvelopeAtomic(
392
451
  targetPath: string,
393
452
  value: unknown,
@@ -404,6 +463,50 @@ export async function writeWorkflowEnvelopeAtomic(
404
463
  .join("; ")}`,
405
464
  );
406
465
  }
466
+ // #658: internal runtime writers (ralplan/ultragoal/deep-interview/team) persist
467
+ // envelopes directly, bypassing the `gjc state` CLI transition gate (`isValidTransition`,
468
+ // historically the sole call site in state-runtime.ts). Re-assert that gate on every
469
+ // sanctioned envelope write so internal writes cannot persist invalid state-machine phase
470
+ // transitions silently. Forced writes (`gjc state ... --force`, reconcile repairs) carry
471
+ // `audit.forced` and bypass, mirroring the CLI's `use --force to bypass`.
472
+ //
473
+ // The gate governs ACTIVE workflow progression only. Deactivation/teardown writes
474
+ // (`active: false`, e.g. `gjc state clear`, which persists the universal `complete`
475
+ // sentinel that is not a per-skill manifest state) leave the transition graph and are
476
+ // intentionally exempt.
477
+ if (options?.audit?.forced !== true && parsed.data.active === true) {
478
+ const toPhase = parsed.data.current_phase.trim();
479
+ if (toPhase) {
480
+ // Lazy import: workflow-manifest dereferences CANONICAL_GJC_WORKFLOW_SKILLS at
481
+ // module load, and active-state -> state-writer -> workflow-manifest -> active-state
482
+ // is a load-time cycle. Importing at call time (after init) avoids the TDZ.
483
+ const { isKnownWorkflowState, isValidTransition } = await import("./workflow-manifest");
484
+ const skill = parsed.data.skill;
485
+ // Structural invariant (hard): a `current_phase` absent from the skill's manifest is
486
+ // never a legitimate internal write, matching the CLI/reconcile unknown-phase gate.
487
+ if (!isKnownWorkflowState(skill, toPhase)) {
488
+ throw new Error(
489
+ `Refusing to write unknown ${skill} phase "${toPhase}" to ${filePath}: not a known ${skill} manifest state (forced writes bypass via audit.forced)`,
490
+ );
491
+ }
492
+ // Transition invariant (#658, diagnostic-only safety net): resolve the prior phase
493
+ // (caller-supplied `audit.fromPhase`, else the active persisted envelope on disk) and
494
+ // flag edges the manifest does not define. Intentionally NON-blocking and audit-only
495
+ // — the CLI path already hard-fails invalid edges before reaching here, and legitimate
496
+ // internal repairs / ralplan short-mode stage skips move between valid states without a
497
+ // direct manifest edge. It records an `invalid_transition_detected` audit entry (no
498
+ // stderr) so such transitions are non-silent without breaking those flows.
499
+ const fromPhase = (options?.audit?.fromPhase ?? (await readPersistedPhase(filePath)))?.trim();
500
+ if (
501
+ fromPhase &&
502
+ fromPhase !== toPhase &&
503
+ isKnownWorkflowState(skill, fromPhase) &&
504
+ !isValidTransition(skill, fromPhase, toPhase)
505
+ ) {
506
+ await recordInvalidWorkflowTransition({ filePath, skill, fromPhase, toPhase, options });
507
+ }
508
+ }
509
+ }
407
510
  await atomicWrite(filePath, jsonText(stamped));
408
511
  await maybeAudit(filePath, options);
409
512
  return filePath;
@@ -416,17 +519,55 @@ export async function writeTextAtomic(targetPath: string, text: string, options?
416
519
  return filePath;
417
520
  }
418
521
 
522
+ /**
523
+ * Serialize a read-modify-write (or any multi-step mutation) against concurrent
524
+ * writers of the same `.gjc/**` target. Uses the cross-process directory lock
525
+ * from `withFileLock`, keyed on the resolved file path, so separate CLI/agent
526
+ * processes (e.g. team-mode workers) cannot interleave one writer's read with
527
+ * another writer's write and silently drop the first mutation (issue #646).
528
+ *
529
+ * The lock is advisory: it only protects callers that route through it, so every
530
+ * read-modify-write of a given file MUST acquire this lock for the same resolved
531
+ * path. `atomicWrite`'s temp-file + rename crash-atomicity is preserved; this
532
+ * layers concurrency-atomicity on top without weakening it.
533
+ */
534
+ export async function withWorkflowStateLock<T>(
535
+ targetPath: string,
536
+ fn: () => Promise<T>,
537
+ options?: StateWriterOptions,
538
+ ): Promise<T> {
539
+ const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
540
+ return lockResolvedWorkflowTarget(filePath, fn, options?.lock);
541
+ }
542
+
543
+ async function lockResolvedWorkflowTarget<T>(
544
+ filePath: string,
545
+ fn: () => Promise<T>,
546
+ lockOptions?: FileLockOptions,
547
+ ): Promise<T> {
548
+ // `withFileLock` creates the lock dir next to the target with a non-recursive
549
+ // mkdir, so the parent directory must exist before the lock is acquired.
550
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
551
+ return withFileLock(filePath, fn, lockOptions);
552
+ }
553
+
419
554
  export async function updateJsonAtomic<T = unknown>(
420
555
  targetPath: string,
421
556
  mutator: (current: T | undefined) => T | Promise<T>,
422
557
  options?: StateWriterOptions,
423
558
  ): Promise<string> {
424
559
  const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
425
- const current = (await readJsonIfPresent(filePath)) as T | undefined;
426
- const next = await mutator(current);
427
- await atomicWrite(filePath, jsonText(withWorkflowReceipt(next, buildReceipt(options))));
428
- await maybeAudit(filePath, options);
429
- return filePath;
560
+ return lockResolvedWorkflowTarget(
561
+ filePath,
562
+ async () => {
563
+ const current = (await readJsonIfPresent(filePath)) as T | undefined;
564
+ const next = await mutator(current);
565
+ await atomicWrite(filePath, jsonText(withWorkflowReceipt(next, buildReceipt(options))));
566
+ await maybeAudit(filePath, options);
567
+ return filePath;
568
+ },
569
+ options?.lock,
570
+ );
430
571
  }
431
572
 
432
573
  export async function appendJsonl(targetPath: string, entry: unknown, options?: StateWriterOptions): Promise<string> {
@@ -437,6 +578,112 @@ export async function appendJsonl(targetPath: string, entry: unknown, options?:
437
578
  return filePath;
438
579
  }
439
580
 
581
+ export interface AppendJsonlIdempotentOptions extends StateWriterOptions {
582
+ /**
583
+ * Identity key for an entry. Two entries that produce the same non-`undefined`
584
+ * key are duplicates, so only the first is appended. Return `undefined` to opt a
585
+ * candidate out of dedup (it is always appended). Use `key` for the common case
586
+ * where identity reduces to a single string.
587
+ */
588
+ key?: (entry: unknown) => string | undefined;
589
+ /**
590
+ * Equivalence predicate: return `true` when `existing` already represents
591
+ * `candidate`, suppressing the append. Use when identity cannot be reduced to a
592
+ * single string key. When both `key` and `equals` are supplied, `equals` wins.
593
+ */
594
+ equals?: (candidate: unknown, existing: unknown) => boolean;
595
+ }
596
+
597
+ export interface AppendJsonlIdempotentResult {
598
+ path: string;
599
+ /** `true` when the entry was written; `false` when an equivalent entry already existed. */
600
+ appended: boolean;
601
+ /** The pre-existing entry that suppressed the append, when `appended` is `false`. */
602
+ duplicate?: unknown;
603
+ }
604
+
605
+ async function readJsonlEntries(filePath: string): Promise<unknown[]> {
606
+ let raw: string;
607
+ try {
608
+ raw = await fs.readFile(filePath, "utf-8");
609
+ } catch (error) {
610
+ if (isErrno(error, "ENOENT")) return [];
611
+ throw error;
612
+ }
613
+ const entries: unknown[] = [];
614
+ for (const line of raw.split(/\r?\n/)) {
615
+ const trimmed = line.trim();
616
+ if (!trimmed) continue;
617
+ try {
618
+ entries.push(JSON.parse(trimmed));
619
+ } catch {
620
+ // Best-effort: dedup compares parseable rows only. A corrupt line cannot
621
+ // be matched, so it never suppresses a new append.
622
+ }
623
+ }
624
+ return entries;
625
+ }
626
+
627
+ function findJsonlDuplicate(
628
+ existing: readonly unknown[],
629
+ candidate: unknown,
630
+ options: AppendJsonlIdempotentOptions,
631
+ ): unknown | undefined {
632
+ if (options.equals) {
633
+ const equals = options.equals;
634
+ return existing.find(item => equals(candidate, item));
635
+ }
636
+ const key = options.key;
637
+ if (!key) return undefined;
638
+ const candidateKey = key(candidate);
639
+ if (candidateKey === undefined) return undefined;
640
+ return existing.find(item => key(item) === candidateKey);
641
+ }
642
+
643
+ /**
644
+ * Append `entry` to a JSONL file only when no equivalent entry already exists —
645
+ * the shared idempotent append primitive (issue #660).
646
+ *
647
+ * `appendJsonl` is a pure append with no dedup, so every recurring "duplicate
648
+ * ledger row" bug (#638, #643, #645) had to be patched with bespoke per-call-site
649
+ * guards. This primitive centralizes the read-check-append cycle: a caller
650
+ * declares identity once via `key` or `equals` instead of re-deriving the lookup
651
+ * at each site.
652
+ *
653
+ * The read-then-append is serialized through the same cross-process workflow lock
654
+ * as `updateJsonAtomic`, so two concurrent idempotent appends cannot both observe
655
+ * "no duplicate" and both write (the #646 TOCTOU that a plain `appendJsonl`
656
+ * preceded by a manual existence check is still exposed to).
657
+ *
658
+ * Scope note: this dedups the *append* only. Call sites whose idempotency must
659
+ * also skip a coupled mutation — e.g. the plan/state rewrite in #643/#645 — still
660
+ * need a whole-operation guard; this primitive is the ledger-level half of that.
661
+ */
662
+ export async function appendJsonlIdempotent(
663
+ targetPath: string,
664
+ entry: unknown,
665
+ options: AppendJsonlIdempotentOptions,
666
+ ): Promise<AppendJsonlIdempotentResult> {
667
+ if (!options.key && !options.equals) {
668
+ throw new Error("appendJsonlIdempotent requires a `key` or `equals` option to detect duplicates");
669
+ }
670
+ const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
671
+ return lockResolvedWorkflowTarget(
672
+ filePath,
673
+ async () => {
674
+ const existing = await readJsonlEntries(filePath);
675
+ const duplicate = findJsonlDuplicate(existing, entry, options);
676
+ if (duplicate !== undefined) {
677
+ return { path: filePath, appended: false, duplicate };
678
+ }
679
+ await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf-8");
680
+ await maybeAudit(filePath, options);
681
+ return { path: filePath, appended: true };
682
+ },
683
+ options.lock,
684
+ );
685
+ }
686
+
440
687
  export async function appendText(targetPath: string, text: string, options?: StateWriterOptions): Promise<string> {
441
688
  const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
442
689
  await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -639,7 +886,7 @@ export async function hardPrune(
639
886
  const removed: string[] = [];
640
887
  for (const target of targets) {
641
888
  const filePath = resolveGjcTarget(target.path, cwd);
642
- let stat: Awaited<ReturnType<typeof fs.stat>>;
889
+ let stat: Stats;
643
890
  try {
644
891
  stat = await fs.stat(filePath);
645
892
  } catch (error) {
@@ -11,6 +11,7 @@ import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapt
11
11
  import { GJC_TMUX_PROFILE_VALUE } from "./tmux-common";
12
12
  import {
13
13
  type GjcTmuxSessionStatus,
14
+ type GjcTmuxSessionsForGc,
14
15
  listTmuxSessionsForGc,
15
16
  readTmuxSessionTagsForGc,
16
17
  removeGjcTmuxSession,
@@ -124,7 +125,7 @@ export const tmuxSessionsGcAdapter: GcStoreAdapter = {
124
125
  async collect(ctx: GcContext): Promise<GcCollectResult> {
125
126
  const records: GcRecord[] = [];
126
127
  const errors: GcCollectResult["errors"] = [];
127
- let sessions: ReturnType<typeof listTmuxSessionsForGc>;
128
+ let sessions: GjcTmuxSessionsForGc;
128
129
  try {
129
130
  sessions = listTmuxSessionsForGc(ctx.env);
130
131
  } catch (error) {
@@ -32,6 +32,16 @@ export interface UltragoalGuardDiagnostic {
32
32
  goalId?: string;
33
33
  }
34
34
 
35
+ export interface UltragoalAskBlockDiagnostic {
36
+ active: boolean;
37
+ reason: string;
38
+ source: "absent" | "durable_state" | "durable_state_unreadable" | "ledger" | "goals_json";
39
+ goalsPath?: string;
40
+ ledgerPath?: string;
41
+ goalIds?: string[];
42
+ message: string;
43
+ }
44
+
35
45
  export interface CurrentGoalLike {
36
46
  objective: string;
37
47
  status?: string;
@@ -70,6 +80,48 @@ async function hasDurableUltragoalState(cwd: string): Promise<boolean> {
70
80
  }
71
81
  }
72
82
 
83
+ function isEnoent(error: unknown): boolean {
84
+ return (
85
+ typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT"
86
+ );
87
+ }
88
+
89
+ function activeAskDiagnostic(input: {
90
+ reason: string;
91
+ source: UltragoalAskBlockDiagnostic["source"];
92
+ goalsPath?: string;
93
+ ledgerPath?: string;
94
+ goalIds?: string[];
95
+ }): UltragoalAskBlockDiagnostic {
96
+ return {
97
+ active: true,
98
+ reason: input.reason,
99
+ source: input.source,
100
+ goalsPath: input.goalsPath,
101
+ ledgerPath: input.ledgerPath,
102
+ goalIds: input.goalIds,
103
+ message: `${input.reason} Use \`gjc ultragoal record-review-blockers\` instead of asking the user.`,
104
+ };
105
+ }
106
+
107
+ function inactiveAskDiagnostic(input: {
108
+ reason: string;
109
+ source: UltragoalAskBlockDiagnostic["source"];
110
+ goalsPath?: string;
111
+ ledgerPath?: string;
112
+ goalIds?: string[];
113
+ }): UltragoalAskBlockDiagnostic {
114
+ return {
115
+ active: false,
116
+ reason: input.reason,
117
+ source: input.source,
118
+ goalsPath: input.goalsPath,
119
+ ledgerPath: input.ledgerPath,
120
+ goalIds: input.goalIds,
121
+ message: input.reason,
122
+ };
123
+ }
124
+
73
125
  function requiredGoals(plan: UltragoalPlan): UltragoalGoal[] {
74
126
  return plan.goals.filter(goal => goal.status !== "superseded");
75
127
  }
@@ -278,6 +330,109 @@ export async function readUltragoalVerificationState(input: {
278
330
  return receiptDiagnostic;
279
331
  }
280
332
 
333
+ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBlockDiagnostic> {
334
+ const paths = getUltragoalPaths(cwd);
335
+ try {
336
+ await fs.stat(paths.dir);
337
+ } catch (error) {
338
+ if (isEnoent(error)) {
339
+ return inactiveAskDiagnostic({
340
+ reason: "No durable .gjc/ultragoal state exists.",
341
+ source: "absent",
342
+ goalsPath: paths.goalsPath,
343
+ ledgerPath: paths.ledgerPath,
344
+ });
345
+ }
346
+ return activeAskDiagnostic({
347
+ reason: `Durable .gjc/ultragoal state is present but unreadable: ${error instanceof Error ? error.message : String(error)}`,
348
+ source: "durable_state_unreadable",
349
+ goalsPath: paths.goalsPath,
350
+ ledgerPath: paths.ledgerPath,
351
+ });
352
+ }
353
+
354
+ let plan: UltragoalPlan | null;
355
+ let ledger: UltragoalLedgerEvent[];
356
+ try {
357
+ plan = await readUltragoalPlan(cwd);
358
+ ledger = await readUltragoalLedger(cwd);
359
+ } catch (error) {
360
+ return activeAskDiagnostic({
361
+ reason: `Unable to read durable Ultragoal state: ${error instanceof Error ? error.message : String(error)}`,
362
+ source: "durable_state_unreadable",
363
+ goalsPath: paths.goalsPath,
364
+ ledgerPath: paths.ledgerPath,
365
+ });
366
+ }
367
+ if (!plan) {
368
+ return activeAskDiagnostic({
369
+ reason: "Durable .gjc/ultragoal state exists but goals.json is missing or empty.",
370
+ source: "durable_state_unreadable",
371
+ goalsPath: paths.goalsPath,
372
+ ledgerPath: paths.ledgerPath,
373
+ });
374
+ }
375
+
376
+ if (plan.goals.some(goal => goal.status === "review_blocked")) {
377
+ const goalIds = plan.goals.filter(goal => goal.status === "review_blocked").map(goal => goal.id);
378
+ return activeAskDiagnostic({
379
+ reason: `Ultragoal has recorded review blockers: ${goalIds.join(", ")}.`,
380
+ source: "goals_json",
381
+ goalsPath: paths.goalsPath,
382
+ ledgerPath: paths.ledgerPath,
383
+ goalIds,
384
+ });
385
+ }
386
+
387
+ const runState = getUltragoalRunCompletionState(plan);
388
+ if (runState.incompleteGoals.length > 0) {
389
+ const goalIds = runState.incompleteGoals.map(goal => goal.id);
390
+ return activeAskDiagnostic({
391
+ reason: `Ultragoal has incomplete required goals: ${goalIds.join(", ")}.`,
392
+ source: "goals_json",
393
+ goalsPath: paths.goalsPath,
394
+ ledgerPath: paths.ledgerPath,
395
+ goalIds,
396
+ });
397
+ }
398
+
399
+ const finalReceiptGoal = [...requiredGoals(plan)]
400
+ .reverse()
401
+ .find(goal => goal.completionVerification?.receiptKind === "final-aggregate");
402
+ if (!finalReceiptGoal) {
403
+ return activeAskDiagnostic({
404
+ reason: "Ultragoal aggregate completion is missing a final aggregate receipt.",
405
+ source: "durable_state",
406
+ goalsPath: paths.goalsPath,
407
+ ledgerPath: paths.ledgerPath,
408
+ goalIds: requiredGoals(plan).map(goal => goal.id),
409
+ });
410
+ }
411
+
412
+ const diagnostic = validateCompletionReceipt({
413
+ plan,
414
+ ledger,
415
+ goal: finalReceiptGoal,
416
+ receiptKind: "final-aggregate",
417
+ });
418
+ if (diagnostic.state !== "active_verified_complete") {
419
+ return activeAskDiagnostic({
420
+ reason: diagnostic.message,
421
+ source: diagnostic.state === "active_dirty_quality_gate" ? "ledger" : "durable_state",
422
+ goalsPath: paths.goalsPath,
423
+ ledgerPath: paths.ledgerPath,
424
+ goalIds: diagnostic.goalId ? [diagnostic.goalId] : undefined,
425
+ });
426
+ }
427
+ return inactiveAskDiagnostic({
428
+ reason: "Ultragoal run is verified complete.",
429
+ source: "durable_state",
430
+ goalsPath: paths.goalsPath,
431
+ ledgerPath: paths.ledgerPath,
432
+ goalIds: [finalReceiptGoal.id],
433
+ });
434
+ }
435
+
281
436
  export async function assertCanCompleteCurrentGoal(input: {
282
437
  cwd: string;
283
438
  currentGoal?: CurrentGoalLike | null;