praxis-agent 0.60.1 → 0.61.0

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/README.md CHANGED
@@ -215,8 +215,10 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
215
215
  tool, memory, first-turn, and resume behavior. Agent execution uses one
216
216
  durable lifecycle vocabulary with bounded cancellation and drain,
217
217
  continuation, notifications, and single-owner orphan recovery. Isolated
218
- Workflow turns use ownership-recorded repo-local temporary worktrees and
219
- retain dirty or committed results for inspection. Experimental
218
+ Workflow turns use ownership-recorded repo-local temporary worktrees, run
219
+ trust-admitted synchronous lifecycle hooks, safely roll back blocked
220
+ creation, and retain dirty, committed, unsafe, or removal-blocked results
221
+ for inspection. Experimental
220
222
  local Teams (`PRAXIS_ENABLE_TEAMS=true`) stay absent from ordinary startup by
221
223
  default and add durable task ownership plus one ordered mailbox with stable
222
224
  identities, fixed broadcast recipients, durable cursors, bounded retention,
@@ -6,6 +6,23 @@ export interface ManagedWorktree {
6
6
  cwd: string;
7
7
  cleanup(): Promise<ManagedWorktreeCleanup>;
8
8
  }
9
+ export interface ManagedWorktreeHookInput {
10
+ readonly worktreePath: string;
11
+ readonly worktreeKind: 'workflow' | 'agent' | 'team';
12
+ readonly worktreeId: string;
13
+ readonly ownerId: string;
14
+ readonly baseCommit: string;
15
+ }
16
+ export interface ManagedWorktreeRemoveHookInput extends ManagedWorktreeHookInput {
17
+ readonly reason: 'normal' | 'reconcile';
18
+ }
19
+ export interface ManagedWorktreeHookOutcome {
20
+ blockedReason?: string;
21
+ }
22
+ export interface ManagedWorktreeHooks {
23
+ afterCreate(input: ManagedWorktreeHookInput): Promise<ManagedWorktreeHookOutcome>;
24
+ beforeRemove(input: ManagedWorktreeRemoveHookInput): Promise<ManagedWorktreeHookOutcome>;
25
+ }
9
26
  export interface OwnedManagedWorktreeOptions {
10
27
  cwd: string;
11
28
  stateRoot: string;
@@ -14,6 +31,7 @@ export interface OwnedManagedWorktreeOptions {
14
31
  label: 'Agent' | 'Workflow' | 'Team';
15
32
  kind: 'workflow' | 'agent' | 'team';
16
33
  policy: 'ephemeral' | 'durable';
34
+ hooks?: ManagedWorktreeHooks;
17
35
  }
18
36
  export declare function createOwnedManagedWorktree(options: OwnedManagedWorktreeOptions): Promise<ManagedWorktree>;
19
37
  export declare function createManagedWorktree(options: {
@@ -360,6 +360,24 @@ export async function createOwnedManagedWorktree(options) {
360
360
  gitCreated = true;
361
361
  await writeMarker(worktreePath, record);
362
362
  markerCreated = true;
363
+ if (options.hooks) {
364
+ let outcome;
365
+ try {
366
+ outcome = await options.hooks.afterCreate({
367
+ worktreePath,
368
+ worktreeKind: record.kind,
369
+ worktreeId: record.worktreeId,
370
+ ownerId: record.ownerId,
371
+ baseCommit: record.baseCommit,
372
+ });
373
+ }
374
+ catch (error) {
375
+ throw new Error(`WorktreeCreate hook failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
376
+ }
377
+ if (outcome.blockedReason) {
378
+ throw new Error(`WorktreeCreate hook blocked: ${outcome.blockedReason}`);
379
+ }
380
+ }
363
381
  await store.update(nextRecord(record, 'active'));
364
382
  created = true;
365
383
  }
@@ -537,6 +555,38 @@ async function cleanupOwnedManagedWorktree(store, options, original, heldLease)
537
555
  const reason = `${options.label} worktree has commits and was retained at ${record.worktreePath}`;
538
556
  return retain(store, releasing, reason);
539
557
  }
558
+ if (options.hooks) {
559
+ let outcome;
560
+ try {
561
+ outcome = await options.hooks.beforeRemove({
562
+ worktreePath: record.worktreePath,
563
+ worktreeKind: record.kind,
564
+ worktreeId: record.worktreeId,
565
+ ownerId: record.ownerId,
566
+ baseCommit: record.baseCommit,
567
+ reason: 'normal',
568
+ });
569
+ }
570
+ catch (error) {
571
+ return retain(store, releasing, `WorktreeRemove hook failed for ${record.worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
572
+ }
573
+ if (outcome.blockedReason) {
574
+ return retain(store, releasing, `WorktreeRemove hook blocked for ${record.worktreePath}: ${outcome.blockedReason}`);
575
+ }
576
+ try {
577
+ const postHookRegistered = await registeredWorktrees(record.repositoryRoot);
578
+ const postHookInspection = await inspectOwnedCheckout(record, postHookRegistered);
579
+ if (postHookInspection.status.length > 0) {
580
+ return retain(store, releasing, `WorktreeRemove hook left uncommitted changes in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
581
+ }
582
+ if (postHookInspection.head !== record.baseCommit) {
583
+ return retain(store, releasing, `WorktreeRemove hook created commits in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
584
+ }
585
+ }
586
+ catch (error) {
587
+ return retain(store, releasing, `WorktreeRemove hook left worktree unsafe at ${record.worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
588
+ }
589
+ }
540
590
  try {
541
591
  await git(record.repositoryRoot, [
542
592
  'worktree',
@@ -1393,6 +1393,17 @@ export class ClaudeSubagentExecutor {
1393
1393
  praxisRoot: sessionPaths.praxisRoot,
1394
1394
  runId: options.runId,
1395
1395
  agentId: options.agentId,
1396
+ ...(this.options.hooks
1397
+ ? {
1398
+ hookContext: {
1399
+ runner: this.options.hooks,
1400
+ sessionId: options.sessionId,
1401
+ transcriptPath: paths.transcriptFile,
1402
+ permissionMode: 'default',
1403
+ ...(options.signal ? { signal: options.signal } : {}),
1404
+ },
1405
+ }
1406
+ : {}),
1396
1407
  })
1397
1408
  : null;
1398
1409
  const agentCwd = isolation?.cwd ?? this.cwd();
@@ -1,9 +1,18 @@
1
1
  import { type ManagedWorktree } from './managed-worktree.js';
2
+ import type { ClaudeHookRunner } from '../hooks/claude-hooks.js';
2
3
  export type WorkflowWorktree = ManagedWorktree;
4
+ export interface WorkflowWorktreeHookContext {
5
+ runner: ClaudeHookRunner;
6
+ sessionId: string;
7
+ transcriptPath: string;
8
+ permissionMode: string;
9
+ signal?: AbortSignal;
10
+ }
3
11
  export declare function createWorkflowWorktree(options: {
4
12
  cwd: string;
5
13
  praxisRoot: string;
6
14
  runId: string;
7
15
  agentId: string;
16
+ hookContext?: WorkflowWorktreeHookContext;
8
17
  }): Promise<WorkflowWorktree>;
9
18
  //# sourceMappingURL=workflow-worktree.d.ts.map
@@ -1,4 +1,26 @@
1
1
  import { createOwnedManagedWorktree, } from './managed-worktree.js';
2
+ function lifecycleHookInput(input, context, event) {
3
+ const reason = 'reason' in input ? input.reason : undefined;
4
+ return {
5
+ session_id: context.sessionId,
6
+ transcript_path: context.transcriptPath,
7
+ cwd: input.worktreePath,
8
+ permission_mode: context.permissionMode,
9
+ hook_event_name: event,
10
+ worktree_path: input.worktreePath,
11
+ worktree_kind: input.worktreeKind,
12
+ worktree_id: input.worktreeId,
13
+ owner_id: input.ownerId,
14
+ base_commit: input.baseCommit,
15
+ ...(event === 'WorktreeRemove' && reason !== undefined ? { reason } : {}),
16
+ };
17
+ }
18
+ function lifecycleHooks(context) {
19
+ return {
20
+ afterCreate: async (input) => context.runner.run(lifecycleHookInput(input, context, 'WorktreeCreate'), input.worktreeKind, context.signal),
21
+ beforeRemove: async (input) => context.runner.run(lifecycleHookInput(input, context, 'WorktreeRemove'), input.worktreeKind, context.signal),
22
+ };
23
+ }
2
24
  export async function createWorkflowWorktree(options) {
3
25
  return createOwnedManagedWorktree({
4
26
  cwd: options.cwd,
@@ -8,6 +30,9 @@ export async function createWorkflowWorktree(options) {
8
30
  label: 'Workflow',
9
31
  kind: 'workflow',
10
32
  policy: 'ephemeral',
33
+ ...(options.hookContext
34
+ ? { hooks: lifecycleHooks(options.hookContext) }
35
+ : {}),
11
36
  });
12
37
  }
13
38
  //# sourceMappingURL=workflow-worktree.js.map
@@ -28,7 +28,14 @@ export const HOOK_EVENTS = [
28
28
  'FileChanged',
29
29
  'SessionEnd',
30
30
  ];
31
- const EXECUTABLE_HOOK_EVENTS = new Set(HOOK_EVENTS);
31
+ const LIFECYCLE_HOOK_EVENTS = [
32
+ 'WorktreeCreate',
33
+ 'WorktreeRemove',
34
+ ];
35
+ const EXECUTABLE_HOOK_EVENTS = new Set([
36
+ ...HOOK_EVENTS,
37
+ ...LIFECYCLE_HOOK_EVENTS,
38
+ ]);
32
39
  function isRecord(value) {
33
40
  return typeof value === 'object' && value !== null && !Array.isArray(value);
34
41
  }
@@ -91,6 +98,9 @@ function eventSettings(settings, event, maxTimeoutMs) {
91
98
  if (hook.async !== undefined && typeof hook.async !== 'boolean') {
92
99
  throw new Error(`Invalid Claude ${event} hook async: ${resource.path}`);
93
100
  }
101
+ if (LIFECYCLE_HOOK_EVENTS.includes(event) && hook.async === true) {
102
+ throw new Error(`Invalid Claude ${event} hook async: ${resource.path}`);
103
+ }
94
104
  return [
95
105
  {
96
106
  command,
@@ -145,8 +155,9 @@ export function validateClaudeHooks(settings, maxTimeoutMs = DEFAULT_TIMEOUT_MS)
145
155
  !Number.isInteger(maxTimeoutMs)) {
146
156
  throw new Error('Hook max timeout must be a positive integer');
147
157
  }
148
- for (const event of HOOK_EVENTS)
158
+ for (const event of [...HOOK_EVENTS, ...LIFECYCLE_HOOK_EVENTS]) {
149
159
  eventSettings(settings, event, maxTimeoutMs);
160
+ }
150
161
  }
151
162
  function outputRecord(stdout) {
152
163
  const trimmed = stdout.trim();
@@ -204,6 +215,9 @@ function hookMatcherValue(input) {
204
215
  return basename(optionalString(input.file_path) ?? '');
205
216
  case 'SessionEnd':
206
217
  return optionalString(input.reason) ?? '';
218
+ case 'WorktreeCreate':
219
+ case 'WorktreeRemove':
220
+ return optionalString(input.worktree_kind) ?? '';
207
221
  default:
208
222
  return '';
209
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.60.1",
3
+ "version": "0.61.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",