@gajae-code/coding-agent 0.12.2 → 0.12.4

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,4 +1,5 @@
1
1
  import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { getConfigRootDir } from "@gajae-code/utils";
4
5
  import type { WorkflowHudSummary } from "../skill-state/active-state";
@@ -1794,27 +1795,46 @@ export function hasTypedVerifiedReceipt(value: unknown): boolean {
1794
1795
  return Boolean(type && id && (status === "verified" || status === "passed"));
1795
1796
  }
1796
1797
 
1797
- export async function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise<boolean> {
1798
+ async function resolveExistingArtifactPathUnderCwd(
1799
+ cwd: string,
1800
+ value: unknown,
1801
+ fieldName: string,
1802
+ ): Promise<string | null> {
1798
1803
  const artifactPath = nonEmptyString(value);
1799
- if (!artifactPath) return false;
1800
- const resolved = path.resolve(cwd, artifactPath);
1804
+ if (!artifactPath) return null;
1805
+ const lexicalRoot = path.resolve(cwd);
1806
+ const lexical = path.resolve(lexicalRoot, artifactPath);
1807
+ const lexicalRelative = path.relative(lexicalRoot, lexical);
1808
+ if (lexicalRelative === ".." || lexicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(lexicalRelative)) {
1809
+ throw new Error(`qualityGate ${fieldName} artifact path must resolve under the repository cwd`);
1810
+ }
1801
1811
  try {
1802
- const file = Bun.file(resolved);
1803
- return (await file.exists()) && file.size > 0;
1812
+ const [root, resolved] = await Promise.all([fs.realpath(lexicalRoot), fs.realpath(lexical)]);
1813
+ const relative = path.relative(root, resolved);
1814
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1815
+ throw new Error(`qualityGate ${fieldName} artifact path must not escape the repository cwd through symlinks`);
1816
+ }
1817
+ if (!(await fs.stat(resolved)).isFile()) {
1818
+ throw new Error(`qualityGate ${fieldName} artifact path must reference a regular file`);
1819
+ }
1820
+ return resolved;
1804
1821
  } catch (error) {
1805
- if (isEnoent(error)) return false;
1822
+ if (isEnoent(error)) return null;
1806
1823
  throw error;
1807
1824
  }
1808
1825
  }
1809
1826
 
