@xzzpig/pi-openspec-x 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,14 @@
4
4
  *
5
5
  * `worker-dispatch.ts` decides *whether* a tick is allowed; this module is what
6
6
  * makes the decision binding. It tracks the main session's dispatch traffic —
7
- * an `opsx-worker` dispatch, the `report_work` review that must follow it, and
8
- * the tick — and the `tool_call` handler uses it to block an
9
- * `update_goal_task` completion that has no review behind it.
7
+ * an `opsx-worker` dispatch and the tick — and the `tool_call` handler uses it
8
+ * to block an `update_goal_task` completion that has no accepted worker report
9
+ * behind it.
10
+ *
11
+ * The report itself is NOT the tracker's state any more. It arrives as the
12
+ * persisted ready receipt for the task (design D3/D7), because the report tool
13
+ * runs inside the child session and the parent never sees it as a tool result.
14
+ * The tracker keeps only what the gate needs for presence and stall detection.
10
15
  *
11
16
  * The gate is agent-only on purpose: direct mode has no dispatch, so there is
12
17
  * nothing to reconcile and the completion audit remains the only gate.
@@ -50,7 +55,6 @@ function looksFailed(result: string | undefined): boolean {
50
55
  */
51
56
  export class DispatchReviewTracker {
52
57
  private readonly records: DispatchRecord[] = [];
53
- private readonly reports = new Map<string, WorkReportLike>();
54
58
 
55
59
  /**
56
60
  * Record an `opsx-worker` dispatch. `taskId` is undefined when the payload
@@ -67,11 +71,12 @@ export class DispatchReviewTracker {
67
71
  }
68
72
 
69
73
  /**
70
- * Remember the main agent's `report_work` review and attach its changed-file
71
- * count to the most recent dispatch it can belong to.
74
+ * Attach an accepted worker report to the most recent dispatch it can belong
75
+ * to. Only the changed-file count is retained, for stall detection: the
76
+ * report itself is the gate's input, never the tracker's stored evidence
77
+ * (design D7).
72
78
  */
73
- rememberReport(report: WorkReportLike): void {
74
- this.reports.set(report.taskId, report);
79
+ noteReceipt(report: WorkReportLike): void {
75
80
  for (let index = this.records.length - 1; index >= 0; index -= 1) {
76
81
  const record = this.records[index]!;
77
82
  if (record.taskId !== undefined && record.taskId !== report.taskId) {
@@ -90,11 +95,6 @@ export class DispatchReviewTracker {
90
95
  }
91
96
  }
92
97
 
93
- /** The observed `report_work` for a task, if the main agent submitted one. */
94
- reportFor(taskId: string): WorkReportLike | undefined {
95
- return this.reports.get(taskId);
96
- }
97
-
98
98
  /**
99
99
  * Whether a worker dispatch was observed for the task. A dispatch whose
100
100
  * payload never named a task counts for every task: the alternative is
@@ -123,7 +123,6 @@ export class DispatchReviewTracker {
123
123
 
124
124
  reset(): void {
125
125
  this.records.length = 0;
126
- this.reports.clear();
127
126
  }
128
127
  }
129
128
 
@@ -161,7 +160,7 @@ function filterTaskDelta(paths: readonly string[]): string[] {
161
160
  * The checks run in this order, and each one is unconditional:
162
161
  *
163
162
  * 1. a worker dispatch must have been observed;
164
- * 2. the dispatch must have been reviewed with `report_work`;
163
+ * 2. the dispatch must have produced an accepted worker report;
165
164
  * 3. the recorded verification must have run and passed;
166
165
  * 4. the report's files must reconcile with the window delta — a claimed file
167
166
  * nobody changed is a false claim (hold), while a delta file the report
@@ -175,7 +174,15 @@ function filterTaskDelta(paths: readonly string[]): string[] {
175
174
  */
176
175
  export function evaluateTickGate(
177
176
  tracker: DispatchReviewTracker,
178
- input: { taskId: string; observedChangedFiles?: string[] },
177
+ input: {
178
+ taskId: string;
179
+ /**
180
+ * The persisted ready receipt for this task, when one exists. It comes from
181
+ * the worker's own run, so the parent model never has to transcribe it.
182
+ */
183
+ receipt?: WorkReportLike | undefined;
184
+ observedChangedFiles?: string[];
185
+ },
179
186
  ): TickGateDecision {
180
187
  if (!tracker.dispatched(input.taskId)) {
181
188
  return {
@@ -184,11 +191,11 @@ export function evaluateTickGate(
184
191
  };
185
192
  }
186
193
 
187
- const report = tracker.reportFor(input.taskId);
194
+ const report = input.receipt;
188
195
  if (!report) {
189
196
  return {
190
197
  allow: false,
191
- reason: `The dispatch for task ${input.taskId} has not been reviewed. Call report_work with taskId "${input.taskId}", the changed files you verified, and the verification command and result, then tick.`,
198
+ reason: `The dispatch for task ${input.taskId} has no accepted worker report yet. The report is recorded automatically from the worker's own run once that run finishes successfully; if the worker failed or reported nothing, re-dispatch it for this task.`,
192
199
  };
193
200
  }
194
201
 
package/src/goal-base.ts CHANGED
@@ -33,6 +33,11 @@ import type { GoalTaskLike } from "./task-sync.ts";
33
33
  /** The goal-x context shape `goalStorageRoot` accepts. */
34
34
  export interface GoalStorageContextLike {
35
35
  cwd: string;
36
+ /**
37
+ * Pre-resolved storage root. goal-x returns it verbatim (no settings I/O, no
38
+ * directory creation), which is what the read-only focus facet passes.
39
+ */
40
+ goalStorageRoot?: string;
36
41
  sessionManager?: { getSessionId(): string | undefined };
37
42
  }
38
43
 
@@ -244,6 +249,395 @@ export async function readGoalTaskTree(
244
249
  return undefined;
245
250
  }
246
251
 
252
+ /**
253
+ * goal-x custom entry types the focus facet reads (design D6).
254
+ *
255
+ * A goal's *focus* is a session-level fact carried by a `pi-goal-focus` entry
256
+ * on the current branch — the active-goal summaries cannot answer it, which is
257
+ * why this facet exists instead of reusing `readGoalBaseSummaries`.
258
+ */
259
+ export const GOAL_FOCUS_ENTRY_TYPE = "pi-goal-focus";
260
+ export const GOAL_STATE_ENTRY_TYPE = "pi-goal-state";
261
+
262
+ /** The goal record fields this facet consumes (goal-x owns the real shape). */
263
+ export interface GoalRecordLike {
264
+ id: string;
265
+ status: string;
266
+ objective: string;
267
+ updatedAt?: string;
268
+ activePath?: string;
269
+ taskList?: { tasks?: GoalTaskLike[] } | undefined;
270
+ [key: string]: unknown;
271
+ }
272
+
273
+ /** One persisted focus entry (goal-x's `GoalFocusEntry`). */
274
+ export interface GoalFocusEntryLike {
275
+ version: 1;
276
+ storageRoot?: string;
277
+ focusedGoalId: string | null;
278
+ reason: string;
279
+ }
280
+
281
+ /**
282
+ * The slice of goal-x's published helpers this facet needs. Everything is
283
+ * resolved through goal-x's own modules so the plugin can never drift from the
284
+ * on-disk format; the live `GoalCore` is deliberately never obtained.
285
+ *
286
+ * "Read-only" here means the goal/focus/task domain state is unchanged. The
287
+ * helpers do maintain their own caches and derived pool snapshots, which is
288
+ * allowed (design D6); this package must not create such maintenance itself or
289
+ * write goal files, the goal ledger, focus entries or tasks.
290
+ */
291
+ export interface GoalXFocusFacet {
292
+ goalStorageRoot(ctx: GoalStorageContextLike): string;
293
+ parseGoalFile(filePath: string): GoalRecordLike | null;
294
+ readActiveGoalPoolAsync(
295
+ ctx: GoalStorageContextLike,
296
+ ): Promise<Map<string, GoalRecordLike>>;
297
+ normalizeGoalFocusEntry(value: unknown): GoalFocusEntryLike | null;
298
+ normalizeGoalRecord(value: unknown): GoalRecordLike | null;
299
+ resolveSessionFocus(args: {
300
+ pool: Map<string, GoalRecordLike>;
301
+ focusEntry?: GoalFocusEntryLike | null;
302
+ legacyGoal?: GoalRecordLike | null;
303
+ autoSelectSingleGoal?: boolean;
304
+ }): string | null;
305
+ loadGoalSettings(cwd: string): { autoSelectSingleGoal?: boolean };
306
+ }
307
+
308
+ /** Injectable dynamic-import seam; tests supply a fake focus facet. */
309
+ export type GoalXFocusLoader = () => Promise<GoalXFocusFacet>;
310
+
311
+ const GOAL_RECORD_MODULE: string =
312
+ "@xzzpig/pi-goal-x/extensions/goal-record.ts";
313
+ const GOAL_POOL_MODULE: string = "@xzzpig/pi-goal-x/extensions/goal-pool.ts";
314
+ const GOAL_SETTINGS_MODULE: string =
315
+ "@xzzpig/pi-goal-x/extensions/goal-settings.ts";
316
+
317
+ async function defaultGoalXFocusLoader(): Promise<GoalXFocusFacet> {
318
+ const rootModule = (await import(GOAL_ROOT_MODULE)) as {
319
+ goalStorageRoot: GoalXFocusFacet["goalStorageRoot"];
320
+ };
321
+ const filesModule = (await import(GOAL_FILES_MODULE)) as {
322
+ parseGoalFile: GoalXFocusFacet["parseGoalFile"];
323
+ readActiveGoalPoolAsync: GoalXFocusFacet["readActiveGoalPoolAsync"];
324
+ };
325
+ const recordModule = (await import(GOAL_RECORD_MODULE)) as {
326
+ normalizeGoalFocusEntry: GoalXFocusFacet["normalizeGoalFocusEntry"];
327
+ normalizeGoalRecord: GoalXFocusFacet["normalizeGoalRecord"];
328
+ };
329
+ const poolModule = (await import(GOAL_POOL_MODULE)) as {
330
+ resolveSessionFocus: GoalXFocusFacet["resolveSessionFocus"];
331
+ };
332
+ const settingsModule = (await import(GOAL_SETTINGS_MODULE)) as {
333
+ loadGoalSettings: GoalXFocusFacet["loadGoalSettings"];
334
+ };
335
+ return {
336
+ goalStorageRoot: rootModule.goalStorageRoot,
337
+ parseGoalFile: filesModule.parseGoalFile,
338
+ readActiveGoalPoolAsync: filesModule.readActiveGoalPoolAsync,
339
+ normalizeGoalFocusEntry: recordModule.normalizeGoalFocusEntry,
340
+ normalizeGoalRecord: recordModule.normalizeGoalRecord,
341
+ resolveSessionFocus: poolModule.resolveSessionFocus,
342
+ loadGoalSettings: settingsModule.loadGoalSettings,
343
+ };
344
+ }
345
+
346
+ /** Generation token: re-reading after any of these change invalidates a decision. */
347
+ export interface GoalFocusToken {
348
+ sessionId: string | null;
349
+ branchGeneration: string | null;
350
+ storageRoot: string | null;
351
+ focusEntryId: string | null;
352
+ goalId: string | null;
353
+ }
354
+
355
+ /** One real goal tool result observed on this branch (v3 details). */
356
+ export interface RecentGoalToolRecord {
357
+ goalId: string;
358
+ record: GoalRecordLike;
359
+ observedAt: string;
360
+ toolCallId: string;
361
+ }
362
+
363
+ /** The authoritative answer to "which goal is focused, and what is its state". */
364
+ export interface FocusedGoalSnapshot {
365
+ state: "known-focused" | "known-unfocused" | "unknown";
366
+ goalId?: string;
367
+ record?: GoalRecordLike;
368
+ activePath?: string;
369
+ token: GoalFocusToken;
370
+ reason?: string;
371
+ }
372
+
373
+ /** Session shape this facet reads (a read-only subset of the host manager). */
374
+ export interface FocusSessionLike {
375
+ getSessionId(): string | undefined;
376
+ getBranch(): readonly unknown[];
377
+ getLeafId(): string | null;
378
+ }
379
+
380
+ export interface FocusReadContext extends GoalStorageContextLike {
381
+ sessionManager?: FocusSessionLike;
382
+ }
383
+
384
+ function tokenOf(
385
+ ctx: FocusReadContext,
386
+ storageRoot: string | null,
387
+ focusEntryId: string | null,
388
+ goalId: string | null,
389
+ ): GoalFocusToken {
390
+ return {
391
+ sessionId: ctx.sessionManager?.getSessionId() ?? null,
392
+ branchGeneration: ctx.sessionManager?.getLeafId() ?? null,
393
+ storageRoot,
394
+ focusEntryId,
395
+ goalId,
396
+ };
397
+ }
398
+
399
+ function unknownSnapshot(
400
+ ctx: FocusReadContext,
401
+ reason: string,
402
+ storageRoot: string | null = null,
403
+ ): FocusedGoalSnapshot {
404
+ return {
405
+ state: "unknown",
406
+ token: tokenOf(ctx, storageRoot, null, null),
407
+ reason,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Resolve a goal's recorded `activePath` to a real file path under the
413
+ * configured storage root. goal-x records the logical `.pi/goals/<file>` form,
414
+ * so a bare absolute parse would miss the file. Paths that escape the root are
415
+ * refused rather than followed.
416
+ */
417
+ function resolveActivePath(
418
+ storageRoot: string,
419
+ activePath: string,
420
+ ): string | undefined {
421
+ if (path.isAbsolute(activePath)) {
422
+ return isPathWithinRootSync(activePath, storageRoot)
423
+ ? activePath
424
+ : undefined;
425
+ }
426
+ const logical = activePath.replace(/\\/g, "/");
427
+ const suffix = logical.startsWith(".pi/goals/")
428
+ ? logical.slice(".pi/goals/".length)
429
+ : logical;
430
+ const resolved = path.resolve(storageRoot, suffix);
431
+ return isPathWithinRootSync(resolved, storageRoot) ? resolved : undefined;
432
+ }
433
+
434
+ function isPathWithinRootSync(child: string, root: string): boolean {
435
+ const relative = path.relative(root, child);
436
+ return (
437
+ relative === "" ||
438
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
439
+ );
440
+ }
441
+
442
+ /**
443
+ * The record with the newest `updatedAt`; on a tie the branch's newer real tool
444
+ * result wins, because it covers task changes the file has not flushed yet.
445
+ */
446
+ function mergeGoalRecords(
447
+ fileRecord: GoalRecordLike | null,
448
+ toolRecord: RecentGoalToolRecord | undefined,
449
+ ): GoalRecordLike | null {
450
+ if (!toolRecord) return fileRecord;
451
+ if (!fileRecord) return toolRecord.record;
452
+ if (toolRecord.goalId !== fileRecord.id) return fileRecord;
453
+ const fileAt = fileRecord.updatedAt ?? "";
454
+ const toolAt = toolRecord.record.updatedAt ?? "";
455
+ return toolAt >= fileAt ? toolRecord.record : fileRecord;
456
+ }
457
+
458
+ /**
459
+ * Read the authoritative focus snapshot (design D6).
460
+ *
461
+ * Mirrors `goal-state.ts::loadState`: the latest valid focus entry and legacy
462
+ * state entry on the current branch, a storage-root check against the
463
+ * configured root, and single-goal auto-selection only when the setting is
464
+ * explicitly true. An explicit `null` focus never falls back to another goal.
465
+ *
466
+ * Returns `unknown` — never a guess — when the capability is missing, the
467
+ * format is unrecognized, or the record cannot be read.
468
+ */
469
+ export async function readFocusedGoalSnapshot(
470
+ ctx: FocusReadContext,
471
+ options: {
472
+ loader?: GoalXFocusLoader;
473
+ recentToolRecord?: RecentGoalToolRecord | undefined;
474
+ } = {},
475
+ ): Promise<FocusedGoalSnapshot> {
476
+ const loader = options.loader ?? defaultGoalXFocusLoader;
477
+ let facet: GoalXFocusFacet;
478
+ try {
479
+ facet = await loader();
480
+ } catch (error) {
481
+ return unknownSnapshot(
482
+ ctx,
483
+ `goal-x focus support is unavailable: ${
484
+ error instanceof Error ? error.message : String(error)
485
+ }`,
486
+ );
487
+ }
488
+
489
+ let storageRoot: string;
490
+ try {
491
+ storageRoot = facet.goalStorageRoot(ctx);
492
+ } catch (error) {
493
+ return unknownSnapshot(
494
+ ctx,
495
+ `the goal storage root could not be resolved: ${
496
+ error instanceof Error ? error.message : String(error)
497
+ }`,
498
+ );
499
+ }
500
+
501
+ // Latest valid focus entry and legacy state entry on the current branch.
502
+ let focusEntryId: string | null = null;
503
+ let focusEntry: GoalFocusEntryLike | null = null;
504
+ let legacyGoal: GoalRecordLike | null = null;
505
+ let legacySeen = false;
506
+ const branch = ctx.sessionManager?.getBranch() ?? [];
507
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
508
+ const entry = branch[index] as {
509
+ type?: string;
510
+ id?: string;
511
+ customType?: string;
512
+ data?: unknown;
513
+ };
514
+ if (!entry || entry.type !== "custom") continue;
515
+ if (!focusEntry && entry.customType === GOAL_FOCUS_ENTRY_TYPE) {
516
+ const normalized = facet.normalizeGoalFocusEntry(entry.data);
517
+ if (normalized) {
518
+ focusEntry = normalized;
519
+ focusEntryId = typeof entry.id === "string" ? entry.id : null;
520
+ }
521
+ }
522
+ if (!legacySeen && entry.customType === GOAL_STATE_ENTRY_TYPE) {
523
+ const data = entry.data as { goal?: unknown } | undefined;
524
+ legacyGoal = facet.normalizeGoalRecord(data?.goal);
525
+ legacySeen = true;
526
+ }
527
+ if (focusEntry && legacySeen) break;
528
+ }
529
+
530
+ const defaultRoot = path.resolve(ctx.cwd, ".pi/goals");
531
+ if (focusEntry && (focusEntry.storageRoot ?? defaultRoot) !== storageRoot) {
532
+ // A focus entry recorded against a different storage root never applies
533
+ // here; it does not silently re-target another goal either.
534
+ focusEntry = { ...focusEntry, focusedGoalId: null };
535
+ legacyGoal = null;
536
+ }
537
+ if (storageRoot !== defaultRoot) legacyGoal = null;
538
+
539
+ let pool: Map<string, GoalRecordLike>;
540
+ try {
541
+ pool = await facet.readActiveGoalPoolAsync(ctx);
542
+ } catch (error) {
543
+ return unknownSnapshot(
544
+ ctx,
545
+ `the active goal pool could not be read: ${
546
+ error instanceof Error ? error.message : String(error)
547
+ }`,
548
+ storageRoot,
549
+ );
550
+ }
551
+
552
+ let autoSelectSingleGoal = false;
553
+ try {
554
+ autoSelectSingleGoal =
555
+ facet.loadGoalSettings(ctx.cwd).autoSelectSingleGoal === true;
556
+ } catch {
557
+ // A settings failure must not widen the search; keep the strict default.
558
+ autoSelectSingleGoal = false;
559
+ }
560
+
561
+ let goalId: string | null;
562
+ try {
563
+ goalId = facet.resolveSessionFocus({
564
+ pool,
565
+ focusEntry,
566
+ legacyGoal,
567
+ autoSelectSingleGoal,
568
+ });
569
+ } catch (error) {
570
+ return unknownSnapshot(
571
+ ctx,
572
+ `the focused goal could not be resolved: ${
573
+ error instanceof Error ? error.message : String(error)
574
+ }`,
575
+ storageRoot,
576
+ );
577
+ }
578
+
579
+ if (!goalId) {
580
+ return {
581
+ state: "known-unfocused",
582
+ token: tokenOf(ctx, storageRoot, focusEntryId, null),
583
+ };
584
+ }
585
+
586
+ // The record itself comes from the canonical goal file, located by the known
587
+ // path — not from a summary and not by scanning the directory.
588
+ const poolRecord = pool.get(goalId) ?? null;
589
+ let fileRecord: GoalRecordLike | null = poolRecord;
590
+ let canonicalPath: string | undefined;
591
+ if (poolRecord?.activePath) {
592
+ canonicalPath = resolveActivePath(storageRoot, poolRecord.activePath);
593
+ }
594
+ if (canonicalPath) {
595
+ try {
596
+ fileRecord = facet.parseGoalFile(canonicalPath) ?? poolRecord;
597
+ } catch {
598
+ fileRecord = poolRecord;
599
+ }
600
+ }
601
+ const record = mergeGoalRecords(fileRecord, options.recentToolRecord);
602
+ if (!record) {
603
+ return unknownSnapshot(
604
+ ctx,
605
+ `the focused goal ${goalId} has no readable record`,
606
+ storageRoot,
607
+ );
608
+ }
609
+
610
+ const activePath = canonicalPath ?? record.activePath;
611
+ return {
612
+ state: "known-focused",
613
+ goalId,
614
+ record,
615
+ ...(activePath ? { activePath } : {}),
616
+ token: tokenOf(ctx, storageRoot, focusEntryId, goalId),
617
+ };
618
+ }
619
+
620
+ /**
621
+ * Refresh one focused goal's record by its known path, without scanning the
622
+ * storage root. `undefined` means the record is no longer readable, which the
623
+ * caller must treat as "cannot prove the state" rather than "unchanged".
624
+ */
625
+ export function refreshGoalRecordByPath(
626
+ activePath: string,
627
+ loader: GoalXFocusLoader = defaultGoalXFocusLoader,
628
+ ): Promise<GoalRecordLike | undefined> {
629
+ return loader().then(
630
+ (facet) => {
631
+ try {
632
+ return facet.parseGoalFile(activePath) ?? undefined;
633
+ } catch {
634
+ return undefined;
635
+ }
636
+ },
637
+ () => undefined,
638
+ );
639
+ }
640
+
247
641
  export interface RecoveryHandlerDeps {
248
642
  /** Injectable storage loader (tests); defaults to the real goal-x storage. */
249
643
  loadGoalXStorage?: GoalXStorageLoader;