@wichayutdew/pi-workflows 0.3.0 → 1.0.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.
Files changed (142) hide show
  1. package/README.md +53 -39
  2. package/dist/index.js +5754 -4794
  3. package/package.json +1 -1
  4. package/src/command-names.ts +7 -1
  5. package/src/commands.ts +126 -52
  6. package/src/config/catalog.ts +162 -0
  7. package/src/config/ceiling.ts +116 -116
  8. package/src/config/command-conflicts.ts +4 -4
  9. package/src/config/diagnostics.ts +28 -0
  10. package/src/config/load-settings.ts +37 -0
  11. package/src/config/load-types.ts +53 -0
  12. package/src/config/load-workflows.ts +135 -0
  13. package/src/config/load.ts +43 -311
  14. package/src/config/types.ts +137 -135
  15. package/src/config/validate.ts +12 -1261
  16. package/src/config/validation/permissions.ts +291 -0
  17. package/src/config/validation/prompt.ts +19 -0
  18. package/src/config/validation/settings.ts +122 -0
  19. package/src/config/validation/shared.ts +129 -0
  20. package/src/config/validation/shortcut.ts +118 -0
  21. package/src/config/validation/step.ts +228 -0
  22. package/src/config/validation/subagent.ts +288 -0
  23. package/src/config/validation/workflow.ts +156 -0
  24. package/src/config/yaml.ts +29 -0
  25. package/src/digest.ts +38 -6
  26. package/src/engine/checkpoint.ts +13 -9
  27. package/src/engine/create-run.ts +42 -0
  28. package/src/engine/gate-transitions.ts +184 -0
  29. package/src/engine/reconciliation-history.ts +88 -0
  30. package/src/engine/resume.ts +19 -7
  31. package/src/engine/run-advance.ts +122 -0
  32. package/src/engine/run-lifecycle.ts +124 -0
  33. package/src/engine/run-reconciliation.ts +116 -0
  34. package/src/engine/run-validation.ts +128 -0
  35. package/src/engine/state-types.ts +64 -0
  36. package/src/engine/state.ts +10 -193
  37. package/src/engine/transition-helpers.ts +28 -0
  38. package/src/engine/transition-types.ts +8 -0
  39. package/src/engine/transitions.ts +17 -471
  40. package/src/harness/action-context.ts +181 -0
  41. package/src/harness/catalog.ts +45 -0
  42. package/src/harness/context-idle.ts +21 -0
  43. package/src/harness/core-actions.ts +299 -0
  44. package/src/harness/delegation-control-actions.ts +250 -0
  45. package/src/harness/delegation-failure.ts +215 -0
  46. package/src/harness/delegation-plan.ts +219 -0
  47. package/src/harness/delegation-recovery-validation.ts +159 -0
  48. package/src/harness/delegation-response-actions.ts +295 -0
  49. package/src/harness/delegation-retry-policy.ts +118 -0
  50. package/src/harness/dependencies.ts +165 -0
  51. package/src/harness/gate-submission-action.ts +154 -0
  52. package/src/harness/lifecycle-actions.ts +154 -0
  53. package/src/harness/pause-actions.ts +120 -0
  54. package/src/harness/plannotator-result-actions.ts +123 -0
  55. package/src/harness/prompt-gate-actions.ts +270 -0
  56. package/src/harness/resume-action.ts +261 -0
  57. package/src/harness/start-actions.ts +183 -0
  58. package/src/harness/status-actions.ts +191 -0
  59. package/src/harness/step-execution-actions.ts +283 -0
  60. package/src/harness/types.ts +76 -0
  61. package/src/harness.ts +236 -2303
  62. package/src/index.ts +73 -11
  63. package/src/integrations/plannotator-requests.ts +127 -0
  64. package/src/integrations/plannotator-responses.ts +141 -0
  65. package/src/integrations/plannotator-types.ts +44 -0
  66. package/src/integrations/plannotator.ts +16 -235
  67. package/src/integrations/prompt-gate.ts +17 -10
  68. package/src/integrations/subagents/child-policy-envelope.ts +119 -0
  69. package/src/integrations/subagents/child-policy-paths.ts +66 -0
  70. package/src/integrations/subagents/child-policy-sections.ts +174 -0
  71. package/src/integrations/subagents/child-policy-types.ts +32 -0
  72. package/src/integrations/subagents/child-policy-validation.ts +163 -0
  73. package/src/integrations/subagents/child-runtime-completion.ts +56 -0
  74. package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
  75. package/src/integrations/subagents/child-runtime-files.ts +156 -0
  76. package/src/integrations/subagents/child-runtime-policy.ts +83 -0
  77. package/src/integrations/subagents/child-runtime-types.ts +30 -0
  78. package/src/integrations/subagents/child-runtime.ts +140 -301
  79. package/src/integrations/subagents/client-delegation.ts +181 -0
  80. package/src/integrations/subagents/client-messages.ts +66 -0
  81. package/src/integrations/subagents/client-types.ts +36 -0
  82. package/src/integrations/subagents/client.ts +127 -219
  83. package/src/integrations/subagents/delegated-result.ts +31 -0
  84. package/src/integrations/subagents/diagnostic-format.ts +40 -0
  85. package/src/integrations/subagents/diagnostic-text.ts +114 -0
  86. package/src/integrations/subagents/diagnostic-types.ts +77 -0
  87. package/src/integrations/subagents/diagnostics.ts +24 -977
  88. package/src/integrations/subagents/failure-correlation.ts +235 -0
  89. package/src/integrations/subagents/failure-transcript.ts +192 -0
  90. package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
  91. package/src/integrations/subagents/protocol-events.ts +27 -0
  92. package/src/integrations/subagents/protocol.ts +30 -375
  93. package/src/integrations/subagents/replay-audit.ts +147 -0
  94. package/src/integrations/subagents/replay-safety.ts +82 -0
  95. package/src/integrations/subagents/session-diagnostics.ts +258 -0
  96. package/src/policy/approved-command-extraction.ts +71 -0
  97. package/src/policy/approved-commands.ts +15 -432
  98. package/src/policy/bash-authorization.ts +86 -0
  99. package/src/policy/bash-read-only.ts +161 -0
  100. package/src/policy/bash-types.ts +20 -0
  101. package/src/policy/bash.ts +4 -346
  102. package/src/policy/completion-batch.ts +21 -16
  103. package/src/policy/immutable-input.ts +5 -2
  104. package/src/policy/mcp-authorization.ts +71 -0
  105. package/src/policy/restricted-command.ts +141 -0
  106. package/src/policy/restricted-git.ts +30 -0
  107. package/src/policy/reviewed-artifact.ts +97 -0
  108. package/src/policy/reviewed-command-safety.ts +150 -0
  109. package/src/policy/reviewed-command-shape.ts +49 -0
  110. package/src/policy/reviewed-repository-contract.ts +138 -0
  111. package/src/policy/reviewed-repository-cwd.ts +102 -0
  112. package/src/policy/tool-call-authorization.ts +58 -0
  113. package/src/policy/tool-selection.ts +72 -0
  114. package/src/policy/tool-types.ts +14 -0
  115. package/src/policy/tools.ts +11 -150
  116. package/src/preflight.ts +96 -61
  117. package/src/prompt/main-workflow-notice.ts +41 -0
  118. package/src/prompt/retry-task.ts +83 -0
  119. package/src/prompt/step-contract.ts +101 -0
  120. package/src/prompt/step-sections.ts +75 -0
  121. package/src/prompt/step-task.ts +156 -0
  122. package/src/prompt/template.ts +81 -0
  123. package/src/prompt.ts +10 -254
  124. package/src/runtime/completion-tool.ts +3 -0
  125. package/src/runtime/main-step-completion.ts +78 -0
  126. package/src/runtime/main-step-lifecycle.ts +37 -0
  127. package/src/runtime/main-step-policy.ts +84 -0
  128. package/src/runtime/main-step-runtime-types.ts +64 -0
  129. package/src/runtime/main-step-runtime.ts +129 -200
  130. package/src/runtime/main-step-state.ts +85 -0
  131. package/src/runtime/serial-task-queue.ts +48 -13
  132. package/src/runtime/step-result.ts +48 -27
  133. package/src/workflow-list.ts +15 -6
  134. package/src/workflow-status/format-status.ts +52 -0
  135. package/src/workflow-status/formatting.ts +178 -0
  136. package/src/workflow-status/layout.ts +109 -0
  137. package/src/workflow-status/render-board.ts +101 -0
  138. package/src/workflow-status/render-path.ts +123 -0
  139. package/src/workflow-status/render-summary.ts +144 -0
  140. package/src/workflow-status/types.ts +40 -0
  141. package/src/workflow-status/view.ts +273 -0
  142. package/src/workflow-status.ts +20 -820
