@gajae-code/coding-agent 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.
@@ -1,3 +1,4 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { DEFAULT_ULTRAGOAL_OBJECTIVE } from "./goal-mode-request";
@@ -23,6 +24,7 @@ export interface UltragoalGoal {
23
24
  completedAt?: string;
24
25
  evidence?: string;
25
26
  steering?: Record<string, unknown>;
27
+ completionVerification?: UltragoalCompletionVerification;
26
28
  }
27
29
 
28
30
  export interface UltragoalPlan {
@@ -36,6 +38,36 @@ export interface UltragoalPlan {
36
38
  updatedAt: string;
37
39
  }
38
40
 
41
+ export type UltragoalReceiptKind = "per-goal" | "final-aggregate";
42
+
43
+ export interface UltragoalCompletionVerification {
44
+ schemaVersion: 1;
45
+ receiptId: string;
46
+ verifiedAt: string;
47
+ goalId: string;
48
+ receiptKind: UltragoalReceiptKind;
49
+ goalStatusBeforeCheckpoint: UltragoalGoalStatus;
50
+ gjcGoalMode: UltragoalGjcGoalMode;
51
+ gjcObjective: string;
52
+ qualityGateHash: string;
53
+ planGeneration: string;
54
+ basis: {
55
+ planHashBeforeCheckpoint: string;
56
+ latestRelevantLedgerEventIdBeforeCheckpoint: string | null;
57
+ goalUpdatedAtBeforeCheckpoint: string;
58
+ relevantGoalIdsBeforeCheckpoint: string[];
59
+ requiredGoalSetHashBeforeCheckpoint: string;
60
+ };
61
+ checkpointLedgerEventId: string;
62
+ }
63
+
64
+ export interface UltragoalLedgerEvent extends JsonObject {
65
+ eventId?: string;
66
+ event?: string;
67
+ goalId?: string;
68
+ timestamp?: string;
69
+ }
70
+
39
71
  export interface UltragoalPaths {
40
72
  dir: string;
41
73
  briefPath: string;
@@ -65,8 +97,33 @@ interface JsonObject {
65
97
  }
66
98
 
67
99
  const TERMINAL_OR_SKIPPED_STATUSES = new Set<UltragoalGoalStatus>(["complete", "superseded"]);
100
+ const CLEAN_ARCHITECT_STATUS = "CLEAR";
101
+ const APPROVE_RECOMMENDATION = "APPROVE";
102
+ const PASSED_STATUS = "passed";
103
+
68
104
  const SCHEDULABLE_STATUSES = new Set<UltragoalGoalStatus>(["pending", "active", "failed"]);
69
105
 
106
+ function stableStructuredValue(value: unknown): unknown {
107
+ if (Array.isArray(value)) return value.map(item => stableStructuredValue(item));
108
+ if (value && typeof value === "object") {
109
+ const record = value as Record<string, unknown>;
110
+ const output: Record<string, unknown> = {};
111
+ for (const key of Object.keys(record).sort()) {
112
+ const item = record[key];
113
+ if (item !== undefined) output[key] = stableStructuredValue(item);
114
+ }
115
+ return output;
116
+ }
117
+ return value;
118
+ }
119
+
120
+ export function hashStructuredValue(value: unknown): string {
121
+ return crypto
122
+ .createHash("sha256")
123
+ .update(JSON.stringify(stableStructuredValue(value)))
124
+ .digest("hex");
125
+ }
126
+
70
127
  export function getUltragoalPaths(cwd: string): UltragoalPaths {
71
128
  const dir = path.join(cwd, ".gjc", "ultragoal");
72
129
  return {
@@ -87,11 +144,30 @@ async function ensureUltragoalDir(paths: UltragoalPaths): Promise<void> {
87
144
  await fs.mkdir(paths.dir, { recursive: true });
88
145
  }
89
146
 
90
- async function appendLedger(cwd: string, event: JsonObject): Promise<void> {
147
+ async function appendLedger(cwd: string, event: JsonObject): Promise<UltragoalLedgerEvent> {
91
148
  const paths = getUltragoalPaths(cwd);
92
149
  await ensureUltragoalDir(paths);
93
- const entry = { ...event, timestamp: new Date().toISOString() };
150
+ const entry: UltragoalLedgerEvent = {
151
+ eventId: typeof event.eventId === "string" ? event.eventId : crypto.randomUUID(),
152
+ ...event,
153
+ timestamp: new Date().toISOString(),
154
+ };
94
155
  await fs.appendFile(paths.ledgerPath, `${JSON.stringify(entry)}\n`);
156
+ return entry;
157
+ }
158
+
159
+ export async function readUltragoalLedger(cwd: string): Promise<UltragoalLedgerEvent[]> {
160
+ try {
161
+ const raw = await Bun.file(getUltragoalPaths(cwd).ledgerPath).text();
162
+ return raw
163
+ .split(/\r?\n/)
164
+ .map(line => line.trim())
165
+ .filter(line => line.length > 0)
166
+ .map(line => JSON.parse(line) as UltragoalLedgerEvent);
167
+ } catch (error) {
168
+ if (isEnoent(error)) return [];
169
+ throw error;
170
+ }
95
171
  }
96
172
 
97
173
  async function writePlan(cwd: string, plan: UltragoalPlan): Promise<void> {
@@ -159,6 +235,10 @@ function normalizePlan(raw: unknown): UltragoalPlan {
159
235
  typeof goalRecord.steering === "object" && goalRecord.steering !== null
160
236
  ? (goalRecord.steering as Record<string, unknown>)
161
237
  : undefined,
238
+ completionVerification:
239
+ typeof goalRecord.completionVerification === "object" && goalRecord.completionVerification !== null
240
+ ? (goalRecord.completionVerification as UltragoalCompletionVerification)
241
+ : undefined,
162
242
  };
163
243
  });
164
244
  const aliases = Array.isArray(record.gjcObjectiveAliases)
@@ -303,6 +383,201 @@ async function readStructuredValue(cwd: string, value: string): Promise<unknown>
303
383
  throw error;
304
384
  }
305
385
  }
386
+ function qualityGateObject(value: unknown): JsonObject | null {
387
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as JsonObject) : null;
388
+ }
389
+
390
+ function requiredReceiptGoals(plan: UltragoalPlan): UltragoalGoal[] {
391
+ return plan.goals.filter(goal => goal.status !== "superseded");
392
+ }
393
+
394
+ function chooseReceiptKind(
395
+ plan: UltragoalPlan,
396
+ goal: UltragoalGoal,
397
+ status: UltragoalGoalStatus,
398
+ ): UltragoalReceiptKind {
399
+ if (status !== "complete") return "per-goal";
400
+ const incomplete = requiredReceiptGoals(plan).filter(item => item.id !== goal.id && item.status !== "complete");
401
+ return incomplete.length === 0 ? "final-aggregate" : "per-goal";
402
+ }
403
+
404
+ function relevantGoalIds(plan: UltragoalPlan, goal: UltragoalGoal, receiptKind: UltragoalReceiptKind): string[] {
405
+ const ids = receiptKind === "final-aggregate" ? requiredReceiptGoals(plan).map(item => item.id) : [goal.id];
406
+ return [...new Set(ids)].sort();
407
+ }
408
+
409
+ function ledgerEventTouchesGoals(event: UltragoalLedgerEvent, goalIds: readonly string[]): boolean {
410
+ if (typeof event.goalId === "string" && goalIds.includes(event.goalId)) return true;
411
+ const eventGoalIds = Array.isArray(event.goalIds) ? event.goalIds : [];
412
+ return eventGoalIds.some(item => typeof item === "string" && goalIds.includes(item));
413
+ }
414
+
415
+ export function computeUltragoalPlanGeneration(input: {
416
+ plan: UltragoalPlan;
417
+ ledger: readonly UltragoalLedgerEvent[];
418
+ goal: UltragoalGoal;
419
+ receiptKind: UltragoalReceiptKind;
420
+ beforeStatus: UltragoalGoalStatus;
421
+ excludeEventId?: string;
422
+ }): UltragoalCompletionVerification["basis"] & { planGeneration: string } {
423
+ const goalIds = relevantGoalIds(input.plan, input.goal, input.receiptKind);
424
+ const planBeforeCheckpoint = structuredClone(input.plan) as UltragoalPlan;
425
+ const goalBeforeCheckpoint = planBeforeCheckpoint.goals.find(goal => goal.id === input.goal.id);
426
+ const receipt = input.goal.completionVerification;
427
+ const goalUpdatedAtBeforeCheckpoint = receipt?.basis.goalUpdatedAtBeforeCheckpoint ?? input.goal.updatedAt;
428
+ if (goalBeforeCheckpoint) {
429
+ goalBeforeCheckpoint.status = input.beforeStatus;
430
+ goalBeforeCheckpoint.updatedAt = goalUpdatedAtBeforeCheckpoint;
431
+ delete goalBeforeCheckpoint.completedAt;
432
+ delete goalBeforeCheckpoint.completionVerification;
433
+ }
434
+ const relevantLedger = input.ledger.filter(event => {
435
+ if (input.excludeEventId && event.eventId === input.excludeEventId) return false;
436
+ return ledgerEventTouchesGoals(event, goalIds);
437
+ });
438
+ const latestRelevantEvent = relevantLedger.at(-1) ?? null;
439
+ const basis = {
440
+ planHashBeforeCheckpoint: hashStructuredValue(planBeforeCheckpoint),
441
+ latestRelevantLedgerEventIdBeforeCheckpoint:
442
+ typeof latestRelevantEvent?.eventId === "string" ? latestRelevantEvent.eventId : null,
443
+ goalUpdatedAtBeforeCheckpoint,
444
+ relevantGoalIdsBeforeCheckpoint: goalIds,
445
+ requiredGoalSetHashBeforeCheckpoint: hashStructuredValue(goalIds),
446
+ };
447
+ return {
448
+ ...basis,
449
+ planGeneration: hashStructuredValue({
450
+ receiptKind: input.receiptKind,
451
+ goalId: input.goal.id,
452
+ beforeStatus: input.beforeStatus,
453
+ basis,
454
+ }),
455
+ };
456
+ }
457
+
458
+ function buildCompletionReceipt(input: {
459
+ plan: UltragoalPlan;
460
+ ledger: readonly UltragoalLedgerEvent[];
461
+ goal: UltragoalGoal;
462
+ receiptKind: UltragoalReceiptKind;
463
+ beforeStatus: UltragoalGoalStatus;
464
+ qualityGateJson: JsonObject;
465
+ now: string;
466
+ checkpointLedgerEventId: string;
467
+ }): UltragoalCompletionVerification {
468
+ const generation = computeUltragoalPlanGeneration({
469
+ plan: input.plan,
470
+ ledger: input.ledger,
471
+ goal: input.goal,
472
+ receiptKind: input.receiptKind,
473
+ beforeStatus: input.beforeStatus,
474
+ excludeEventId: input.checkpointLedgerEventId,
475
+ });
476
+ return {
477
+ schemaVersion: 1,
478
+ receiptId: crypto.randomUUID(),
479
+ verifiedAt: input.now,
480
+ goalId: input.goal.id,
481
+ receiptKind: input.receiptKind,
482
+ goalStatusBeforeCheckpoint: input.beforeStatus,
483
+ gjcGoalMode: input.plan.gjcGoalMode,
484
+ gjcObjective: input.plan.gjcObjective,
485
+ qualityGateHash: hashStructuredValue(input.qualityGateJson),
486
+ planGeneration: generation.planGeneration,
487
+ basis: {
488
+ planHashBeforeCheckpoint: generation.planHashBeforeCheckpoint,
489
+ latestRelevantLedgerEventIdBeforeCheckpoint: generation.latestRelevantLedgerEventIdBeforeCheckpoint,
490
+ goalUpdatedAtBeforeCheckpoint: generation.goalUpdatedAtBeforeCheckpoint,
491
+ relevantGoalIdsBeforeCheckpoint: generation.relevantGoalIdsBeforeCheckpoint,
492
+ requiredGoalSetHashBeforeCheckpoint: generation.requiredGoalSetHashBeforeCheckpoint,
493
+ },
494
+ checkpointLedgerEventId: input.checkpointLedgerEventId,
495
+ };
496
+ }
497
+ function nonEmptyStringArray(value: unknown): string[] | null {
498
+ if (!Array.isArray(value)) return null;
499
+ const items = value.filter(item => typeof item === "string" && item.trim().length > 0);
500
+ return items.length === value.length && items.length > 0 ? items : null;
501
+ }
502
+
503
+ function emptyArray(value: unknown): boolean {
504
+ return Array.isArray(value) && value.length === 0;
505
+ }
506
+
507
+ function assertCleanSection(
508
+ section: JsonObject | null,
509
+ checks: Record<string, string>,
510
+ commandField: string,
511
+ pathPrefix: string,
512
+ ): void {
513
+ if (!section) throw new Error(`qualityGate.${pathPrefix} is required`);
514
+ for (const [field, expected] of Object.entries(checks)) {
515
+ if (section[field] !== expected) throw new Error(`qualityGate.${pathPrefix}.${field} must be ${expected}`);
516
+ }
517
+ if (!nonEmptyString(section.evidence)) throw new Error(`qualityGate.${pathPrefix}.evidence is required`);
518
+ if (!nonEmptyStringArray(section[commandField]))
519
+ throw new Error(`qualityGate.${pathPrefix}.${commandField} is required`);
520
+ if (!emptyArray(section.blockers)) throw new Error(`qualityGate.${pathPrefix}.blockers must be empty`);
521
+ }
522
+
523
+ async function readRequiredCompletionQualityGate(cwd: string, value: string | undefined): Promise<JsonObject> {
524
+ if (!value?.trim()) {
525
+ throw new Error("complete checkpoints require --quality-gate-json; requires --quality-gate-json");
526
+ }
527
+ const gate = await readStructuredValue(cwd, value);
528
+ const gateObject = qualityGateObject(gate);
529
+ if (!gateObject) throw new Error("qualityGate must be a JSON object");
530
+ const legacyCodeReview = qualityGateObject(gateObject.codeReview);
531
+ if (
532
+ legacyCodeReview &&
533
+ (legacyCodeReview.recommendation !== APPROVE_RECOMMENDATION ||
534
+ legacyCodeReview.architectStatus !== CLEAN_ARCHITECT_STATUS)
535
+ ) {
536
+ throw new Error(
537
+ "checkpoint --status complete requires architect review approval: codeReview.recommendation must be APPROVE and codeReview.architectStatus must be CLEAR",
538
+ );
539
+ }
540
+ const allowedKeys = new Set(["architectReview", "executorQa", "iteration"]);
541
+ const unsupportedKeys = Object.keys(gateObject).filter(key => !allowedKeys.has(key));
542
+ if (unsupportedKeys.length > 0) {
543
+ throw new Error(`qualityGate contains unsupported keys: ${unsupportedKeys.join(", ")}`);
544
+ }
545
+ assertCleanSection(
546
+ qualityGateObject(gateObject.architectReview),
547
+ {
548
+ architectureStatus: CLEAN_ARCHITECT_STATUS,
549
+ productStatus: CLEAN_ARCHITECT_STATUS,
550
+ codeStatus: CLEAN_ARCHITECT_STATUS,
551
+ recommendation: APPROVE_RECOMMENDATION,
552
+ },
553
+ "commands",
554
+ "architectReview",
555
+ );
556
+ assertCleanSection(
557
+ qualityGateObject(gateObject.executorQa),
558
+ {
559
+ status: PASSED_STATUS,
560
+ e2eStatus: PASSED_STATUS,
561
+ redTeamStatus: PASSED_STATUS,
562
+ },
563
+ "e2eCommands",
564
+ "executorQa",
565
+ );
566
+ const executorQa = qualityGateObject(gateObject.executorQa);
567
+ if (!nonEmptyStringArray(executorQa?.redTeamCommands))
568
+ throw new Error("qualityGate.executorQa.redTeamCommands is required");
569
+ assertCleanSection(
570
+ qualityGateObject(gateObject.iteration),
571
+ {
572
+ status: PASSED_STATUS,
573
+ },
574
+ "rerunCommands",
575
+ "iteration",
576
+ );
577
+ const iteration = qualityGateObject(gateObject.iteration);
578
+ if (iteration?.fullRerun !== true) throw new Error("qualityGate.iteration.fullRerun must be true");
579
+ return gateObject;
580
+ }
306
581
 
307
582
  export async function checkpointUltragoalGoal(input: {
308
583
  cwd: string;
@@ -318,7 +593,44 @@ export async function checkpointUltragoalGoal(input: {
318
593
  if (!goal) throw new Error(`No ultragoal goal found for ${input.goalId}.`);
319
594
  const evidence = input.evidence.trim();
320
595
  if (!evidence) throw new Error("checkpoint evidence is required");
596
+ const qualityGateJson =
597
+ input.status === "complete"
598
+ ? await readRequiredCompletionQualityGate(input.cwd, input.qualityGateJson)
599
+ : input.qualityGateJson
600
+ ? await readStructuredValue(input.cwd, input.qualityGateJson)
601
+ : undefined;
602
+ if (input.status === "complete" && !input.gjcGoalJson?.trim()) {
603
+ throw new Error("complete checkpoints require --gjc-goal-json with a fresh get_goal snapshot");
604
+ }
321
605
  const now = new Date().toISOString();
606
+ const ledgerBefore = await readUltragoalLedger(input.cwd);
607
+ const beforeStatus = goal.status;
608
+ if (input.status === "complete") {
609
+ const blockedGoalId =
610
+ typeof goal.steering?.kind === "string" && goal.steering.kind === "review_blocker"
611
+ ? nonEmptyString(goal.steering.blockedGoalId)
612
+ : null;
613
+ const blockedGoal = blockedGoalId ? plan.goals.find(item => item.id === blockedGoalId) : undefined;
614
+ if (blockedGoal?.status === "review_blocked") {
615
+ blockedGoal.status = "superseded";
616
+ blockedGoal.evidence = `Resolved by verification blocker story ${goal.id}: ${evidence}`;
617
+ blockedGoal.updatedAt = now;
618
+ }
619
+ }
620
+ const receiptKind = input.status === "complete" ? chooseReceiptKind(plan, goal, input.status) : null;
621
+ const pendingCheckpointEventId = crypto.randomUUID();
622
+ if (input.status === "complete" && receiptKind && qualityGateJson && !Array.isArray(qualityGateJson)) {
623
+ goal.completionVerification = buildCompletionReceipt({
624
+ plan,
625
+ ledger: ledgerBefore,
626
+ goal,
627
+ receiptKind,
628
+ beforeStatus,
629
+ qualityGateJson: qualityGateJson as JsonObject,
630
+ now,
631
+ checkpointLedgerEventId: pendingCheckpointEventId,
632
+ });
633
+ }
322
634
  goal.status = input.status;
323
635
  goal.evidence = evidence;
324
636
  goal.updatedAt = now;
@@ -326,12 +638,13 @@ export async function checkpointUltragoalGoal(input: {
326
638
  plan.updatedAt = now;
327
639
  await writePlan(input.cwd, plan);
328
640
  await appendLedger(input.cwd, {
641
+ eventId: pendingCheckpointEventId,
329
642
  event: "goal_checkpointed",
330
643
  goalId: goal.id,
331
644
  status: input.status,
332
645
  evidence,
333
646
  gjcGoalJson: input.gjcGoalJson ? await readStructuredValue(input.cwd, input.gjcGoalJson) : undefined,
334
- qualityGateJson: input.qualityGateJson ? await readStructuredValue(input.cwd, input.qualityGateJson) : undefined,
647
+ qualityGateJson,
335
648
  });
336
649
  return plan;
337
650
  }
@@ -480,7 +793,8 @@ function renderCompleteHandoff(
480
793
  `Ultragoal handoff: ${result.goal.id} — ${result.goal.title}`,
481
794
  `Objective: ${result.goal.objective}`,
482
795
  `GJC objective: ${result.plan.gjcObjective}`,
483
- "Call get_goal({}); create_goal only if no active GJC goal exists, then complete this GJC story and checkpoint it.",
796
+ "Call get_goal({}); create_goal only if no active GJC goal exists, then complete this GJC story.",
797
+ "Before checkpointing complete, obtain a passing architectReview (architecture/product/code CLEAR + APPROVE) and executorQa (e2e/red-team passed); record blockers instead of completing on any finding.",
484
798
  "",
485
799
  ].join("\n");
486
800
  }
@@ -4,6 +4,7 @@ import { Text } from "@gajae-code/tui";
4
4
  import { formatNumber, prompt } from "@gajae-code/utils";
5
5
  import * as z from "zod/v4";
6
6
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
7
+ import { assertCanCompleteCurrentGoal } from "../../gjc-runtime/ultragoal-guard";
7
8
  import type { Theme, ThemeColor } from "../../modes/theme/theme";
8
9
  import createGoalDescription from "../../prompts/tools/create-goal.md" with { type: "text" };
9
10
  import getGoalDescription from "../../prompts/tools/get-goal.md" with { type: "text" };
@@ -128,6 +129,11 @@ async function executeGoalOperation(session: ToolSession, params: GoalToolInput)
128
129
  const dropped = await runtime.dropGoal();
129
130
  return buildGoalToolResponse(dropped ?? null);
130
131
  }
132
+ try {
133
+ await assertCanCompleteCurrentGoal({ cwd: session.cwd, currentGoal: session.getGoalModeState?.()?.goal ?? null });
134
+ } catch (error) {
135
+ throw new ToolError(error instanceof Error ? error.message : String(error));
136
+ }
131
137
  const completed = await runtime.completeGoalFromTool();
132
138
  return buildGoalToolResponse(completed, { includeCompletionReport: true });
133
139
  }
@@ -135,7 +141,7 @@ async function executeGoalOperation(session: ToolSession, params: GoalToolInput)
135
141
  export class GoalTool implements AgentTool<typeof goalSchema, GoalToolDetails> {
136
142
  readonly name = "goal";
137
143
  readonly label = "Goal";
138
- readonly loadMode = "essential";
144
+ readonly loadMode = "essential" as const;
139
145
  readonly description = prompt.render(goalDescription);
140
146
  readonly parameters = goalSchema;
141
147
  readonly strict = true;
@@ -161,7 +167,7 @@ export class GoalTool implements AgentTool<typeof goalSchema, GoalToolDetails> {
161
167
  export class GetGoalTool implements AgentTool<typeof getGoalSchema, GoalToolDetails> {
162
168
  readonly name = "get_goal";
163
169
  readonly label = "Get Goal";
164
- readonly loadMode = "essential";
170
+ readonly loadMode = "essential" as const;
165
171
  readonly description = prompt.render(getGoalDescription);
166
172
  readonly parameters = getGoalSchema;
167
173
  readonly strict = true;
@@ -191,7 +197,7 @@ export class GetGoalTool implements AgentTool<typeof getGoalSchema, GoalToolDeta
191
197
  export class CreateGoalTool implements AgentTool<typeof createGoalSchema, GoalToolDetails> {
192
198
  readonly name = "create_goal";
193
199
  readonly label = "Create Goal";
194
- readonly loadMode = "essential";
200
+ readonly loadMode = "essential" as const;
195
201
  readonly description = prompt.render(createGoalDescription);
196
202
  readonly parameters = createGoalSchema;
197
203
  readonly strict = true;
@@ -225,7 +231,7 @@ export class CreateGoalTool implements AgentTool<typeof createGoalSchema, GoalTo
225
231
  export class UpdateGoalTool implements AgentTool<typeof updateGoalSchema, GoalToolDetails> {
226
232
  readonly name = "update_goal";
227
233
  readonly label = "Update Goal";
228
- readonly loadMode = "essential";
234
+ readonly loadMode = "essential" as const;
229
235
  readonly description = prompt.render(updateGoalDescription);
230
236
  readonly parameters = updateGoalSchema;
231
237
  readonly strict = true;
@@ -152,6 +152,16 @@ function readTurnId(payload: HookPayload): string | undefined {
152
152
  return safeString(payload.turn_id ?? payload.turnId).trim() || undefined;
153
153
  }
154
154
 
155
+ function readSessionFile(payload: HookPayload): string | undefined {
156
+ return (
157
+ safeString(
158
+ payload.session_file ?? payload.sessionFile ?? payload.transcript_path ?? payload.transcriptPath,
159
+ ).trim() ||
160
+ process.env.GJC_SESSION_FILE?.trim() ||
161
+ undefined
162
+ );
163
+ }
164
+
155
165
  export async function dispatchGjcNativeSkillHook(
156
166
  payload: HookPayload,
157
167
  options: GjcNativeHookDispatchOptions = {},
@@ -180,7 +190,22 @@ export async function dispatchGjcNativeSkillHook(
180
190
  sessionId: readSessionId(payload),
181
191
  threadId: readThreadId(payload),
182
192
  stateDir: options.stateDir,
193
+ prompt,
194
+ sessionFile: readSessionFile(payload),
183
195
  });
196
+ if (activeUltragoalContext?.startsWith("BLOCK_ULTRAGOAL_COMPLETION:")) {
197
+ return {
198
+ hookEventName,
199
+ outputJson: {
200
+ decision: "block",
201
+ reason: activeUltragoalContext,
202
+ hookSpecificOutput: {
203
+ hookEventName,
204
+ additionalContext: activeUltragoalContext,
205
+ },
206
+ },
207
+ };
208
+ }
184
209
  return {
185
210
  hookEventName,
186
211
  outputJson:
@@ -205,6 +230,7 @@ export async function dispatchGjcNativeSkillHook(
205
230
  sessionId: readSessionId(payload),
206
231
  threadId: readThreadId(payload),
207
232
  stateDir: options.stateDir,
233
+ sessionFile: readSessionFile(payload),
208
234
  }),
209
235
  };
210
236
  }
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import type { SkillDiscoverySettings } from "../config/skill-settings-defaults";
4
+ import { isUltragoalBypassPrompt, readUltragoalVerificationState } from "../gjc-runtime/ultragoal-guard";
5
+ import { buildSessionContext, loadEntriesFromFile, type SessionEntry } from "../session/session-manager";
4
6
  import {
5
7
  compareSkillKeywordMatches,
6
8
  GJC_SKILL_KEYWORD_DEFINITIONS,
@@ -124,6 +126,7 @@ export interface StopHookInput {
124
126
  sessionId?: string;
125
127
  threadId?: string;
126
128
  stateDir?: string;
129
+ sessionFile?: string;
127
130
  }
128
131
 
129
132
  export interface UserPromptSubmitStateInput {
@@ -131,6 +134,8 @@ export interface UserPromptSubmitStateInput {
131
134
  sessionId?: string;
132
135
  threadId?: string;
133
136
  stateDir?: string;
137
+ prompt?: string;
138
+ sessionFile?: string;
134
139
  }
135
140
 
136
141
  function escapeRegex(value: string): string {
@@ -361,6 +366,19 @@ function stateMatchesContext(state: ModeState, sessionId?: string, threadId?: st
361
366
  return true;
362
367
  }
363
368
 
369
+ async function readCurrentGoalObjectiveFromSessionFile(sessionFile: string | undefined): Promise<string | null> {
370
+ const trimmed = sessionFile?.trim();
371
+ if (!trimmed) return null;
372
+ const entries = (await loadEntriesFromFile(trimmed)).filter(
373
+ (entry): entry is SessionEntry => entry.type !== "session",
374
+ );
375
+ const context = buildSessionContext(entries);
376
+ const goal = context.modeData?.goal;
377
+ if (typeof goal !== "object" || goal === null) return null;
378
+ const objective = (goal as { objective?: unknown }).objective;
379
+ return typeof objective === "string" && objective.trim().length > 0 ? objective.trim() : null;
380
+ }
381
+
364
382
  export async function buildActiveUltragoalPromptContext(input: UserPromptSubmitStateInput): Promise<string | null> {
365
383
  const visibleModeState = await readVisibleModeState(input.cwd, "ultragoal", input.sessionId, input.stateDir);
366
384
  if (!visibleModeState) return null;
@@ -368,6 +386,22 @@ export async function buildActiveUltragoalPromptContext(input: UserPromptSubmitS
368
386
  if (!stateMatchesContext(visibleModeState.state, input.sessionId, input.threadId)) return null;
369
387
 
370
388
  const phase = String(visibleModeState.state.current_phase ?? "active");
389
+ const objective =
390
+ (await readCurrentGoalObjectiveFromSessionFile(input.sessionFile)) ??
391
+ (typeof visibleModeState.state.objective === "string"
392
+ ? visibleModeState.state.objective
393
+ : typeof visibleModeState.state.gjcObjective === "string"
394
+ ? visibleModeState.state.gjcObjective
395
+ : "");
396
+ if (input.prompt && isUltragoalBypassPrompt(input.prompt) && objective) {
397
+ const diagnostic = await readUltragoalVerificationState({
398
+ cwd: input.cwd,
399
+ currentGoal: { objective },
400
+ });
401
+ if (!["inactive", "unrelated_goal", "active_verified_complete"].includes(diagnostic.state)) {
402
+ return `BLOCK_ULTRAGOAL_COMPLETION: ${diagnostic.message} Use durable blocker work or run strict \`gjc ultragoal checkpoint --status complete --quality-gate-json <file> --gjc-goal-json <file>\` before completion.`;
403
+ }
404
+ }
371
405
  return `Ultragoal is active (phase: ${phase}; state: ${visibleModeState.statePath}). If the user prompt is a steering request, use \`gjc ultragoal steer\` to add or steer subgoals. Normal prose should not mutate Ultragoal state.`;
372
406
  }
373
407
 
@@ -384,6 +418,31 @@ export async function buildSkillStopOutput(input: StopHookInput): Promise<Record
384
418
  if (isTerminalModeState(modeState)) continue;
385
419
  const phase = String(modeState?.current_phase ?? entry.phase ?? skillState.phase ?? "active");
386
420
  const statePath = modeStatePath(resolvedStateDir, entry.skill, input.sessionId);
421
+ if (entry.skill === "ultragoal") {
422
+ const objective =
423
+ (await readCurrentGoalObjectiveFromSessionFile(input.sessionFile)) ??
424
+ (typeof modeState?.objective === "string"
425
+ ? modeState.objective
426
+ : typeof modeState?.gjcObjective === "string"
427
+ ? modeState.gjcObjective
428
+ : "");
429
+ if (objective) {
430
+ const diagnostic = await readUltragoalVerificationState({
431
+ cwd: input.cwd,
432
+ currentGoal: { objective },
433
+ });
434
+ if (diagnostic.state === "active_verified_complete") continue;
435
+ if (!["inactive", "unrelated_goal"].includes(diagnostic.state)) {
436
+ const ultragoalMessage = `GJC ultragoal verification is blocking stop: ${diagnostic.message} Run strict checkpoint verification or record review blockers before stopping.`;
437
+ return {
438
+ decision: "block",
439
+ reason: ultragoalMessage,
440
+ stopReason: `gjc_ultragoal_verification_${diagnostic.state}`,
441
+ systemMessage: ultragoalMessage,
442
+ };
443
+ }
444
+ }
445
+ }
387
446
  const systemMessage = `GJC skill "${entry.skill}" is still active (phase: ${phase}; state: ${statePath}). Continue or explicitly finish/cancel the skill before stopping.`;
388
447
  return {
389
448
  decision: "block",
package/src/main.ts CHANGED
@@ -480,21 +480,6 @@ async function maybeAutoChdir(parsed: Args): Promise<void> {
480
480
  }
481
481
  }
482
482
 
483
- /** Discover SYSTEM.md file if no CLI system prompt was provided */
484
- function discoverSystemPromptFile(): string | undefined {
485
- // Check project-local first (.gjc/SYSTEM.md, .pi/SYSTEM.md legacy)
486
- const projectPath = findConfigFile("SYSTEM.md", { user: false });
487
- if (projectPath) {
488
- return projectPath;
489
- }
490
- // If not found, check SYSTEM.md file in the global directory.
491
- const globalPath = findConfigFile("SYSTEM.md", { user: true });
492
- if (globalPath) {
493
- return globalPath;
494
- }
495
- return undefined;
496
- }
497
-
498
483
  /** Discover APPEND_SYSTEM.md file if no CLI append system prompt was provided */
499
484
  function discoverAppendSystemPromptFile(): string | undefined {
500
485
  const projectPath = findConfigFile("APPEND_SYSTEM.md", { user: false });
@@ -519,8 +504,7 @@ async function buildSessionOptions(
519
504
  cwd: parsed.cwd ?? getProjectDir(),
520
505
  };
521
506
 
522
- // Auto-discover SYSTEM.md if no CLI system prompt provided
523
- const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
507
+ const systemPromptSource = parsed.systemPrompt;
524
508
  const resolvedSystemPrompt = await resolvePromptInput(systemPromptSource, "system prompt");
525
509
  const appendPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
526
510
  const resolvedAppendPrompt = await resolvePromptInput(appendPromptSource, "append system prompt");