@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.
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock.d.ts +24 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- package/dist/types/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +21 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/cli/args.ts +2 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/setup-cli.ts +138 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +14 -2
- package/src/config/file-lock.ts +54 -12
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +15 -15
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ralplan-runtime.ts +174 -12
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/tmux-gc.ts +2 -1
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/owner.ts +3 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/docs-index.generated.ts +13 -9
- package/src/lsp/defaults.json +1 -0
- package/src/main.ts +14 -4
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +2 -1
- package/src/modes/components/history-search.ts +5 -2
- package/src/modes/components/model-selector.ts +26 -0
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/controllers/selector-controller.ts +80 -1
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +132 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/prompts/agents/executor.md +5 -2
- package/src/sdk.ts +12 -1
- package/src/session/agent-session.ts +22 -11
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +70 -18
- package/src/setup/credential-import.ts +429 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/task/executor.ts +7 -1
- package/src/task/render.ts +18 -7
- package/src/tools/ask.ts +4 -2
- package/src/tools/cron.ts +1 -1
- package/src/tools/subagent-render.ts +119 -29
- package/src/tools/subagent.ts +147 -7
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- 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:
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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:
|
|
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:
|
|
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;
|