pi-crew 0.8.14 → 0.9.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.
- package/CHANGELOG.md +366 -0
- package/README.md +112 -2
- package/docs/FEATURE_INTAKE.md +1 -1
- package/docs/HARNESS.md +20 -19
- package/docs/PROJECT_REVIEW.md +132 -133
- package/docs/PROJECT_REVIEW_FIXES.md +130 -131
- package/docs/actions-reference.md +127 -121
- package/docs/architecture.md +1 -1
- package/docs/code-review-2026-05-11.md +134 -134
- package/docs/commands-reference.md +108 -106
- package/docs/comparison-pi-subagents-vs-pi-crew.md +105 -105
- package/docs/deep-review-report.md +1 -1
- package/docs/dynamic-workflows.md +90 -0
- package/docs/fixes/BATCH_A_H1_H2.md +17 -17
- package/docs/fixes/bug-007-async-notifier-stale-ctx.md +23 -23
- package/docs/followup-plan-2026-05-12.md +135 -135
- package/docs/followup-review-2026-05-12.md +86 -86
- package/docs/followup-review-round3-2026-05-12.md +123 -123
- package/docs/goals.md +59 -0
- package/docs/implementation-plan-top3.md +4 -4
- package/docs/issue-29-analysis.md +2 -2
- package/docs/oh-my-pi-research.md +154 -154
- package/docs/optimization-plan.md +2 -0
- package/docs/perf/baseline-2026-05.md +9 -9
- package/docs/perf/final-report-2026-05.md +2 -2
- package/docs/perf/sprint-1-report.md +2 -2
- package/docs/perf/sprint-2-report.md +1 -1
- package/docs/perf/upgrade-plan-2026-05.md +72 -72
- package/docs/pi-crew-bugs.md +230 -230
- package/docs/pi-crew-investigation-report.md +102 -102
- package/docs/pi-crew-test-round5.md +4 -4
- package/docs/runtime-analysis-child-vs-live.md +57 -57
- package/docs/runtime-migration-in-process-analysis.md +97 -97
- package/package.json +2 -4
- package/skills/orchestration/SKILL.md +11 -11
- package/src/agents/agent-config.ts +4 -0
- package/src/config/config.ts +39 -0
- package/src/config/types.ts +11 -0
- package/src/extension/action-suggestions.ts +2 -1
- package/src/extension/async-notifier.ts +10 -0
- package/src/extension/help.ts +14 -0
- package/src/extension/registration/commands.ts +27 -0
- package/src/extension/team-tool/destructive-gate.ts +1 -1
- package/src/extension/team-tool/goal-wrap.ts +288 -0
- package/src/extension/team-tool/goal.ts +405 -0
- package/src/extension/team-tool/run.ts +103 -4
- package/src/extension/team-tool/workflow-manage.ts +194 -0
- package/src/extension/team-tool.ts +20 -0
- package/src/hooks/types.ts +3 -1
- package/src/runtime/async-runner.ts +27 -2
- package/src/runtime/background-runner.ts +68 -19
- package/src/runtime/child-pi.ts +9 -1
- package/src/runtime/completion-guard.ts +1 -1
- package/src/runtime/dynamic-workflow-context.ts +450 -0
- package/src/runtime/dynamic-workflow-runner.ts +180 -0
- package/src/runtime/global-worker-cap.ts +96 -0
- package/src/runtime/goal-evaluator.ts +294 -0
- package/src/runtime/goal-loop-runner.ts +612 -0
- package/src/runtime/goal-state-store.ts +209 -0
- package/src/runtime/iteration-hooks.ts +2 -1
- package/src/runtime/pi-args.ts +10 -2
- package/src/runtime/post-checks.ts +2 -1
- package/src/runtime/result-extractor.ts +32 -0
- package/src/runtime/team-runner.ts +11 -1
- package/src/runtime/verification-gates.ts +88 -5
- package/src/runtime/verification-integrity.ts +110 -0
- package/src/runtime/verification-worktree.ts +136 -0
- package/src/runtime/workspace-lock.ts +448 -0
- package/src/schema/config-schema.ts +26 -0
- package/src/schema/team-tool-schema.ts +39 -4
- package/src/state/atomic-write.ts +9 -0
- package/src/state/contracts.ts +14 -0
- package/src/state/crew-init.ts +18 -5
- package/src/state/event-log.ts +7 -1
- package/src/state/state-store.ts +2 -0
- package/src/state/types.ts +82 -0
- package/src/state/worker-atomic-writer.ts +190 -0
- package/src/utils/env-allowlist.ts +30 -0
- package/src/utils/redaction.ts +104 -24
- package/src/utils/safe-paths.ts +55 -14
- package/src/workflows/discover-workflows.ts +25 -1
- package/src/workflows/workflow-config.ts +13 -0
- package/src/worktree/cleanup.ts +2 -1
- package/src/worktree/worktree-manager.ts +4 -3
- package/teams/parallel-research.team.md +1 -1
- package/workflows/examples/hello.dwf.ts +24 -0
package/src/utils/redaction.ts
CHANGED
|
@@ -6,6 +6,36 @@
|
|
|
6
6
|
// Pattern for PEM private keys (possessive quantifier prevents backtracking)
|
|
7
7
|
export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g;
|
|
8
8
|
|
|
9
|
+
// --- P1f (RFC §P1f / §6 STRIDE) — additional anchored, ReDoS-SAFE secret patterns. ---
|
|
10
|
+
// All patterns below are LINEAR-TIME: each uses a single bounded quantifier on a
|
|
11
|
+
// character class (fixed {N} or a plain +) with NO nested quantifiers and NO
|
|
12
|
+
// overlapping alternation. Boundaries are zero-width lookarounds on simple char
|
|
13
|
+
// classes, which are also linear. Do NOT introduce (a+)+-style nesting here.
|
|
14
|
+
//
|
|
15
|
+
// RESIDUAL (documented, Med-High per RFC §6): regex redaction is BEST-EFFORT
|
|
16
|
+
// against an *adversarial* worker that can encode/split/transform secrets
|
|
17
|
+
// (base64, line splits, novel formats, non-pattern env vars). This catches the
|
|
18
|
+
// common/accidental leak; it is NOT a boundary against a determined exfiltrator.
|
|
19
|
+
// Full mitigation ladder: (1) redaction here + at artifact-write; (2) Phase 1.5
|
|
20
|
+
// sanitized-env verification; (3) sandbox (deferred).
|
|
21
|
+
|
|
22
|
+
// JWT — three base64url segments separated by dots, distinctive "eyJ" headers.
|
|
23
|
+
// Linear: single + on [A-Za-z0-9_-] per segment, no nesting.
|
|
24
|
+
export const JWT_PATTERN = /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
|
25
|
+
|
|
26
|
+
// GitHub PAT (classic + fine-grained prefixes) — fixed 36-char base62 tail.
|
|
27
|
+
// Linear: fixed {36} count on a char class (constant time per match position).
|
|
28
|
+
export const GITHUB_PAT_PATTERN = /(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{36}(?![A-Za-z0-9])/g;
|
|
29
|
+
|
|
30
|
+
// AWS access key id — fixed 16-char uppercase-alphanumeric tail.
|
|
31
|
+
// Linear: fixed {16} count on a char class.
|
|
32
|
+
export const AWS_ACCESS_KEY_PATTERN = /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![0-9A-Z])/g;
|
|
33
|
+
|
|
34
|
+
// Optional extras (RFC OQ13) — same ReDoS-safe shape (fixed counts / single +).
|
|
35
|
+
export const SLACK_TOKEN_PATTERN = /(?<![A-Za-z0-9_-])xox[baprs]-[A-Za-z0-9-]{10,}/g;
|
|
36
|
+
export const GOOGLE_API_KEY_PATTERN = /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g;
|
|
37
|
+
export const STRIPE_KEY_PATTERN = /(?<![A-Za-z0-9_])sk_live_[0-9a-zA-Z]{24}(?![0-9a-zA-Z])/g;
|
|
38
|
+
|
|
9
39
|
// Linear-time secret key detection
|
|
10
40
|
// IMPORTANT: This function must maintain linear-time guarantees.
|
|
11
41
|
// The fast-path regex uses simple string alternatives with anchors only (no quantifiers),
|
|
@@ -18,23 +48,52 @@ export function isSecretKey(keyName: string): boolean {
|
|
|
18
48
|
if (/^(token|apikey|api_key|password|secret|credential|authorization|privatekey|private_key)$/.test(lower)) {
|
|
19
49
|
return true;
|
|
20
50
|
}
|
|
21
|
-
// Linear scan for prefix characters followed by keywords
|
|
51
|
+
// Linear scan for prefix characters followed by keywords.
|
|
52
|
+
// FIX (cold-review #1 of Phase 1): use `lower.startsWith(kw, i+1)` + `lower.charAt(...)` to
|
|
53
|
+
// avoid allocating substring+toLowerCase inside the O(n) loop (was O(n^2) — adversarial
|
|
54
|
+
// worker emitting `_`×N+`=` stalled 200KB→29s, 500KB→216s). Now O(n).
|
|
22
55
|
const prefixes = "_.-";
|
|
23
56
|
const keywords = ["token", "api", "key", "password", "passwd", "secret", "credential", "authorization", "private"];
|
|
24
|
-
|
|
57
|
+
|
|
25
58
|
for (let i = 0; i < keyName.length; i++) {
|
|
26
59
|
if (prefixes.includes(keyName[i])) {
|
|
27
|
-
const remaining = keyName.substring(i + 1).toLowerCase();
|
|
28
60
|
for (const kw of keywords) {
|
|
29
|
-
if (
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
61
|
+
if (lower.startsWith(kw, i + 1)) {
|
|
62
|
+
const afterCh = lower.charAt(i + 1 + kw.length);
|
|
63
|
+
if (afterCh === "" || prefixes.includes(afterCh) || /[a-zA-Z0-9]/.test(afterCh)) {
|
|
32
64
|
return true;
|
|
33
65
|
}
|
|
34
66
|
}
|
|
35
67
|
}
|
|
36
68
|
}
|
|
37
69
|
}
|
|
70
|
+
// FIX (P1f, surfaced by notification-sink test): also match camelCase
|
|
71
|
+
// boundaries (e.g. `apiToken`, `clientSecret`, `authKey`) — the separator
|
|
72
|
+
// scan above requires `_-.` between prefix and keyword and MISSES the very
|
|
73
|
+
// common camelCase pattern. Scan: a keyword matches if it appears with a
|
|
74
|
+
// word boundary (start of string, end of string, camelCase lowercase->upper
|
|
75
|
+
// transition, or one of `_-.` separators). Linear: one forward pass.
|
|
76
|
+
for (const kw of keywords) {
|
|
77
|
+
let from = 0;
|
|
78
|
+
while (true) {
|
|
79
|
+
const idx = lower.indexOf(kw, from);
|
|
80
|
+
if (idx === -1) break;
|
|
81
|
+
const before = idx === 0 ? "" : lower.charAt(idx - 1);
|
|
82
|
+
const afterIdx = idx + kw.length;
|
|
83
|
+
const afterCh = afterIdx >= lower.length ? "" : lower.charAt(afterIdx);
|
|
84
|
+
const atStart = idx === 0;
|
|
85
|
+
const atEnd = afterIdx === lower.length;
|
|
86
|
+
const camelBoundary = /[A-Z]/.test(keyName.charAt(afterIdx)); // lowercase->uppercase in original
|
|
87
|
+
const sepBoundary = prefixes.includes(before) || prefixes.includes(afterCh);
|
|
88
|
+
if (atStart || atEnd || camelBoundary || sepBoundary) {
|
|
89
|
+
// Require non-empty chars before/after to avoid matching `api` inside `capitalize`
|
|
90
|
+
const hasBefore = idx > 0;
|
|
91
|
+
const hasAfter = afterIdx < lower.length;
|
|
92
|
+
if (hasBefore || hasAfter) return true;
|
|
93
|
+
}
|
|
94
|
+
from = idx + 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
38
97
|
return false;
|
|
39
98
|
}
|
|
40
99
|
|
|
@@ -124,9 +183,20 @@ export function redactSecretString(value: string): string {
|
|
|
124
183
|
// Replace Authorization headers (non-Bearer format)
|
|
125
184
|
result = redactAuthHeader(result);
|
|
126
185
|
|
|
127
|
-
// Replace Bearer tokens
|
|
186
|
+
// Replace Bearer tokens (run before structured-token patterns so a
|
|
187
|
+
// "Bearer <jwt>" pair is collapsed first; bare tokens are caught below).
|
|
128
188
|
result = redactBearerTokens(result);
|
|
129
189
|
|
|
190
|
+
// P1f: structured secret tokens (JWT / GitHub PAT / AWS keys + optional
|
|
191
|
+
// Slack/Google/Stripe). Best-effort vs adversarial workers (see note above).
|
|
192
|
+
result = result
|
|
193
|
+
.replace(JWT_PATTERN, "***")
|
|
194
|
+
.replace(GITHUB_PAT_PATTERN, "***")
|
|
195
|
+
.replace(AWS_ACCESS_KEY_PATTERN, "***")
|
|
196
|
+
.replace(SLACK_TOKEN_PATTERN, "***")
|
|
197
|
+
.replace(GOOGLE_API_KEY_PATTERN, "***")
|
|
198
|
+
.replace(STRIPE_KEY_PATTERN, "***");
|
|
199
|
+
|
|
130
200
|
// Replace inline secrets: key=value or key:value patterns
|
|
131
201
|
result = redactInlineSecrets(result);
|
|
132
202
|
|
|
@@ -134,51 +204,61 @@ export function redactSecretString(value: string): string {
|
|
|
134
204
|
}
|
|
135
205
|
|
|
136
206
|
// Linear-time inline secret redaction: token=xxx, api_key=xxx, etc.
|
|
207
|
+
// FIX (P1f): previously O(n^2) — after a non-secret alphanumeric run, the loop did
|
|
208
|
+
// i++ (advance 1 char) and re-scanned from i+1, so a long run was rescanned O(n)
|
|
209
|
+
// times = O(n^2). The P1f ReDoS test (300KB no-dot input) surfaced this pre-existing
|
|
210
|
+
// bug. Now advances past the whole run when it isn't a redactable secret -> O(n).
|
|
137
211
|
function redactInlineSecrets(value: string): string {
|
|
138
212
|
const result: string[] = [];
|
|
139
213
|
let i = 0;
|
|
140
|
-
|
|
214
|
+
|
|
141
215
|
while (i < value.length) {
|
|
142
|
-
//
|
|
143
|
-
// Check for secret key followed by = or :
|
|
216
|
+
// Collect a run of key characters (alphanumeric, underscore, hyphen).
|
|
144
217
|
let j = i;
|
|
145
|
-
let keyLen = 0;
|
|
146
|
-
|
|
147
|
-
// Collect key characters (alphanumeric, underscore, hyphen)
|
|
148
218
|
while (j < value.length && /[a-zA-Z0-9_-]/.test(value[j])) {
|
|
149
219
|
j++;
|
|
150
|
-
keyLen++;
|
|
151
220
|
}
|
|
152
|
-
|
|
221
|
+
const keyLen = j - i;
|
|
222
|
+
|
|
223
|
+
let redacted = false;
|
|
153
224
|
if (keyLen > 0 && j < value.length && (value[j] === '=' || value[j] === ':')) {
|
|
154
|
-
const key = value.substring(i,
|
|
155
|
-
|
|
225
|
+
const key = value.substring(i, j);
|
|
226
|
+
|
|
156
227
|
// Check if this is a secret key
|
|
157
228
|
if (isSecretKey(key)) {
|
|
158
229
|
// Find the value (everything after = or : until space, comma, or end)
|
|
159
230
|
const sep = value[j];
|
|
160
231
|
let k = j + 1;
|
|
161
232
|
let valLen = 0;
|
|
162
|
-
while (k < value.length && valLen < 500 && value[k] !== ' ' && value[k] !== ',' && value[k] !== ';' && value[k] !== '"' && value[k] !== '
|
|
233
|
+
while (k < value.length && valLen < 500 && value[k] !== ' ' && value[k] !== ',' && value[k] !== ';' && value[k] !== '"' && value[k] !== '\r' && value[k] !== '\n') {
|
|
163
234
|
k++;
|
|
164
235
|
valLen++;
|
|
165
236
|
}
|
|
166
|
-
|
|
237
|
+
|
|
167
238
|
// Only redact if there's actual content
|
|
168
239
|
if (valLen > 0) {
|
|
169
240
|
result.push(key);
|
|
170
241
|
result.push(sep);
|
|
171
242
|
result.push("***");
|
|
172
243
|
i = k;
|
|
173
|
-
|
|
244
|
+
redacted = true;
|
|
174
245
|
}
|
|
175
246
|
}
|
|
176
247
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
248
|
+
|
|
249
|
+
if (!redacted) {
|
|
250
|
+
if (keyLen > 0) {
|
|
251
|
+
// Not a redactable secret — push the WHOLE run and advance past it (O(n)).
|
|
252
|
+
result.push(value.substring(i, j));
|
|
253
|
+
i = j;
|
|
254
|
+
} else {
|
|
255
|
+
// Single non-key character (space, punctuation, etc.)
|
|
256
|
+
result.push(value[i]);
|
|
257
|
+
i++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
180
260
|
}
|
|
181
|
-
|
|
261
|
+
|
|
182
262
|
return result.join("");
|
|
183
263
|
}
|
|
184
264
|
|
package/src/utils/safe-paths.ts
CHANGED
|
@@ -16,32 +16,73 @@ export function resolveContainedPath(baseDir: string, targetPath: string): strin
|
|
|
16
16
|
}
|
|
17
17
|
const base = path.resolve(baseDir);
|
|
18
18
|
const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
const
|
|
19
|
+
// Normalize BOTH paths to canonical form on ALL platforms. This resolves
|
|
20
|
+
// symlinks so that a base and target that refer to the same physical dir
|
|
21
|
+
// via different paths (Windows 8.3 short-name; macOS /var → /private/var)
|
|
22
|
+
// compare equal. Without this, a legitimately-contained target under an
|
|
23
|
+
// OS-managed symlink is wrongly rejected as "outside" the base.
|
|
24
|
+
const baseNorm = resolveCanonicalPath(base);
|
|
25
|
+
const resolvedNorm = resolveCanonicalPath(resolved);
|
|
25
26
|
const relative = process.platform === "win32"
|
|
26
27
|
? path.relative(baseNorm.toLowerCase(), resolvedNorm.toLowerCase())
|
|
27
|
-
: path.relative(
|
|
28
|
+
: path.relative(baseNorm, resolvedNorm);
|
|
28
29
|
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
|
|
29
30
|
return resolved;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Platform-agnostic canonical path resolution. Resolves symlinks (via
|
|
35
|
+
* realpathSync) to normalize paths so the same physical location always
|
|
36
|
+
* yields the same string regardless of how it was referenced.
|
|
37
|
+
*
|
|
38
|
+
* - Windows: delegates to resolveWindowsCanonical (canonical LONG-NAME form,
|
|
39
|
+
* resolving 8.3 short-name aliases like RUNNER~1 → runneradmin).
|
|
40
|
+
* - POSIX: uses realpathSync, which resolves system symlinks such as the
|
|
41
|
+
* macOS /var → /private/var mapping.
|
|
42
|
+
*
|
|
43
|
+
* For non-existent paths (write targets), walks up to the deepest existing
|
|
44
|
+
* ancestor and joins the remaining components, so the canonical prefix is
|
|
45
|
+
* still comparable.
|
|
46
|
+
*/
|
|
47
|
+
function resolveCanonicalPath(p: string): string {
|
|
48
|
+
if (process.platform === "win32") return resolveWindowsCanonical(p);
|
|
49
|
+
try {
|
|
50
|
+
return fs.realpathSync(p);
|
|
51
|
+
} catch {
|
|
52
|
+
const parts: string[] = [];
|
|
53
|
+
let current = p;
|
|
54
|
+
while (current !== path.dirname(current)) {
|
|
55
|
+
try {
|
|
56
|
+
const real = fs.realpathSync(current);
|
|
57
|
+
let acc = real;
|
|
58
|
+
for (let i = parts.length - 1; i >= 0; i--) acc = path.join(acc, parts[i]);
|
|
59
|
+
return acc;
|
|
60
|
+
} catch { /* keep walking up */ }
|
|
61
|
+
parts.push(path.basename(current));
|
|
62
|
+
current = path.dirname(current);
|
|
63
|
+
}
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
32
68
|
/**
|
|
33
69
|
* On Windows, resolve a path to its canonical (long-name) form.
|
|
34
70
|
* Walks up ancestors until finding one that exists, then joins back down.
|
|
35
71
|
* This handles paths where intermediate directories don't exist yet but
|
|
36
72
|
* their ancestors do (and may use short-name aliases).
|
|
73
|
+
*
|
|
74
|
+
* Uses fs.realpathSync.native (canonical long-name form) as the primary
|
|
75
|
+
* resolver, falling back to non-native realpathSync if .native fails.
|
|
37
76
|
*/
|
|
38
77
|
function resolveWindowsCanonical(p: string): string {
|
|
39
78
|
try {
|
|
40
|
-
// Use
|
|
41
|
-
// On Windows
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
79
|
+
// Use the NATIVE realpath to resolve to canonical LONG-NAME form.
|
|
80
|
+
// On Windows, fs.realpathSync.native resolves 8.3 short-name aliases
|
|
81
|
+
// (e.g. RUNNER~1) to long-name (runneradmin). This is essential for
|
|
82
|
+
// containment checks: base (from cwd, often long-name) and target
|
|
83
|
+
// (from os.tmpdir()/mkdtempSync, often short-name) must normalize to
|
|
84
|
+
// the SAME form or a contained target is wrongly rejected as "outside".
|
|
85
|
+
let real = fs.realpathSync.native(p);
|
|
45
86
|
// Guard against NTFS internal paths (e.g. C:\$Extend\$Deleted)
|
|
46
87
|
if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
|
|
47
88
|
return real;
|
|
@@ -56,8 +97,8 @@ function resolveWindowsCanonical(p: string): string {
|
|
|
56
97
|
let current = p;
|
|
57
98
|
while (current !== path.dirname(current)) {
|
|
58
99
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
100
|
+
let real: string;
|
|
101
|
+
try { real = fs.realpathSync.native(current); } catch { real = fs.realpathSync(current); }
|
|
61
102
|
// Guard against NTFS internal paths
|
|
62
103
|
if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
|
|
63
104
|
// Found existing ancestor — join with remaining parts in reverse order
|
|
@@ -120,11 +120,35 @@ function parseWorkflowFile(filePath: string, source: ResourceSource): WorkflowCo
|
|
|
120
120
|
|
|
121
121
|
function readWorkflowDir(dir: string, source: ResourceSource): WorkflowConfig[] {
|
|
122
122
|
if (!fs.existsSync(dir)) return [];
|
|
123
|
-
|
|
123
|
+
const staticWorkflows = fs.readdirSync(dir)
|
|
124
124
|
.filter((entry) => entry.endsWith(".workflow.md"))
|
|
125
125
|
.map((entry) => parseWorkflowFile(path.join(dir, entry), source))
|
|
126
126
|
.filter((workflow): workflow is WorkflowConfig => workflow !== undefined)
|
|
127
127
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
128
|
+
// P2: also discover dynamic workflows (*.dwf.ts). A .dwf.ts's default export is a JS orchestrator.
|
|
129
|
+
const dynamicWorkflows = fs.readdirSync(dir)
|
|
130
|
+
.filter((entry) => entry.endsWith(".dwf.ts"))
|
|
131
|
+
.map((entry) => parseDynamicWorkflowFile(path.join(dir, entry), source))
|
|
132
|
+
.filter((workflow): workflow is WorkflowConfig => workflow !== undefined);
|
|
133
|
+
return [...staticWorkflows, ...dynamicWorkflows].sort((a, b) => a.name.localeCompare(b.name));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** P2: a .dwf.ts is a dynamic workflow. Name = filename stem; script = the file itself. */
|
|
137
|
+
function parseDynamicWorkflowFile(filePath: string, source: ResourceSource): WorkflowConfig | undefined {
|
|
138
|
+
try {
|
|
139
|
+
const basename = path.basename(filePath, ".dwf.ts");
|
|
140
|
+
return {
|
|
141
|
+
name: basename,
|
|
142
|
+
description: `Dynamic workflow script (${basename}.dwf.ts).`,
|
|
143
|
+
source,
|
|
144
|
+
filePath,
|
|
145
|
+
steps: [],
|
|
146
|
+
runtime: "dynamic",
|
|
147
|
+
dynamicScript: filePath,
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
export function discoverWorkflows(cwd: string): WorkflowDiscoveryResult {
|
|
@@ -39,4 +39,17 @@ export interface WorkflowConfig {
|
|
|
39
39
|
filePath: string;
|
|
40
40
|
steps: WorkflowStep[];
|
|
41
41
|
maxConcurrency?: number;
|
|
42
|
+
/** P2 dynamic-workflow discriminator. Default "static" (the .workflow.md step-list model).
|
|
43
|
+
* "dynamic" = the workflow is a JS/TS script (.dwf.ts) run via dynamic-workflow-runner.
|
|
44
|
+
* Backward-compatible: absent = "static". */
|
|
45
|
+
runtime?: "static" | "dynamic";
|
|
46
|
+
/** For runtime:"dynamic" — relative/absolute path to the .dwf.ts script. Unused for static. */
|
|
47
|
+
dynamicScript?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A dynamic workflow (runtime === "dynamic"). steps is empty — the script is the source of truth. */
|
|
51
|
+
export interface DynamicWorkflowConfig extends WorkflowConfig {
|
|
52
|
+
runtime: "dynamic";
|
|
53
|
+
dynamicScript: string;
|
|
54
|
+
steps: [];
|
|
42
55
|
}
|
package/src/worktree/cleanup.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
4
5
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
5
6
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
6
7
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
@@ -18,7 +19,7 @@ export interface WorktreeCleanupResult {
|
|
|
18
19
|
|
|
19
20
|
// SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
|
|
20
21
|
// Git operations do not need PI_CREW_* execution-control vars.
|
|
21
|
-
const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER",
|
|
22
|
+
const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "C", LC_ALL: "C" };
|
|
22
23
|
|
|
23
24
|
function sanitizeBranchPart(value: string): string {
|
|
24
25
|
return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
4
5
|
import { loadConfig } from "../config/config.ts";
|
|
5
6
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
6
7
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
@@ -27,7 +28,7 @@ export interface WorktreeDiffStat {
|
|
|
27
28
|
function git(cwd: string, args: string[]): string {
|
|
28
29
|
// SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
|
|
29
30
|
// Git operations do not need PI_CREW_* execution-control vars.
|
|
30
|
-
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER",
|
|
31
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }, windowsHide: true }).trim();
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
// Dots are removed from branch names since they are used in path construction,
|
|
@@ -182,7 +183,7 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
|
|
|
182
183
|
timeout: cfg.setupHookTimeoutMs ?? 30_000,
|
|
183
184
|
shell: false, // cmd.exe /c handles batch files safely
|
|
184
185
|
env: sanitizeEnvSecrets(process.env, {
|
|
185
|
-
allowList: ["PATH", "HOME",
|
|
186
|
+
allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL"],
|
|
186
187
|
}),
|
|
187
188
|
windowsHide: true,
|
|
188
189
|
})
|
|
@@ -193,7 +194,7 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
|
|
|
193
194
|
timeout: cfg.setupHookTimeoutMs ?? 30_000,
|
|
194
195
|
shell: false,
|
|
195
196
|
env: sanitizeEnvSecrets(process.env, {
|
|
196
|
-
allowList: ["PATH", "HOME",
|
|
197
|
+
allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL"],
|
|
197
198
|
}),
|
|
198
199
|
windowsHide: true,
|
|
199
200
|
});
|
|
@@ -4,7 +4,7 @@ description: Parallel research team for multi-project/source audits
|
|
|
4
4
|
workspaceMode: single
|
|
5
5
|
defaultWorkflow: parallel-research
|
|
6
6
|
maxConcurrency: 4
|
|
7
|
-
triggers:
|
|
7
|
+
triggers: deep reading, deep read, deep research, source audit, multiple projects, parallel research, pi-*
|
|
8
8
|
category: research
|
|
9
9
|
cost: cheap
|
|
10
10
|
---
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hello.dwf.ts — Minimal reference dynamic-workflow script (P2).
|
|
3
|
+
*
|
|
4
|
+
* Usage: place under `.crew/workflows/hello.dwf.ts`, then
|
|
5
|
+
* team action='run' workflow='hello' goal='Greet the user warmly.'
|
|
6
|
+
*
|
|
7
|
+
* Demonstrates the WorkflowCtx surface: one ctx.agent() call + ctx.setResult().
|
|
8
|
+
* See 07-PLAN.md v3 §3.1 and 00-SPEC.md §3.2.
|
|
9
|
+
*/
|
|
10
|
+
import type { WorkflowCtx } from "../src/runtime/dynamic-workflow-context.ts";
|
|
11
|
+
|
|
12
|
+
export default async function (ctx: WorkflowCtx): Promise<void> {
|
|
13
|
+
const greeting = await ctx.agent({
|
|
14
|
+
role: "executor",
|
|
15
|
+
prompt: `Compose a single-line warm greeting. Context: ${ctx.goal ?? "(no goal)"}`,
|
|
16
|
+
maxTurns: 2,
|
|
17
|
+
});
|
|
18
|
+
// Only ctx.setResult() reaches the main context.
|
|
19
|
+
ctx.setResult("results/greeting.txt", { ok: greeting.ok, model: "executor" });
|
|
20
|
+
// Note: in this trivial example the artifact path is illustrative; production scripts
|
|
21
|
+
// would use ctx.agent()'s returned artifactPath. Here we just surface the agent's text
|
|
22
|
+
// via the summary (runDynamicWorkflow reads the final artifact or falls back).
|
|
23
|
+
void greeting;
|
|
24
|
+
}
|