@@ -0,0 +1,97 @@
1
+ export type ParsedJsonDocuments = {
2
+ readonly documents: ReadonlyArray<unknown>;
3
+ readonly hasMalformedCandidate: boolean;
4
+ };
5
+
6
+ /**
7
+ * Narrows an unknown value to a non-array record.
8
+ *
9
+ * @param value - Value to inspect.
10
+ * @returns `true` when the value is a record.
11
+ */
12
+ export const isRecord = (
13
+ value: unknown,
14
+ ): value is Readonly<Record<string, unknown>> =>
15
+ value !== null && typeof value === 'object' && !Array.isArray(value);
16
+
17
+ /**
18
+ * Parses a complete JSON artifact and JSON fenced blocks while retaining
19
+ * whether any JSON-looking candidate was malformed.
20
+ *
21
+ * @param text - Reviewed artifact text.
22
+ * @returns Parsed documents and malformed-candidate state.
23
+ */
24
+ export const parseJsonDocumentsWithValidity = (
25
+ text: string,
26
+ ): ParsedJsonDocuments => {
27
+ const documents: Array<unknown> = [];
28
+ let hasMalformedCandidate = false;
29
+ const addDocument = (candidate: string): void => {
30
+ try {
31
+ documents.push(JSON.parse(candidate));
32
+ } catch {
33
+ hasMalformedCandidate = true;
34
+ }
35
+ };
36
+
37
+ const trimmed = text.trim();
38
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
39
+ addDocument(trimmed);
40
+ }
41
+
42
+ const fences = /```(?:json)?[ \t]*\r?\n([\s\S]*?)```/giu;
43
+ for (const match of text.matchAll(fences)) {
44
+ const candidate = match[1]?.trim();
45
+ if (candidate) addDocument(candidate);
46
+ }
47
+ return { documents, hasMalformedCandidate };
48
+ };
49
+
50
+ /**
51
+ * Parses all valid JSON documents from a reviewed artifact.
52
+ *
53
+ * @param text - Reviewed artifact text.
54
+ * @returns Valid JSON documents in source order.
55
+ */
56
+ export const parseJsonDocuments = (text: string): ReadonlyArray<unknown> =>
57
+ parseJsonDocumentsWithValidity(text).documents;
58
+
59
+ /**
60
+ * Extracts verification commands for one role from a parsed artifact.
61
+ *
62
+ * @param value - Parsed artifact document.
63
+ * @param role - Verification role whose commands should be returned.
64
+ * @returns Command strings in artifact order.
65
+ */
66
+ export const verificationCommands = (
67
+ value: unknown,
68
+ role: 'worker' | 'reviewer',
69
+ ): Array<string> => {
70
+ if (!isRecord(value) || !Array.isArray(value.repositories)) return [];
71
+ return value.repositories.flatMap((repository) => {
72
+ if (!isRecord(repository) || !Array.isArray(repository[role])) return [];
73
+ return repository[role].flatMap((check) =>
74
+ isRecord(check) && typeof check.command === 'string'
75
+ ? [check.command]
76
+ : [],
77
+ );
78
+ });
79
+ };
80
+
81
+ /**
82
+ * Extracts Bash command actions from a parsed remote-action artifact.
83
+ *
84
+ * @param value - Parsed artifact document.
85
+ * @returns Bash commands in artifact order.
86
+ */
87
+ export const remoteActionCommands = (value: unknown): Array<string> => {
88
+ if (!isRecord(value) || !Array.isArray(value.actions)) return [];
89
+ return value.actions.flatMap((action) =>
90
+ isRecord(action) &&
91
+ action.toolName === 'bash' &&
92
+ isRecord(action.input) &&
93
+ typeof action.input.command === 'string'
94
+ ? [action.input.command]
95
+ : [],
96
+ );
97
+ };
@@ -0,0 +1,150 @@
1
+ import { basename } from 'node:path';
2
+ import { tokenizeRestrictedCommand } from './restricted-command.ts';
3
+ import { parseRestrictedGitCommand } from './restricted-git.ts';
4
+
5
+ const SHELL_WRAPPERS: ReadonlySet<string> = new Set([
6
+ 'bash',
7
+ 'env',
8
+ 'exec',
9
+ 'fish',
10
+ 'sh',
11
+ 'xargs',
12
+ 'zsh',
13
+ ]);
14
+ const REMOTE_EXECUTABLES: ReadonlySet<string> = new Set([
15
+ 'curl',
16
+ 'scp',
17
+ 'ssh',
18
+ 'rsync',
19
+ 'wget',
20
+ ]);
21
+ const FORBIDDEN_LONG_PUSH_OPTIONS = [
22
+ '--force',
23
+ '--force-if-includes',
24
+ '--force-with-lease',
25
+ '--all',
26
+ '--delete',
27
+ '--mirror',
28
+ '--prune',
29
+ '--tags',
30
+ ] as const;
31
+ const PUBLISH_EXECUTABLES: ReadonlySet<string> = new Set([
32
+ 'bun',
33
+ 'cargo',
34
+ 'npm',
35
+ 'pnpm',
36
+ 'yarn',
37
+ ]);
38
+ const LOCAL_VERIFICATION_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set([
39
+ 'add',
40
+ 'branch',
41
+ 'commit',
42
+ 'diff',
43
+ 'grep',
44
+ 'log',
45
+ 'ls-files',
46
+ 'rev-parse',
47
+ 'show',
48
+ 'status',
49
+ 'worktree',
50
+ ]);
51
+
52
+ const containsPublishOperation = (tokens: ReadonlyArray<string>): boolean =>
53
+ tokens
54
+ .slice(1)
55
+ .some((token) => token.length >= 3 && 'publish'.startsWith(token));
56
+
57
+ const hasDestructivePushShortOption = (token: string): boolean =>
58
+ token.startsWith('-') &&
59
+ !token.startsWith('--') &&
60
+ (token.slice(1).includes('f') || token.slice(1).includes('d'));
61
+
62
+ const hasForbiddenLongPushOption = (token: string): boolean => {
63
+ if (!token.startsWith('--') || token === '--') return false;
64
+ const optionName = token.split('=', 1)[0] ?? token;
65
+ return FORBIDDEN_LONG_PUSH_OPTIONS.some((option) =>
66
+ option.startsWith(optionName),
67
+ );
68
+ };
69
+
70
+ const hasEmptyPushRefspecSide = (token: string): boolean => {
71
+ const separator = token.indexOf(':');
72
+ return separator >= 0 && (separator === 0 || separator === token.length - 1);
73
+ };
74
+
75
+ /**
76
+ * Determines whether a reviewed verification command is safe to authorize.
77
+ *
78
+ * @param command - Exact command extracted from an approved artifact.
79
+ * @returns `true` for local, non-publishing verification commands.
80
+ */
81
+ export const isSafeVerificationCommand = (command: string): boolean => {
82
+ const parsed = tokenizeRestrictedCommand(command);
83
+ if (!parsed.tokens) return false;
84
+
85
+ const executable = basename(parsed.tokens[0] ?? '');
86
+ if (SHELL_WRAPPERS.has(executable) || REMOTE_EXECUTABLES.has(executable)) {
87
+ return false;
88
+ }
89
+
90
+ const subcommand =
91
+ executable === 'git'
92
+ ? parseRestrictedGitCommand(parsed.tokens)?.subcommand
93
+ : parsed.tokens[1];
94
+ if (
95
+ executable === 'git' &&
96
+ (!subcommand || !LOCAL_VERIFICATION_GIT_SUBCOMMANDS.has(subcommand))
97
+ ) {
98
+ return false;
99
+ }
100
+ if (executable === 'gh' || executable === 'glab') return false;
101
+ if (
102
+ PUBLISH_EXECUTABLES.has(executable) &&
103
+ containsPublishOperation(parsed.tokens)
104
+ ) {
105
+ return false;
106
+ }
107
+ return !(executable === 'docker' && parsed.tokens.slice(1).includes('push'));
108
+ };
109
+
110
+ const isHostedApiDelete = (tokens: ReadonlyArray<string>): boolean =>
111
+ tokens.slice(2).some((token, index, apiTokens) => {
112
+ const normalizedToken = token.toUpperCase();
113
+ return (
114
+ normalizedToken === '--METHOD=DELETE' ||
115
+ normalizedToken === '-XDELETE' ||
116
+ ((normalizedToken === '--METHOD' || normalizedToken === '-X') &&
117
+ apiTokens[index + 1]?.toUpperCase() === 'DELETE')
118
+ );
119
+ });
120
+
121
+ /**
122
+ * Determines whether a reviewed remote action is narrowly safe to authorize.
123
+ *
124
+ * @param command - Exact command extracted from an approved artifact.
125
+ * @returns `true` for non-destructive Git pushes and hosted API writes.
126
+ */
127
+ export const isSafeRemoteActionCommand = (command: string): boolean => {
128
+ const parsed = tokenizeRestrictedCommand(command);
129
+ if (!parsed.tokens) return false;
130
+
131
+ const executable = parsed.tokens[0];
132
+ const subcommand =
133
+ executable === 'git'
134
+ ? parseRestrictedGitCommand(parsed.tokens)?.subcommand
135
+ : parsed.tokens[1];
136
+ if (executable === 'gh' || executable === 'glab') {
137
+ return subcommand === 'api' && !isHostedApiDelete(parsed.tokens);
138
+ }
139
+ if (executable !== 'git' || subcommand !== 'push') return false;
140
+
141
+ return !parsed.tokens
142
+ .slice(1)
143
+ .some(
144
+ (token) =>
145
+ hasForbiddenLongPushOption(token) ||
146
+ token.startsWith('+') ||
147
+ hasEmptyPushRefspecSide(token) ||
148
+ hasDestructivePushShortOption(token),
149
+ );
150
+ };
@@ -0,0 +1,49 @@
1
+ import { basename } from 'node:path';
2
+ import { tokenizeRestrictedCommand } from './restricted-command.ts';
3
+ import {
4
+ parseJsonDocuments,
5
+ verificationCommands,
6
+ } from './reviewed-artifact.ts';
7
+
8
+ const malformedBunInstallReason = (command: string): string | undefined => {
9
+ const parsed = tokenizeRestrictedCommand(command);
10
+ if (!parsed.tokens || basename(parsed.tokens[0] ?? '') !== 'bun') {
11
+ return undefined;
12
+ }
13
+
14
+ const installIndex = parsed.tokens.indexOf('install', 1);
15
+ if (installIndex <= 1) return undefined;
16
+ const hasCwdBeforeInstall = parsed.tokens
17
+ .slice(1, installIndex)
18
+ .some((token) => token === '--cwd' || token.startsWith('--cwd='));
19
+ if (!hasCwdBeforeInstall) return undefined;
20
+
21
+ return [
22
+ `Invalid Bun install command: ${JSON.stringify(command)}.`,
23
+ '`--cwd` appears before `install`, so Bun interprets `install` as a package script.',
24
+ 'Use `bun install --cwd <absolute-cwd> --frozen-lockfile`, preserving the reviewed path and any other intended install flags, then resubmit the plan.',
25
+ ].join(' ');
26
+ };
27
+
28
+ /**
29
+ * Rejects known command-shape mistakes before human approval.
30
+ *
31
+ * Deterministic checks prevent known parser traps from becoming approved
32
+ * capabilities while leaving arbitrary runtime diagnosis to the agent.
33
+ *
34
+ * @param artifact - Reviewed execution contract.
35
+ * @returns A corrective error message, or `undefined` when shapes are valid.
36
+ */
37
+ export const reviewedCommandShapeError = (
38
+ artifact: string,
39
+ ): string | undefined => {
40
+ for (const document of parseJsonDocuments(artifact)) {
41
+ for (const role of ['worker', 'reviewer'] as const) {
42
+ for (const command of verificationCommands(document, role)) {
43
+ const reason = malformedBunInstallReason(command);
44
+ if (reason) return reason;
45
+ }
46
+ }
47
+ }
48
+ return undefined;
49
+ };
@@ -0,0 +1,138 @@
1
+ import { isAbsolute } from 'node:path';
2
+ import {
3
+ isRecord,
4
+ parseJsonDocumentsWithValidity,
5
+ } from './reviewed-artifact.ts';
6
+
7
+ export type ReviewedRepositoryContract =
8
+ | { readonly kind: 'none' }
9
+ | { readonly kind: 'invalid'; readonly reason: string }
10
+ | {
11
+ readonly kind: 'valid';
12
+ readonly repositoryCwd: string;
13
+ readonly sourceCwd?: string;
14
+ };
15
+
16
+ const invalidContract = (reason: string): ReviewedRepositoryContract => ({
17
+ kind: 'invalid',
18
+ reason,
19
+ });
20
+
21
+ const isAbsoluteSafePath = (value: unknown): value is string =>
22
+ typeof value === 'string' && isAbsolute(value) && !value.includes('\0');
23
+
24
+ type RepositoryDirectories = {
25
+ readonly repositoryDirectories: ReadonlySet<string>;
26
+ readonly sourceDirectories: ReadonlySet<string>;
27
+ };
28
+
29
+ const addRepositoryDirectories = (
30
+ document: Readonly<Record<string, unknown>>,
31
+ repositoryDirectories: Set<string>,
32
+ sourceDirectories: Set<string>,
33
+ ): ReviewedRepositoryContract | undefined => {
34
+ if (
35
+ !Array.isArray(document.repositories) ||
36
+ document.repositories.length === 0
37
+ ) {
38
+ return invalidContract(
39
+ 'Reviewed repository contract must contain a non-empty repositories array',
40
+ );
41
+ }
42
+
43
+ for (const repository of document.repositories) {
44
+ if (!isRecord(repository)) {
45
+ return invalidContract(
46
+ 'Reviewed repository contract contains a malformed repository entry',
47
+ );
48
+ }
49
+ if (!isAbsoluteSafePath(repository.cwd)) {
50
+ return invalidContract(
51
+ 'Reviewed repository contract repository cwd must be an absolute path',
52
+ );
53
+ }
54
+ repositoryDirectories.add(repository.cwd);
55
+
56
+ if ('sourceCwd' in repository) {
57
+ if (!isAbsoluteSafePath(repository.sourceCwd)) {
58
+ return invalidContract(
59
+ 'Reviewed repository contract sourceCwd must be an absolute path',
60
+ );
61
+ }
62
+ sourceDirectories.add(repository.sourceCwd);
63
+ }
64
+ }
65
+ return undefined;
66
+ };
67
+
68
+ const collectRepositoryDirectories = (
69
+ documents: ReadonlyArray<unknown>,
70
+ ): RepositoryDirectories | ReviewedRepositoryContract => {
71
+ const repositoryDirectories = new Set<string>();
72
+ const sourceDirectories = new Set<string>();
73
+
74
+ for (const document of documents) {
75
+ if (!isRecord(document) || !('repositories' in document)) continue;
76
+ const error = addRepositoryDirectories(
77
+ document,
78
+ repositoryDirectories,
79
+ sourceDirectories,
80
+ );
81
+ if (error) return error;
82
+ }
83
+ return { repositoryDirectories, sourceDirectories };
84
+ };
85
+
86
+ /**
87
+ * Parses and validates the repository-directory portion of a reviewed artifact.
88
+ *
89
+ * This function is the pure validation core; filesystem availability is
90
+ * resolved separately.
91
+ *
92
+ * @param artifact - Human-reviewed artifact text.
93
+ * @returns A validated repository contract, absence, or validation error.
94
+ */
95
+ export const parseReviewedRepositoryContract = (
96
+ artifact: string,
97
+ ): ReviewedRepositoryContract => {
98
+ const parsed = parseJsonDocumentsWithValidity(artifact);
99
+ const hasRepositoryContract = parsed.documents.some(
100
+ (document) => isRecord(document) && 'repositories' in document,
101
+ );
102
+ if (!hasRepositoryContract) {
103
+ return parsed.hasMalformedCandidate
104
+ ? invalidContract('Reviewed repository contract contains malformed JSON')
105
+ : { kind: 'none' };
106
+ }
107
+
108
+ const collected = collectRepositoryDirectories(parsed.documents);
109
+ if ('kind' in collected) return collected;
110
+ if (parsed.hasMalformedCandidate) {
111
+ return invalidContract(
112
+ 'Reviewed repository contract contains malformed JSON',
113
+ );
114
+ }
115
+ if (collected.repositoryDirectories.size !== 1) {
116
+ return invalidContract(
117
+ 'Reviewed repository contract is ambiguous: expected exactly one repository cwd',
118
+ );
119
+ }
120
+ if (collected.sourceDirectories.size > 1) {
121
+ return invalidContract(
122
+ 'Reviewed repository contract is ambiguous: expected at most one sourceCwd',
123
+ );
124
+ }
125
+
126
+ const [repositoryCwd] = collected.repositoryDirectories;
127
+ if (!repositoryCwd) {
128
+ return invalidContract(
129
+ 'Reviewed repository contract is ambiguous: expected exactly one repository cwd',
130
+ );
131
+ }
132
+ const [sourceCwd] = collected.sourceDirectories;
133
+ return {
134
+ kind: 'valid',
135
+ repositoryCwd,
136
+ ...(sourceCwd ? { sourceCwd } : {}),
137
+ };
138
+ };
@@ -0,0 +1,102 @@
1
+ import { statSync } from 'node:fs';
2
+ import { isRecord } from './reviewed-artifact.ts';
3
+ import { parseReviewedRepositoryContract } from './reviewed-repository-contract.ts';
4
+
5
+ export type ReviewedRepositoryCwdResolution =
6
+ | { readonly kind: 'none' }
7
+ | { readonly kind: 'invalid'; readonly reason: string }
8
+ | {
9
+ readonly kind: 'resolved';
10
+ readonly cwd: string;
11
+ readonly repositoryCwd: string;
12
+ readonly bootstrapping: boolean;
13
+ };
14
+
15
+ export type DirectoryState = 'directory' | 'missing' | 'invalid';
16
+
17
+ export type ReviewedRepositoryCwdDependencies = {
18
+ readonly readDirectoryState: (path: string) => DirectoryState;
19
+ };
20
+
21
+ export type ResolveReviewedRepositoryCwd = (
22
+ artifact: string,
23
+ ) => ReviewedRepositoryCwdResolution;
24
+
25
+ const readDirectoryState = (path: string): DirectoryState => {
26
+ try {
27
+ return statSync(path).isDirectory() ? 'directory' : 'invalid';
28
+ } catch (error) {
29
+ const code = isRecord(error) ? error.code : undefined;
30
+ return code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'invalid';
31
+ }
32
+ };
33
+
34
+ const invalidResolution = (
35
+ reason: string,
36
+ ): ReviewedRepositoryCwdResolution => ({
37
+ kind: 'invalid',
38
+ reason,
39
+ });
40
+
41
+ /**
42
+ * Creates a reviewed-directory resolver with an injected filesystem reader.
43
+ *
44
+ * The injected boundary keeps contract parsing and launch-path selection
45
+ * deterministic and independently testable.
46
+ *
47
+ * @param dependencies - Filesystem operations used by the resolver.
48
+ * @returns A resolver bound to those dependencies.
49
+ */
50
+ export const createReviewedRepositoryCwdResolver = (
51
+ dependencies: ReviewedRepositoryCwdDependencies,
52
+ ): ResolveReviewedRepositoryCwd => {
53
+ return (artifact): ReviewedRepositoryCwdResolution => {
54
+ const contract = parseReviewedRepositoryContract(artifact);
55
+ if (contract.kind !== 'valid') return contract;
56
+
57
+ const repositoryState = dependencies.readDirectoryState(
58
+ contract.repositoryCwd,
59
+ );
60
+ if (repositoryState === 'directory') {
61
+ return {
62
+ kind: 'resolved',
63
+ cwd: contract.repositoryCwd,
64
+ repositoryCwd: contract.repositoryCwd,
65
+ bootstrapping: false,
66
+ };
67
+ }
68
+ if (repositoryState === 'invalid') {
69
+ return invalidResolution(
70
+ `Reviewed repository cwd is not an accessible directory: ${contract.repositoryCwd}`,
71
+ );
72
+ }
73
+ if (!contract.sourceCwd) {
74
+ return invalidResolution(
75
+ 'Reviewed repository target is missing and requires exactly one absolute sourceCwd',
76
+ );
77
+ }
78
+ if (dependencies.readDirectoryState(contract.sourceCwd) !== 'directory') {
79
+ return invalidResolution(
80
+ `Reviewed repository sourceCwd is not an existing directory: ${contract.sourceCwd}`,
81
+ );
82
+ }
83
+ return {
84
+ kind: 'resolved',
85
+ cwd: contract.sourceCwd,
86
+ repositoryCwd: contract.repositoryCwd,
87
+ bootstrapping: true,
88
+ };
89
+ };
90
+ };
91
+
92
+ /**
93
+ * Resolves the launch directory from a reviewed repository contract.
94
+ *
95
+ * Malformed, ambiguous, or incomplete contracts never fall back silently.
96
+ *
97
+ * @param artifact - Human-reviewed artifact text.
98
+ * @returns The resolved directory, absence, or a validation error.
99
+ */
100
+ export const resolveReviewedRepositoryCwd = createReviewedRepositoryCwdResolver(
101
+ { readDirectoryState },
102
+ );
@@ -0,0 +1,58 @@
1
+ import type { WorkflowStep } from '../config/types.ts';
2
+ import { authorizeBash } from './bash.ts';
3
+ import { authorizeMcpProxy } from './mcp-authorization.ts';
4
+ import { isAllowedExtensionTool } from './tool-selection.ts';
5
+ import type { ToolAuthorization, ToolInventoryItem } from './tool-types.ts';
6
+
7
+ const reject = (reason: string): ToolAuthorization => ({
8
+ allowed: false,
9
+ reason,
10
+ });
11
+
12
+ /**
13
+ * Authorizes a tool call against the active workflow-step policy.
14
+ *
15
+ * MCP and Bash calls receive their additional protocol-specific validation
16
+ * after the tool itself has been selected.
17
+ *
18
+ * @param toolName - Requested tool name.
19
+ * @param input - Requested tool input.
20
+ * @param step - Active workflow step.
21
+ * @param inventory - Registered tool inventory.
22
+ * @param approvedBashCommands - Exact commands derived from human review.
23
+ * @returns The authorization decision.
24
+ */
25
+ export const authorizeToolCall = (
26
+ toolName: string,
27
+ input: Readonly<Record<string, unknown>>,
28
+ step: WorkflowStep,
29
+ inventory: ReadonlyArray<ToolInventoryItem>,
30
+ approvedBashCommands: ReadonlyArray<string> = [],
31
+ ): ToolAuthorization => {
32
+ if (toolName === 'mcp') {
33
+ return authorizeMcpProxy(input, step.permissions.mcp);
34
+ }
35
+
36
+ const tool = inventory.find((candidate) => candidate.name === toolName);
37
+ const isAllowedByName = step.permissions.tools.includes(toolName);
38
+ const isAllowedByExtension =
39
+ tool !== undefined &&
40
+ isAllowedExtensionTool(tool, step.permissions.extensions);
41
+ if (!isAllowedByName && !isAllowedByExtension) {
42
+ return reject(`tool "${toolName}" is not allowed for this workflow step`);
43
+ }
44
+ if (toolName !== 'bash') return { allowed: true };
45
+
46
+ const command = input.command;
47
+ if (typeof command !== 'string') {
48
+ return reject('Bash call is missing command text');
49
+ }
50
+ const result = authorizeBash(
51
+ command,
52
+ step.permissions.bash,
53
+ approvedBashCommands,
54
+ );
55
+ return result.allowed
56
+ ? { allowed: true }
57
+ : reject(result.reason ?? 'Bash command is not allowed');
58
+ };
@@ -0,0 +1,72 @@
1
+ import type { WorkflowStep } from '../config/types.ts';
2
+ import type { ToolInventoryItem } from './tool-types.ts';
3
+
4
+ const sourceText = (tool: ToolInventoryItem): string =>
5
+ `${tool.sourceInfo?.source ?? ''}\n${tool.sourceInfo?.path ?? ''}`.toLowerCase();
6
+
7
+ const isMcpAdapterTool = (tool: ToolInventoryItem): boolean =>
8
+ sourceText(tool).includes('pi-mcp-adapter');
9
+
10
+ /**
11
+ * Determines whether a tool belongs to an allowed extension selector.
12
+ *
13
+ * Built-in and SDK tools are never treated as extension-provided tools.
14
+ *
15
+ * @param tool - Registered tool inventory item.
16
+ * @param selector - Case-insensitive source or path fragment.
17
+ * @returns `true` when the extension selector matches.
18
+ */
19
+ export const matchesExtensionSelector = (
20
+ tool: ToolInventoryItem,
21
+ selector: string,
22
+ ): boolean => {
23
+ const source = tool.sourceInfo?.source;
24
+ return (
25
+ source !== 'builtin' &&
26
+ source !== 'sdk' &&
27
+ sourceText(tool).includes(selector.toLowerCase())
28
+ );
29
+ };
30
+
31
+ /**
32
+ * Selects tools available to the active workflow step.
33
+ *
34
+ * @param inventory - Registered tool inventory.
35
+ * @param step - Active workflow step.
36
+ * @param completionToolName - Completion tool that must always remain active.
37
+ * @returns Unique selected tool names in inventory order.
38
+ */
39
+ export const resolveActiveTools = (
40
+ inventory: ReadonlyArray<ToolInventoryItem>,
41
+ step: WorkflowStep,
42
+ completionToolName: string,
43
+ ): Array<string> => {
44
+ const exactToolNames = new Set(step.permissions.tools);
45
+ const selectedToolNames = inventory
46
+ .filter(
47
+ (tool) =>
48
+ tool.name === completionToolName ||
49
+ exactToolNames.has(tool.name) ||
50
+ (tool.name === 'mcp' && step.permissions.mcp.length > 0) ||
51
+ (!isMcpAdapterTool(tool) &&
52
+ step.permissions.extensions.some((selector) =>
53
+ matchesExtensionSelector(tool, selector),
54
+ )),
55
+ )
56
+ .map((tool) => tool.name);
57
+ return [...new Set(selectedToolNames)];
58
+ };
59
+
60
+ /**
61
+ * Determines whether a tool can be authorized through an extension selector.
62
+ *
63
+ * @param tool - Registered tool inventory item.
64
+ * @param selectors - Allowed extension selectors.
65
+ * @returns `true` when a non-MCP-adapter extension selector matches.
66
+ */
67
+ export const isAllowedExtensionTool = (
68
+ tool: ToolInventoryItem,
69
+ selectors: ReadonlyArray<string>,
70
+ ): boolean =>
71
+ !isMcpAdapterTool(tool) &&
72
+ selectors.some((selector) => matchesExtensionSelector(tool, selector));
@@ -0,0 +1,14 @@
1
+ export type ToolSourceInfo = {
2
+ readonly source?: string;
3
+ readonly path?: string;
4
+ };
5
+
6
+ export type ToolInventoryItem = {
7
+ readonly name: string;
8
+ readonly sourceInfo?: ToolSourceInfo;
9
+ };
10
+
11
+ export type ToolAuthorization = {
12
+ readonly allowed: boolean;
13
+ readonly reason?: string;
14
+ };