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 +4 -2
- package/dist/application/managed-worktree.d.ts +18 -0
- package/dist/application/managed-worktree.js +50 -0
- package/dist/application/subagent-service.js +11 -0
- package/dist/application/workflow-worktree.d.ts +9 -0
- package/dist/application/workflow-worktree.js +25 -0
- package/dist/hooks/claude-hooks.js +16 -2
- package/package.json +1 -1
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
|
|
219
|
-
|
|
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
|
|
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
|
}
|