@abelxiaoxing/cadence 1.0.1 → 1.0.3

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/src/scheduler.ts CHANGED
@@ -34,6 +34,16 @@ export interface SchedulerOptions<T = unknown> {
34
34
  ): Promise<T>;
35
35
  }
36
36
 
37
+ export interface ConflictDeclaration {
38
+ taskId: string;
39
+ requestId?: string;
40
+ read: string[];
41
+ write: string[];
42
+ conflicts: string[];
43
+ resources: string[];
44
+ verificationLocks: string[];
45
+ }
46
+
37
47
  type EntryState = "queued" | "running" | TerminalStatus;
38
48
 
39
49
  interface Entry<T> {
@@ -53,34 +63,63 @@ function intersects(left: string[], right: string[]): boolean {
53
63
  return left.some((path) => right.includes(path));
54
64
  }
55
65
 
66
+ function pathsOverlap(left: string, right: string): boolean {
67
+ return (
68
+ left === "." ||
69
+ right === "." ||
70
+ left === right ||
71
+ left.startsWith(`${right}/`) ||
72
+ right.startsWith(`${left}/`)
73
+ );
74
+ }
75
+
76
+ function pathSetsOverlap(left: string[], right: string[]): boolean {
77
+ return left.some((a) => right.some((b) => pathsOverlap(a, b)));
78
+ }
79
+
56
80
  function conflictEdgeMatches(
57
81
  conflicts: string[],
58
- other: RequestEnvelope,
82
+ other: ConflictDeclaration,
59
83
  ): boolean {
60
84
  return conflicts.some(
61
85
  (edge) =>
62
- edge === other.id ||
63
- other.declared.read.includes(edge) ||
64
- other.declared.write.includes(edge),
86
+ edge === other.taskId ||
87
+ edge === other.requestId ||
88
+ other.read.some((path) => pathsOverlap(edge, path)) ||
89
+ other.write.some((path) => pathsOverlap(edge, path)),
65
90
  );
66
91
  }
67
92
 
68
- /** True when two declared envelopes cannot be active at the same time. */
93
+ function requestConflictOf(request: RequestEnvelope): ConflictDeclaration {
94
+ return {
95
+ taskId: request.taskId ?? request.id,
96
+ requestId: request.id,
97
+ read: request.declared.read,
98
+ write: request.declared.write,
99
+ conflicts: request.declared.conflicts,
100
+ resources: request.declared.resources,
101
+ verificationLocks: request.declared.verificationLock
102
+ ? [request.declared.verificationLock]
103
+ : [],
104
+ };
105
+ }
106
+
107
+ /** True when two pure declarations cannot be active at the same time. */
69
108
  export function declarationsConflict(
70
- left: RequestEnvelope,
71
- right: RequestEnvelope,
109
+ left: ConflictDeclaration,
110
+ right: ConflictDeclaration,
72
111
  ): boolean {
73
- const a = left.declared;
74
- const b = right.declared;
75
- if (intersects(a.write, b.write)) return true;
76
- if (intersects(a.write, b.read) || intersects(a.read, b.write)) return true;
112
+ const a = left;
113
+ const b = right;
114
+ if (a.taskId === b.taskId) return true;
115
+ if (pathSetsOverlap(a.write, b.write)) return true;
116
+ if (pathSetsOverlap(a.write, b.read) || pathSetsOverlap(a.read, b.write)) {
117
+ return true;
118
+ }
77
119
  if (conflictEdgeMatches(a.conflicts, right)) return true;
78
120
  if (conflictEdgeMatches(b.conflicts, left)) return true;
79
121
  if (intersects(a.resources, b.resources)) return true;
80
- return (
81
- a.verificationLock !== undefined &&
82
- a.verificationLock === b.verificationLock
83
- );
122
+ return intersects(a.verificationLocks, b.verificationLocks);
84
123
  }
85
124
 
86
125
  function errorMessage(error: unknown): string {
@@ -234,7 +273,12 @@ export class Scheduler<T> {
234
273
 
235
274
  private hasActiveConflict(entry: Entry<T>): boolean {
236
275
  for (const activeEntry of this.active) {
237
- if (declarationsConflict(entry.request, activeEntry.request)) {
276
+ if (
277
+ declarationsConflict(
278
+ requestConflictOf(entry.request),
279
+ requestConflictOf(activeEntry.request),
280
+ )
281
+ ) {
238
282
  return true;
239
283
  }
240
284
  }
@@ -31,6 +31,7 @@ export interface ScopedToolDef {
31
31
 
32
32
  interface Options {
33
33
  roots: string[];
34
+ allowedPaths?: string[];
34
35
  observer?: (obs: Observation) => void;
35
36
  }
36
37
 
@@ -38,7 +39,11 @@ type PathResult =
38
39
  | { ok: true; abs: string; rel: string }
39
40
  | { ok: false; error: string };
40
41
 
41
- function resolveScoped(roots: string[], input: unknown): PathResult {
42
+ function resolveScoped(
43
+ roots: string[],
44
+ input: unknown,
45
+ allowedPaths?: string[],
46
+ ): PathResult {
42
47
  if (typeof input !== "string" || input.length === 0) {
43
48
  return { ok: false, error: "missing path" };
44
49
  }
@@ -54,11 +59,21 @@ function resolveScoped(roots: string[], input: unknown): PathResult {
54
59
  if (input.startsWith("./") || input.includes("//") || input.endsWith("/.")) {
55
60
  return { ok: false, error: "noncanonical path rejected" };
56
61
  }
62
+ let insideRoot = false;
57
63
  for (const root of roots) {
58
64
  const abs = resolve(root, input);
59
65
  const inside =
60
66
  abs === root || abs.startsWith(root.endsWith("/") ? root : `${root}/`);
61
67
  if (!inside) continue;
68
+ insideRoot = true;
69
+ if (
70
+ allowedPaths &&
71
+ !allowedPaths.some(
72
+ (allowed) => abs === allowed || abs.startsWith(`${allowed}/`),
73
+ )
74
+ ) {
75
+ continue;
76
+ }
62
77
  // Hidden directories are rejected unless the root itself is the hidden scope.
63
78
  const hiddenRoot =
64
79
  basename(realpathSync(root)) === ".git" ||
@@ -89,7 +104,12 @@ function resolveScoped(roots: string[], input: unknown): PathResult {
89
104
  }
90
105
  return { ok: true, abs: abs, rel: relative(root, abs) || "." };
91
106
  }
92
- return { ok: false, error: "path is outside every approved root" };
107
+ return {
108
+ ok: false,
109
+ error: insideRoot
110
+ ? "path is outside the declared read/write scope"
111
+ : "path is outside every approved root",
112
+ };
93
113
  }
94
114
 
95
115
  function existsSync(p: string): boolean {
@@ -113,6 +133,7 @@ function utf8Text(abs: string): string | null {
113
133
 
114
134
  export function createScopedTools(opts: Options): ScopedToolDef[] {
115
135
  const roots = opts.roots.map((r) => resolve(r));
136
+ const allowedPaths = opts.allowedPaths?.map((p) => resolve(p));
116
137
  const observe = opts.observer ?? (() => {});
117
138
 
118
139
  const readTool: ScopedToolDef = {
@@ -120,7 +141,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
120
141
  description:
121
142
  "Read a regular UTF-8 text file within the approved scope (max 2,000 lines / 50 KiB).",
122
143
  async execute(params) {
123
- const resolved = resolveScoped(roots, params.path);
144
+ const resolved = resolveScoped(roots, params.path, allowedPaths);
124
145
  if (!resolved.ok) return { ok: false, error: resolved.error };
125
146
  let st: Stats | undefined;
126
147
  try {
@@ -167,7 +188,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
167
188
  error: `pattern exceeds ${TOOL_LIMITS.maxGrepPattern} characters`,
168
189
  };
169
190
  }
170
- const resolved = resolveScoped(roots, params.path ?? ".");
191
+ const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
171
192
  if (!resolved.ok) return { ok: false, error: resolved.error };
172
193
  let regex: RegExp;
173
194
  try {
@@ -207,7 +228,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
207
228
  description:
208
229
  "List a directory within the approved scope (max 20,000 entries, stable code-unit order).",
209
230
  async execute(params) {
210
- const resolved = resolveScoped(roots, params.path ?? ".");
231
+ const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
211
232
  if (!resolved.ok) return { ok: false, error: resolved.error };
212
233
  let st: Stats | undefined;
213
234
  try {
@@ -236,7 +257,7 @@ export function createScopedTools(opts: Options): ScopedToolDef[] {
236
257
  description:
237
258
  "Recursively list paths within the approved scope (max 20,000 entries, stable code-unit order).",
238
259
  async execute(params) {
239
- const resolved = resolveScoped(roots, params.path ?? ".");
260
+ const resolved = resolveScoped(roots, params.path ?? ".", allowedPaths);
240
261
  if (!resolved.ok) return { ok: false, error: resolved.error };
241
262
  let st: Stats | undefined;
242
263
  try {
@@ -10,7 +10,6 @@ import {
10
10
  visibleWidth,
11
11
  } from "@earendil-works/pi-tui";
12
12
  import type {
13
- DispatchResult,
14
13
  RuntimeActivityEvent,
15
14
  RuntimeActivityObserver,
16
15
  RuntimeActivityState,
@@ -39,12 +38,12 @@ export interface EvidenceActivitySummary {
39
38
  export interface DiffActivitySummary {
40
39
  kind: "diff";
41
40
  summary: string;
42
- nextStep: string;
43
41
  riskCount: number;
44
42
  retained: boolean;
45
43
  }
46
44
 
47
45
  export type ActivitySummary = EvidenceActivitySummary | DiffActivitySummary;
46
+ export type ActivityTone = "success" | "warning" | "muted" | "error";
48
47
 
49
48
  export interface ActivityDisplay {
50
49
  version: 1;
@@ -54,6 +53,7 @@ export interface ActivityDisplay {
54
53
  phase: string;
55
54
  objective: string;
56
55
  state: TerminalActivityState;
56
+ tone: ActivityTone;
57
57
  elapsedMs: number;
58
58
  summary?: ActivitySummary;
59
59
  reason?: string;
@@ -217,14 +217,7 @@ function renderActivityLines(
217
217
  formatElapsed(elapsed),
218
218
  ].join(" · ");
219
219
  const second = ` ${sanitizeDisplayText(snapshot.objective, 240)}`;
220
- const color =
221
- snapshot.state === "completed"
222
- ? "success"
223
- : snapshot.state === "failed" || snapshot.state === "timed-out"
224
- ? "error"
225
- : snapshot.state === "cancelled"
226
- ? "warning"
227
- : "accent";
220
+ const color = "tone" in snapshot ? snapshot.tone : "accent";
228
221
  return [
229
222
  line(styled(theme, color, first), width),
230
223
  line(styled(theme, "muted", second), width),
@@ -311,7 +304,7 @@ export class ActivityInlineComponent implements Component {
311
304
  lines[1] = line(
312
305
  styled(
313
306
  this.theme,
314
- "error",
307
+ this.display.tone,
315
308
  ` reason: ${sanitizeFailureReason(this.display.reason)}`,
316
309
  ),
317
310
  width,
@@ -323,7 +316,7 @@ export class ActivityInlineComponent implements Component {
323
316
  const detail =
324
317
  summary.kind === "evidence"
325
318
  ? ` evidence: ${summary.conclusions} conclusions · ${summary.citations} citations · ${summary.risks} risks · ${summary.blockingQuestions} blocking`
326
- : ` diff: ${sanitizeDisplayText(summary.summary)} · next: ${sanitizeDisplayText(summary.nextStep)} · ${summary.riskCount} risks · retained: ${summary.retained ? "yes" : "no"}`;
319
+ : ` diff: ${sanitizeDisplayText(summary.summary)} · ${summary.riskCount} risks · retained: ${summary.retained ? "yes" : "no"}`;
327
320
  return [lines[0] ?? "", line(styled(this.theme, "muted", detail), width)];
328
321
  }
329
322
 
@@ -337,10 +330,14 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
337
330
  }
338
331
 
339
332
  export function summarizeDispatchResult(
340
- result: DispatchResult,
333
+ result: unknown,
341
334
  ): ActivitySummary | undefined {
342
- if (!result.ok) return undefined;
343
- const value = asRecord(result.result);
335
+ const outer = asRecord(result);
336
+ if (!outer || outer.ok === false) return undefined;
337
+ const outcome = outer.ok === true ? asRecord(outer.result) : outer;
338
+ if (!outcome) return undefined;
339
+ const value =
340
+ outcome.kind === "candidate" ? asRecord(outcome.result) : outcome;
344
341
  if (!value) return undefined;
345
342
  if (
346
343
  value.kind === "evidence" &&
@@ -360,20 +357,41 @@ export function summarizeDispatchResult(
360
357
  if (
361
358
  value.kind === "diff" &&
362
359
  typeof value.summary === "string" &&
363
- typeof value.nextStep === "string" &&
364
360
  Array.isArray(value.risks)
365
361
  ) {
366
362
  return {
367
363
  kind: "diff",
368
364
  summary: sanitizeDisplayText(value.summary),
369
- nextStep: sanitizeDisplayText(value.nextStep),
370
365
  riskCount: value.risks.length,
371
- retained: typeof result.resultId === "string",
366
+ retained:
367
+ typeof outer.resultId === "string" ||
368
+ typeof outcome.resultId === "string",
372
369
  };
373
370
  }
374
371
  return undefined;
375
372
  }
376
373
 
374
+ function toneForResult(result: unknown): ActivityTone | undefined {
375
+ const outer = asRecord(result);
376
+ if (!outer || outer.ok === false) return undefined;
377
+ const value = outer.ok === true ? asRecord(outer.result) : outer;
378
+ switch (value?.kind) {
379
+ case "candidate":
380
+ case "applied":
381
+ case "completed":
382
+ return "success";
383
+ case "deferred":
384
+ case "retry":
385
+ case "checkpoint-required":
386
+ return "warning";
387
+ case "blocked":
388
+ case "cancelled":
389
+ return "muted";
390
+ default:
391
+ return undefined;
392
+ }
393
+ }
394
+
377
395
  function terminalState(
378
396
  event: RuntimeActivityEvent,
379
397
  ): event is RuntimeActivityEvent & {
@@ -396,6 +414,12 @@ function displayFromEvent(
396
414
  phase: entry.phase,
397
415
  objective: sanitizeDisplayText(entry.objective),
398
416
  state: event.state as TerminalActivityState,
417
+ tone:
418
+ event.state === "failed" || event.state === "timed-out"
419
+ ? "error"
420
+ : event.state === "cancelled"
421
+ ? "muted"
422
+ : "success",
399
423
  elapsedMs,
400
424
  ...(reason === undefined ? {} : { reason: sanitizeFailureReason(reason) }),
401
425
  };
@@ -404,7 +428,7 @@ function displayFromEvent(
404
428
  export function createActivityDisplay(
405
429
  event: RuntimeActivityEvent,
406
430
  elapsedMs: number,
407
- result?: DispatchResult,
431
+ result?: unknown,
408
432
  ): ActivityDisplay {
409
433
  const entry: ActivityEntry = {
410
434
  toolCallId: "",
@@ -419,7 +443,12 @@ export function createActivityDisplay(
419
443
  };
420
444
  const display = displayFromEvent(entry, event, elapsedMs);
421
445
  const summary = result ? summarizeDispatchResult(result) : undefined;
422
- return summary ? { ...display, summary } : display;
446
+ const tone = result ? toneForResult(result) : undefined;
447
+ return {
448
+ ...display,
449
+ ...(tone === undefined ? {} : { tone }),
450
+ ...(summary === undefined ? {} : { summary }),
451
+ };
423
452
  }
424
453
 
425
454
  function activityPartial(display: ActivityDisplay | ActivitySnapshot): unknown {
@@ -538,15 +567,17 @@ export class ActivityController {
538
567
  this.syncUi();
539
568
  }
540
569
 
541
- finalize(
542
- toolCallId: string,
543
- result: DispatchResult,
544
- ): ActivityDisplay | undefined {
570
+ finalize(toolCallId: string, result: unknown): ActivityDisplay | undefined {
545
571
  const display = this.terminals.get(toolCallId);
546
572
  if (!display) return undefined;
547
573
  this.terminals.delete(toolCallId);
548
574
  const summary = summarizeDispatchResult(result);
549
- return summary ? { ...display, summary } : display;
575
+ const tone = toneForResult(result);
576
+ return {
577
+ ...display,
578
+ ...(tone === undefined ? {} : { tone }),
579
+ ...(summary === undefined ? {} : { summary }),
580
+ };
550
581
  }
551
582
 
552
583
  getActiveEntries(): readonly ActivitySnapshot[] {
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import {
4
+ type CandidateFailure,
4
5
  type DiffResult,
5
6
  type EvidenceResult,
6
7
  validateDiffResult,
@@ -36,7 +37,6 @@ const diffSchema = Type.Object({
36
37
  diff: Type.String(),
37
38
  expectedVerification: Type.String(),
38
39
  risks: Type.Array(Type.String()),
39
- nextStep: Type.String(),
40
40
  contractCompliant: Type.Literal(true),
41
41
  });
42
42
 
@@ -63,11 +63,13 @@ export interface SubmitClassification {
63
63
 
64
64
  export function createSubmitTool(input: {
65
65
  requestId: string;
66
+ taskId?: string;
66
67
  role: string;
67
68
  phase: string;
68
69
  output: "evidence" | "diff";
69
70
  }) {
70
71
  let submitted: EvidenceResult | DiffResult | undefined;
72
+ let failure: CandidateFailure | undefined;
71
73
  let attempts = 0;
72
74
  let schema: "valid" | "invalid" = "invalid";
73
75
  const identity: IdentityOutcome = {
@@ -93,12 +95,14 @@ export function createSubmitTool(input: {
93
95
  identity.task = false;
94
96
  identity.phase = false;
95
97
  schema = "invalid";
98
+ failure = { kind: "artifact", code: "invalid-structural-result" };
96
99
  throw new Error("submitted result is not an object");
97
100
  }
98
101
  if (value.id !== input.requestId) identity.request = false;
99
102
  if (value.role !== input.role) identity.role = false;
100
103
  if (input.output === "diff") {
101
- if (value.taskId !== input.requestId) identity.task = false;
104
+ if (value.taskId !== (input.taskId ?? input.requestId))
105
+ identity.task = false;
102
106
  if (value.phase !== input.phase) identity.phase = false;
103
107
  }
104
108
  const validation =
@@ -109,11 +113,17 @@ export function createSubmitTool(input: {
109
113
  const matches =
110
114
  identity.request && identity.role && identity.task && identity.phase;
111
115
  if (!validation.ok || !matches) {
116
+ failure =
117
+ validation.failure ??
118
+ ({ kind: "artifact", code: "invalid-structural-result" } as const);
112
119
  throw new Error(
113
120
  validation.reason ?? "submitted result identity does not match",
114
121
  );
115
122
  }
116
- if (submitted) throw new Error("duplicate structural submission");
123
+ if (submitted) {
124
+ failure = { kind: "artifact", code: "invalid-structural-result" };
125
+ throw new Error("duplicate structural submission");
126
+ }
117
127
  submitted = structuredClone(value) as unknown as
118
128
  | EvidenceResult
119
129
  | DiffResult;
@@ -131,5 +141,7 @@ export function createSubmitTool(input: {
131
141
  getAttempts: () => attempts,
132
142
  getSchema: () => schema,
133
143
  getIdentity: () => ({ ...identity }),
144
+ getFailure: () =>
145
+ failure === undefined ? undefined : structuredClone(failure),
134
146
  };
135
147
  }