1827
+ export async function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise<boolean> {
1828
+ const resolved = await resolveExistingArtifactPathUnderCwd(cwd, value, "artifact");
1829
+ if (!resolved) return false;
1830
+ return (await fs.stat(resolved)).size > 0;
1831
+ }
1832
+
1810
1833
  export async function readArtifactBytes(cwd: string, row: JsonObject, fieldName: string): Promise<Buffer | null> {
1811
- const artifactPath = nonEmptyString(row.path);
1812
- if (!artifactPath) return null;
1813
- const resolved = path.resolve(cwd, artifactPath);
1834
+ const resolved = await resolveExistingArtifactPathUnderCwd(cwd, row.path, fieldName);
1835
+ if (!resolved) return null;
1814
1836
  try {
1815
- const file = Bun.file(resolved);
1816
- if (!(await file.exists())) return null;
1817
- return Buffer.from(await file.arrayBuffer());
1837
+ return Buffer.from(await Bun.file(resolved).arrayBuffer());
1818
1838
  } catch (error) {
1819
1839
  if (isEnoent(error)) return null;
1820
1840
  throw new Error(`qualityGate ${fieldName} artifact could not be read: ${String(error)}`);
@@ -1823,6 +1843,7 @@ export async function readArtifactBytes(cwd: string, row: JsonObject, fieldName:
1823
1843
 
1824
1844
  import {
1825
1845
  readCliReplayRecord,
1846
+ resolveCliReplayCommand,
1826
1847
  validateArtifactProof,
1827
1848
  validateCliReplay,
1828
1849
  validateLiveSurfaceProofPresence,
@@ -1834,6 +1855,7 @@ import {
1834
1855
 
1835
1856
  export type { ReplayProcessHandle } from "./ultragoal-evidence";
1836
1857
  export {
1858
+ resolveCliReplayCommand,
1837
1859
  validateArtifactProof,
1838
1860
  validateCliReplay,
1839
1861
  validateLiveSurfaceProofPresence,
@@ -2230,6 +2252,169 @@ function validateDeferredLaneDeclaration(deferred: JsonObject, fieldName: string
2230
2252
  }
2231
2253
  }
2232
2254
 
2255
+ /**
2256
+ * Agent-friendly deferred-gate hydration: the runtime already stores the durable
2257
+ * batch tuple and computes the cumulative change set itself, so a deferred gate
2258
+ * only has to prove what the runtime cannot know — that targeted verification ran.
2259
+ * Every mechanical field (`kind`, the batch tuple, `deferredLanes`, and the whole
2260
+ * `changeSet` block including `paths` and `changeSetHash`) is auto-filled when
2261
+ * omitted. Explicitly supplied values are never overwritten, so a wrong
2262
+ * declaration still fails closed.
2263
+ */
2264
+ function hydrateDeferredGateDefaults(
2265
+ gate: JsonObject,
2266
+ goal: UltragoalGoal,
2267
+ changeSet: UltragoalChangeSet | undefined,
2268
+ ): JsonObject {
2269
+ const deferred = qualityGateObject(gate.deferredToBatch);
2270
+ if (!deferred) return gate;
2271
+ const metadata = goal.validationBatch;
2272
+ // The final member must carry the full strict gate; never help it defer.
2273
+ if (metadata && goal.id === metadata.finalGoalId) return gate;
2274
+ const hydrated: JsonObject = { ...deferred };
2275
+ if (hydrated.kind === undefined) hydrated.kind = "validation-batch-deferred";
2276
+ if (metadata) {
2277
+ if (hydrated.schemaVersion === undefined) hydrated.schemaVersion = metadata.schemaVersion;
2278
+ if (hydrated.batchId === undefined) hydrated.batchId = metadata.batchId;
2279
+ if (hydrated.memberIds === undefined) hydrated.memberIds = [...metadata.memberIds];
2280
+ if (hydrated.finalGoalId === undefined) hydrated.finalGoalId = metadata.finalGoalId;
2281
+ if (hydrated.metadataHash === undefined) hydrated.metadataHash = metadata.metadataHash;
2282
+ }
2283
+ if (hydrated.deferredLanes === undefined) hydrated.deferredLanes = ["architectReview", "executorQa"];
2284
+ const computedRows = (changeSet?.paths ?? []).map(row => ({
2285
+ path: row.path,
2286
+ status: row.status,
2287
+ ...(row.oldPath ? { oldPath: row.oldPath } : {}),
2288
+ }));
2289
+ const computedByPath = new Map(computedRows.map(row => [row.path, row]));
2290
+ const declared = qualityGateObject(hydrated.changeSet);
2291
+ const changeSetRecord: JsonObject = declared ? { ...declared } : {};
2292
+ if (changeSetRecord.memberGoalId === undefined) changeSetRecord.memberGoalId = goal.id;
2293
+ if (changeSetRecord.cumulativeFromBase === undefined) changeSetRecord.cumulativeFromBase = true;
2294
+ if (changeSetRecord.paths === undefined) {
2295
+ changeSetRecord.paths = computedRows;
2296
+ } else if (Array.isArray(changeSetRecord.paths)) {
2297
+ // Accept plain-string rows and rows without a status; resolve the status
2298
+ // from the computed change set instead of demanding git trivia.
2299
+ changeSetRecord.paths = changeSetRecord.paths.map(row => {
2300
+ const record = typeof row === "string" ? { path: row } : qualityGateObject(row);
2301
+ if (!record) return row;
2302
+ const pathValue = nonEmptyString(record.path);
2303
+ if (!pathValue || nonEmptyString(record.status)) return record;
2304
+ const computed = computedByPath.get(normalizeRepoPath(pathValue));
2305
+ return {
2306
+ ...record,
2307
+ status: computed?.status ?? "unknown",
2308
+ ...(computed?.oldPath && record.oldPath === undefined ? { oldPath: computed.oldPath } : {}),
2309
+ };
2310
+ });
2311
+ }
2312
+ if (changeSetRecord.changeSetHash === undefined) {
2313
+ try {
2314
+ changeSetRecord.changeSetHash = changeSetHashForPaths(
2315
+ canonicalChangeSetRows(changeSetRecord.paths, "deferredToBatch.changeSet.paths"),
2316
+ );
2317
+ } catch {
2318
+ // Malformed rows: leave the hash unset so validation reports the row defect.
2319
+ }
2320
+ }
2321
+ hydrated.changeSet = changeSetRecord;
2322
+ return { ...gate, deferredToBatch: hydrated };
2323
+ }
2324
+
2325
+ /**
2326
+ * Agent-friendly batch-close hydration, mirroring `hydrateDeferredGateDefaults`:
2327
+ * every `validationBatchClose` field except `coverageEvidence` is derivable from
2328
+ * durable state (batch metadata, member receipts) and the computed cumulative
2329
+ * change set, so it is auto-filled when omitted. Supplied values are never
2330
+ * overwritten and still fail closed when wrong. The minimal close is the full
2331
+ * strict gate plus `{"validationBatchClose":{"coverageEvidence":"..."}}`.
2332
+ */
2333
+ function hydrateBatchCloseDefaults(input: {
2334
+ gate: JsonObject;
2335
+ plan: UltragoalPlan;
2336
+ goal: UltragoalGoal;
2337
+ ledger: readonly UltragoalLedgerEvent[];
2338
+ changeSet?: UltragoalChangeSet;
2339
+ }): JsonObject {
2340
+ const metadata = input.goal.validationBatch;
2341
+ if (!metadata || input.goal.id !== metadata.finalGoalId) return input.gate;
2342
+ const requested = qualityGateObject(input.gate.validationBatchClose);
2343
+ if (!requested) return input.gate;
2344
+ // The replacement-close kind has its own dedicated hydrator.
2345
+ if (requested.kind === "review-blocker-replacement-close") return input.gate;
2346
+ const close: JsonObject = { ...requested };
2347
+ if (close.schemaVersion === undefined) close.schemaVersion = 1;
2348
+ if (close.kind === undefined) close.kind = "validation-batch-close";
2349
+ if (close.batchId === undefined) close.batchId = metadata.batchId;
2350
+ if (close.finalGoalId === undefined) close.finalGoalId = metadata.finalGoalId;
2351
+ if (close.memberIds === undefined) close.memberIds = [...metadata.memberIds];
2352
+ const derivedMetadataHashes: Record<string, string> = {};
2353
+ const derivedChangeSetHashes: Record<string, string> = {};
2354
+ const derivedReceipts: JsonObject[] = [];
2355
+ for (const memberId of metadata.memberIds) {
2356
+ const member = input.plan.goals.find(goal => goal.id === memberId);
2357
+ if (member?.validationBatch) derivedMetadataHashes[memberId] = member.validationBatch.metadataHash;
2358
+ if (!member || memberId === input.goal.id) continue;
2359
+ try {
2360
+ const receipt = requireDeferredMemberReceiptFresh(
2361
+ input.plan,
2362
+ input.ledger,
2363
+ member,
2364
+ "validationBatchClose hydration",
2365
+ );
2366
+ derivedChangeSetHashes[memberId] = receipt.validationBatch.changeSetHash;
2367
+ derivedReceipts.push({
2368
+ goalId: memberId,
2369
+ receiptId: receipt.receiptId,
2370
+ checkpointLedgerEventId: receipt.checkpointLedgerEventId,
2371
+ qualityGateHash: receipt.qualityGateHash,
2372
+ changeSetHash: receipt.validationBatch.changeSetHash,
2373
+ role: "deferred-member",
2374
+ });
2375
+ } catch {
2376
+ // Member not fresh/complete: omit it so validation reports the real defect.
2377
+ }
2378
+ }
2379
+ const suppliedMetadataHashes = qualityGateObject(close.memberMetadataHashes);
2380
+ if (close.memberMetadataHashes === undefined) close.memberMetadataHashes = derivedMetadataHashes;
2381
+ else if (suppliedMetadataHashes)
2382
+ close.memberMetadataHashes = { ...derivedMetadataHashes, ...suppliedMetadataHashes };
2383
+ if (close.memberReceipts === undefined) close.memberReceipts = derivedReceipts;
2384
+ const requestedUnion = qualityGateObject(close.unionChangeSet);
2385
+ const union: JsonObject = requestedUnion ? { ...requestedUnion } : {};
2386
+ if (union.source === undefined) union.source = "validation-batch";
2387
+ if (union.paths === undefined) {
2388
+ union.paths = (input.changeSet?.paths ?? []).map(row => ({
2389
+ path: row.path,
2390
+ status: row.status,
2391
+ ...(row.oldPath ? { oldPath: row.oldPath } : {}),
2392
+ }));
2393
+ }
2394
+ try {
2395
+ const unionRows = canonicalChangeSetRows(union.paths, "validationBatchClose.unionChangeSet.paths");
2396
+ const suppliedChangeSetHashes = qualityGateObject(union.memberChangeSetHashes);
2397
+ const memberChangeSetHashes: Record<string, unknown> = {
2398
+ ...derivedChangeSetHashes,
2399
+ [metadata.finalGoalId]: changeSetHashForPaths(unionRows),
2400
+ ...suppliedChangeSetHashes,
2401
+ };
2402
+ if (union.memberChangeSetHashes === undefined || suppliedChangeSetHashes) {
2403
+ union.memberChangeSetHashes = memberChangeSetHashes;
2404
+ }
2405
+ if (union.unionHash === undefined) {
2406
+ union.unionHash = hashStructuredValue({
2407
+ memberChangeSetHashes: qualityGateObject(union.memberChangeSetHashes) ?? memberChangeSetHashes,
2408
+ paths: unionRows.map(row => ({ path: row.path, status: row.status, oldPath: row.oldPath })),
2409
+ });
2410
+ }
2411
+ } catch {
2412
+ // Malformed rows: leave derived hashes unset so validation reports the row defect.
2413
+ }
2414
+ close.unionChangeSet = union;
2415
+ return { ...input.gate, validationBatchClose: close };
2416
+ }
2417
+
2233
2418
  function validateDeferredCompletionQualityGate(
2234
2419
  gate: JsonObject,
2235
2420
  goal: UltragoalGoal,
@@ -2251,7 +2436,9 @@ function validateDeferredCompletionQualityGate(
2251
2436
  }
2252
2437
  const deferredLanes = stringArray(deferred.deferredLanes)?.filter(Boolean).sort();
2253
2438
  if (deferredLanes?.join(",") !== "architectReview,executorQa")
2254
- throw new Error("deferredToBatch.deferredLanes must be architectReview and executorQa");
2439
+ throw new Error(
2440
+ "deferredToBatch.deferredLanes must be architectReview and executorQa (or omitted; the runtime fills it)",
2441
+ );
2255
2442
  const targeted = qualityGateObject(deferred.targetedVerification);
2256
2443
  if (!targeted || targeted.status !== PASSED_STATUS || !nonEmptyStringArray(targeted.commands))
2257
2444
  throw new Error("deferredToBatch.targetedVerification must pass with non-empty commands");
@@ -2276,13 +2463,17 @@ function validateDeferredCompletionQualityGate(
2276
2463
  const declaredChangeSet = qualityGateObject(deferred.changeSet);
2277
2464
  if (!declaredChangeSet) throw new Error("deferredToBatch.changeSet is required");
2278
2465
  if (declaredChangeSet.memberGoalId !== goal.id)
2279
- throw new Error("deferredToBatch.changeSet.memberGoalId must label the checkpointed goal");
2466
+ throw new Error(
2467
+ `deferredToBatch.changeSet.memberGoalId must label the checkpointed goal ${goal.id} (or be omitted; the runtime fills it)`,
2468
+ );
2280
2469
  if (declaredChangeSet.cumulativeFromBase !== true)
2281
- throw new Error("deferredToBatch.changeSet.cumulativeFromBase must be true");
2470
+ throw new Error("deferredToBatch.changeSet.cumulativeFromBase must be true (or omitted; the runtime fills it)");
2282
2471
  const paths = canonicalChangeSetRows(declaredChangeSet.paths, "deferredToBatch.changeSet.paths");
2283
2472
  requireChangeSetCoverage(changeSet, paths, "deferredToBatch.changeSet.paths");
2284
2473
  if (declaredChangeSet.changeSetHash !== changeSetHashForPaths(paths))
2285
- throw new Error("deferredToBatch.changeSet.changeSetHash does not match declared paths");
2474
+ throw new Error(
2475
+ "deferredToBatch.changeSet.changeSetHash does not match declared paths; omit changeSetHash and the runtime computes it",
2476
+ );
2286
2477
  }
2287
2478
  const COHORT_LANE_KEYS = ["cleaner", "architect", "qa"] as const;
2288
2479
 
@@ -2983,8 +3174,17 @@ export async function validateUltragoalQualityGateReadOnly(input: {
2983
3174
  }
2984
3175
  const ledger = plan ? await readUltragoalLedger(input.cwd, sessionId) : undefined;
2985
3176
  const changeSet = await computeCheckpointChangeSet(input.cwd);
3177
+ let hydratedGate = goal ? hydrateDeferredGateDefaults(gate, goal, changeSet) : gate;
3178
+ if (goal && plan && ledger) {
3179
+ hydratedGate = hydrateBatchCloseDefaults({ gate: hydratedGate, plan, goal, ledger, changeSet });
3180
+ }
2986
3181
  try {
2987
- await validateCompletionQualityGate(input.cwd, gate, { changeSet, plan: plan ?? undefined, goal, ledger });
3182
+ await validateCompletionQualityGate(input.cwd, hydratedGate, {
3183
+ changeSet,
3184
+ plan: plan ?? undefined,
3185
+ goal,
3186
+ ledger,
3187
+ });
2988
3188
  return { valid: true, errors: [] };
2989
3189
  } catch (error) {
2990
3190
  if (error instanceof UltragoalQualityGateError) return { valid: false, errors: error.diagnostics };
@@ -3031,13 +3231,25 @@ async function readRequiredCompletionQualityGate(
3031
3231
  changeSet: options.changeSet,
3032
3232
  })
3033
3233
  : gateObject;
3034
- await validateCompletionQualityGate(cwd, hydratedGate, {
3234
+ let completionGate = options.goal
3235
+ ? hydrateDeferredGateDefaults(hydratedGate, options.goal, options.changeSet)
3236
+ : hydratedGate;
3237
+ if (options.goal && options.plan && options.ledger) {
3238
+ completionGate = hydrateBatchCloseDefaults({
3239
+ gate: completionGate,
3240
+ plan: options.plan,
3241
+ goal: options.goal,
3242
+ ledger: options.ledger,
3243
+ changeSet: options.changeSet,
3244
+ });
3245
+ }
3246
+ await validateCompletionQualityGate(cwd, completionGate, {
3035
3247
  changeSet: options.changeSet,
3036
3248
  plan: options.plan,
3037
3249
  goal: options.goal,
3038
3250
  ledger: options.ledger,
3039
3251
  });
3040
- return hydratedGate;
3252
+ return completionGate;
3041
3253
  }
3042
3254
 
3043
3255
  function validateCompleteCheckpointTargetGoal(goal: UltragoalGoal): void {