pi-crew 0.9.12 → 0.9.13
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 +34 -0
- package/README.md +1 -135
- package/package.json +1 -1
- package/src/extension/knowledge-injection.ts +236 -15
- package/src/extension/team-tool/chain-dispatch.ts +103 -0
- package/src/extension/team-tool/chain-executor.ts +400 -0
- package/src/extension/team-tool/run.ts +9 -0
- package/src/runtime/agent-memory.ts +5 -2
- package/src/runtime/background-runner.ts +49 -0
- package/src/runtime/chain-runner.ts +47 -8
- package/src/runtime/child-pi.ts +33 -0
- package/src/runtime/handoff-manager.ts +7 -0
- package/src/runtime/live-session-runtime.ts +10 -4
- package/src/runtime/pi-json-output.ts +5 -1
- package/src/runtime/task-output-context.ts +36 -7
- package/src/runtime/task-runner/prompt-builder.ts +5 -1
- package/src/runtime/task-runner.ts +7 -0
- package/src/schema/team-tool-schema.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v0.9.13] — chain feature + raw worker output + anti-zombie hardening (2026-06-29)
|
|
4
|
+
|
|
5
|
+
Context/compaction efficiency work + a live `chain` feature that wires previously-dead code, plus two root-cause zombie fixes. The unifying theme: **deliver the right context efficiently and never leave orphaned state behind.**
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- **Section-aware knowledge injection** (`knowledge-injection.ts`) — `.crew/knowledge.md` is no longer injected as a uniform head-only block. CONVENTIONS sections (always-relevant: Code Style, Environment, Architecture, Testing, Release Process) are injected in full; SESSION-LOG sections (per-version post-mortems, incidents) are IDF-scored against the task/goal and budget-capped (5 KB), drop-whole with a head-slice fallback and a section-index recovery net. Cuts knowledge-token waste for trivial tasks (a `team action='run'` with a trivial goal went from ~4 000 tokens of mostly-irrelevant session-log noise to ~2 095 tokens of conventions + matching session-log) without starving architecture-relevant tasks. Backward compatible: `readKnowledge(cwd)` with no query keeps the legacy head-only path. Verified live (post-restart research run): worker knowledge dropped from ~4 000 tokens (95 % session-log noise) to ~2 095 tokens, with the synthesized Architecture reference present.
|
|
10
|
+
|
|
11
|
+
- **Live `chain` feature** (`team action='run', chain='"a" -> "b" -> "c"'`) — `ChainRunner` was complete and unit-tested but had ZERO production callers. Each step now runs a REAL team run via an injected `handleRun`, with handoff context (including the previous step's output text) passed forward through a `# Previous Steps in This Chain` block prepended to the step's goal. This also makes `enrichContextFromHandoffs`'s honesty markers (`__chainHistoryNotes`) reachable in production. See `src/extension/team-tool/{chain-dispatch,chain-executor}.ts`.
|
|
12
|
+
|
|
13
|
+
### Bug fixes
|
|
14
|
+
|
|
15
|
+
- **Raw worker result + dependency-context tee recovery (output)** — `results/<id>.txt` was 16 K-capped at child-pi stream-parse time (`compactContentPart` caps assistant text at `MAX_ASSISTANT_TEXT_CHARS`), and the dependency-context path re-read the same capped text circularly (no tee, unlike sharedReads). Now: (a) `ChildPiLineObserver` captures the RAW final assistant text before the transcript's compaction, and `task-runner` writes it as the authoritative `results/<id>.txt` (monotonic-safe fallback to transcript-derived `finalText`); the transcript stays 16 K-capped (telemetry memory bound unchanged). (b) `collectDependencyOutputContext` now uses `readIfSmallWithTee` (matching sharedReads) and `renderDependencyOutputContext` surfaces the `Full output (if you need the missing middle): <path>` hint, breaking the circular re-read. Verified live: a research run's analyst result went from a 16 K-capped file with a `[pi-crew compacted N chars]` marker to a 33 KB RAW file with zero data-loss markers; the downstream writer received 33 K of raw dependency context. Tracing in `research-findings/output-handling-deep-dive.md`.
|
|
16
|
+
|
|
17
|
+
- **Truncated-content recovery hints** — truncation markers in knowledge (`knowledge-injection.ts`), agent memory (`agent-memory.ts`), and dependency output now embed the absolute path + a `read`-tool hint so workers can recover the dropped tail instead of silently losing it. Tee threshold lowered 2× → 1.25× (`TEE_THRESHOLD_MULTIPLIER`) so the 32–64 KB band gets a recovery path. Live-session system prompt no longer duplicates the MEMORY block (it was already injected via `renderTaskPrompt().full`).
|
|
18
|
+
|
|
19
|
+
- **background-runner watchdog (anti-zombie processes)** — the keepAlive interval (`setInterval(()=>{}, 5000)`, no `unref`) cleared only in `runCleanup`, so a hung team run (stuck child Pi, deadlocked lock, or a test that spawns a run without cleanup) left the process alive indefinitely. The existing parent-guard only fires when the parent DIES; here the parent hung too, so it never fired. Fix: a watchdog timer alongside keepAlive aborts via the shared `AbortController` (propagates → child-pi → kills child processes) and force-exits after a 15 s grace if the abort does not propagate. Default 2 h (generous for legitimate long runs); override via `PI_CREW_MAX_RUN_MS`. Verified live: normal run exits cleanly (watchdog cleared, 0 `watchdog_fired` events); isolated hang fires at exactly `MAX` ms.
|
|
20
|
+
|
|
21
|
+
- **Test state isolation (anti-zombie UI rows)** — `useProjectState(cwd)` returns `findRepoRoot(cwd) !== undefined`, and `scopeBaseRoot` falls back to the EXTENSION-GLOBAL state dir (`~/.pi/agent/extensions/pi-crew/state/runs/`) when cwd is NOT a git repo. Tests that create a tmpdir WITHOUT `git init` and call `createRunManifest`/`writeRunFixture`/`createRunPaths` were leaking run records into that global dir — the one the crew widget reads — creating persistent fake-agent rows after every test run. Fix: the shared `createTrackedTempDir` helper and `state-store.test.ts`'s `makeResolvedTempDir` now create a `.git` marker dir so records land in `<tmpdir>/.crew/` (auto-cleaned). Protects all current and future callers. Verified: state-store + cov + chain-executor tests left global state 2 → 2 (zero leak).
|
|
22
|
+
|
|
23
|
+
### Docs
|
|
24
|
+
|
|
25
|
+
- **README** — removed 182 lines of version-by-version highlights (v0.9.0 → v0.9.12, v0.8.x, v0.7.0) that duplicated CHANGELOG.md. README now flows intro → `## Features` straight. Version tags remain only where they qualify a feature (e.g. `(L4, v0.9.8)`) — useful for deciding whether to upgrade.
|
|
26
|
+
|
|
27
|
+
### Stats
|
|
28
|
+
|
|
29
|
+
29 files changed · ~2 498 insertions / ~189 deletions across 7 commits.
|
|
30
|
+
|
|
31
|
+
### Verification
|
|
32
|
+
|
|
33
|
+
- `npx tsc --noEmit`: clean.
|
|
34
|
+
- 44/44 new + modified unit tests pass (`raw-final-text`, `dependency-tee`, `chain-executor`, `chain-handoff-markers`, `background-runner-watchdog`).
|
|
35
|
+
- Live (post-restart): chain 2-step run produced enriched step-2 goal with step 1's output; raw worker result verified end-to-end (33 KB analyst result, 0 markers); watchdog verified on normal path (clean exit) + hang path (fires at MAX ms).
|
|
36
|
+
|
|
3
37
|
## [v0.9.12] — TUI UI/UX polish (21 findings) (2026-06-27)
|
|
4
38
|
|
|
5
39
|
Comprehensive TUI UX review of pi-crew's UI layer (`src/ui/` + `src/utils/visual.ts`). 21 findings (5×P1, 10×P2, 6×P3), all addressed. Full review with evidence-backed file:line citations: `research-findings/pi-crew-uiux-review.md`.
|
package/README.md
CHANGED
|
@@ -39,141 +39,7 @@ npm: pi-crew
|
|
|
39
39
|
repo: https://github.com/baphuongna/pi-crew
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
**v0.9.4 / v0.9.5 / v0.9.8 / v0.9.9**: See [CHANGELOG.md](CHANGELOG.md).
|
|
43
|
-
|
|
44
|
-
### Highlights (v0.6.4 → v0.9.9)
|
|
45
|
-
|
|
46
|
-
A long arc of **trust, cliff-resilience, and robustness** work. Principle: *build
|
|
47
|
-
trust and cliff-resilience, stay lean, delete before adding.*
|
|
48
|
-
|
|
49
|
-
#### v0.9.5 — fix "team run hangs forever at 25%" (2026-06-23)
|
|
50
|
-
Two coupled runtime bugs caused recurring "run stuck at 25% (1/4)" failures
|
|
51
|
-
across 4+ consecutive review/fast-fix runs. The combined symptom: scheduler
|
|
52
|
-
appears to stop responding right after the first task (explorer) finishes, no
|
|
53
|
-
progress to task 2, and `team action='status'` returns "Run not found" with
|
|
54
|
-
**no diagnostic trail** to investigate. Manual `kill` of the parent `pi`
|
|
55
|
-
process was the only workaround.
|
|
56
|
-
|
|
57
|
-
- **🩹 Bug X (proximate cause)** — `purgeStaleActiveRunIndex`
|
|
58
|
-
(`src/runtime/crash-recovery.ts`) destroyed a run's `stateRoot` based on a
|
|
59
|
-
**frozen** `entry.updatedAt` (set once at registration, never refreshed).
|
|
60
|
-
Any long-running legitimate async run (≥5 min) whose worker had exited
|
|
61
|
-
lost its entire durable state. `saveRunTasks()` then silently no-op'd on
|
|
62
|
-
the missing dir, and the workflow could never advance. Fix: corroborate
|
|
63
|
-
liveness via the on-disk `manifest.updatedAt` AND the team-level
|
|
64
|
-
`heartbeat.json`; keep `stateRoot` on cancel so runs stay queryable and
|
|
65
|
-
resumable.
|
|
66
|
-
- **🩹 Bug Y (root cause — why the scheduler died in the first place)** —
|
|
67
|
-
`src/runtime/background-runner.ts` redirected only `console.log` /
|
|
68
|
-
`console.error` to the log file. The first post-detach `console.debug`
|
|
69
|
-
call from `team-runner.ts:242` (inside `mergeTaskUpdatesPreservingTerminal`
|
|
70
|
-
→ "Skipping stale merge") hit the disconnected stdout pipe → unhandled
|
|
71
|
-
`EPIPE` → process exit. Prior investigators concluded (incorrectly) that
|
|
72
|
-
the cause was a native crash, because diagnostic `[DIAG]` handlers never
|
|
73
|
-
fired on the EPIPE. Fix: extend the console redirect to `console.debug` /
|
|
74
|
-
`console.warn`, and wrap `fs.writeSync` in try-catch so any log-write
|
|
75
|
-
failure can never crash the scheduler.
|
|
76
|
-
- **🧪 Regression coverage** — 7 new tests: 3 in
|
|
77
|
-
`test/unit/crash-recovery-purge-liveness.test.ts` (fresh-manifest-kept,
|
|
78
|
-
orphan-cancelled-preserved, fresh-heartbeat-kept) + 4 in
|
|
79
|
-
`test/unit/background-runner-console-redirect.test.ts` (drift-detector
|
|
80
|
-
pattern that exercises undefined / valid / EBADF / post-toggle logFd).
|
|
81
|
-
- **📖 See [CHANGELOG.md](CHANGELOG.md) for full details**, including
|
|
82
|
-
why prior attempts to diagnose the hang kept destroying the only
|
|
83
|
-
evidence (Bug X nuked the stateRoot before anyone could read the EPIPE
|
|
84
|
-
crash in Bug Y).
|
|
85
|
-
|
|
86
|
-
> **Recovering a stuck run from v0.9.4 or earlier:** the `stateRoot` for
|
|
87
|
-
> those runs is already gone. Re-dispatch the workflow — new runs are
|
|
88
|
-
> fully protected.
|
|
89
|
-
|
|
90
|
-
#### v0.9.4 — macOS CI fixture (2026-06-23)
|
|
91
|
-
- **🧪 BSD-vs-GNU grep fix** — benchmark test fixtures used
|
|
92
|
-
`grep --help` (exits 0 on GNU/Linux, exits 2 on BSD/macOS). Switched
|
|
93
|
-
the exit-0 fixture to `echo ok`; the not-in-allowlist fixture is now
|
|
94
|
-
`ls`. CI matrix is now green on all 3 OSes.
|
|
95
|
-
- **📌 Process note** — this release re-commits to: **tag/publish ONLY
|
|
96
|
-
after the full OS matrix CI is green.** v0.9.3 was published mid-CI-run
|
|
97
|
-
(the macOS job hadn't finished); the package itself was correct (the
|
|
98
|
-
broken file is test-only and not shipped), but the repo CI went red.
|
|
99
|
-
v0.9.4 restores green CI. v0.9.5 follows the same discipline.
|
|
100
|
-
|
|
101
|
-
#### v0.9.0 — goal loops + dynamic workflows (2026-06-18)
|
|
102
|
-
Two new features, both modeled on Claude Code, built on a shared `runKind`
|
|
103
|
-
background-dispatch discriminator.
|
|
104
|
-
|
|
105
|
-
- **🎯 Autonomous goal loops** — `team action='goal'` runs a self-directed
|
|
106
|
-
multi-turn loop: a **worker** does a turn, a separate **LLM judge**
|
|
107
|
-
(capability-locked, no tools) evaluates the transcript + verification against
|
|
108
|
-
the objective, and on "not-achieved" the reason is fed into the next turn's
|
|
109
|
-
prompt. Stops on `achieved` / `maxTurns` / budget / `BLOCKED:` / user `stop`.
|
|
110
|
-
See [docs/goals.md](docs/goals.md).
|
|
111
|
-
- **📜 Dynamic workflows (`.dwf.ts`)** — author orchestration as a TypeScript
|
|
112
|
-
script (JS loops/branch/cross-review) instead of a static step list. Runs in
|
|
113
|
-
the background, spawns subagents via `ctx.agent()`/`ctx.fanOut()`, holds
|
|
114
|
-
intermediate results in JS variables, and only `ctx.setResult()` reaches the
|
|
115
|
-
main context. `workflow-create`/`-delete` are ACE-gated (`confirm:true`,
|
|
116
|
-
user-confirmed). See [docs/dynamic-workflows.md](docs/dynamic-workflows.md).
|
|
117
|
-
- **🛡️ Goal-wrap** (RFC v0.5 vision) — apply the goal completion-guarantee to
|
|
118
|
-
existing builtin workflows (`implementation`, `fast-fix`, `default`) via
|
|
119
|
-
per-workflow `.crew/config.json` toggle. Single-step workflows goal-wrap
|
|
120
|
-
end-to-end; multi-step workflows auto-downgrade to a normal team-run because
|
|
121
|
-
they crash non-deterministically under the V8/libuv event-loop (see [Known
|
|
122
|
-
limitations](#known-limitations)).
|
|
123
|
-
- **🔐 Phase 1 integrity hardening** (P1a–P1g) — verification bookend snapshots,
|
|
124
|
-
anti-oscillation (`stuck` non-terminal + resumable), budget enforcement
|
|
125
|
-
(required or explicit opt-out), nonce-token feedback sanitization, secret
|
|
126
|
-
redaction at artifact-write (O(n) fix), global worker cap + workspace lock
|
|
127
|
-
(O_EXCL, startTime-safe). B2 confused-deputy (auto-detecting verification
|
|
128
|
-
commands) refused — user must declare verification explicitly.
|
|
129
|
-
- **🧪 Phase 1.5 fast-follow** — opt-in mitigation toggles for residual risks:
|
|
130
|
-
`PI_CREW_VERIFICATION_SANITIZE_ENV=1` (strip provider secrets from the
|
|
131
|
-
verification subprocess), `PI_CREW_VERIFICATION_WORKTREE=1` (run verification
|
|
132
|
-
in a pristine git worktree at the T_snap commit SHA),
|
|
133
|
-
`PI_CREW_BG_REPORT_ON_FATAL=1` (V8 diagnostic report on fatal).
|
|
134
|
-
- **🐛 TDZ fix** (Phase 1.5 #4) — live `team action='run' workflow='<dynamic>'`
|
|
135
|
-
was failing with a misleading "must export a default async function" error.
|
|
136
|
-
Root cause was a Temporal Dead Zone race in `team-tool/run.ts` when loaded via
|
|
137
|
-
the full Pi extension pipeline (`index.ts → … → run.ts`). Fixed by
|
|
138
|
-
`let`→`var` on the latch + lazy dynamic imports at call sites.
|
|
139
|
-
|
|
140
|
-
#### v0.8.x — hardening & reliability (2026-06-17)
|
|
141
|
-
- **🛠️ Split-scope install fix (v0.8.11)** — `team` runs no longer crash with
|
|
142
|
-
`Cannot find module '@earendil-works/pi-coding-agent'` when pi-crew and pi
|
|
143
|
-
live in separate node_modules trees (the default for `pi install`). New
|
|
144
|
-
`src/runtime/peer-dep.ts` resolves the ESM-only peer dep across 6 strategies.
|
|
145
|
-
- **🔄 Model fallback on transient 5xx (v0.8.11)** — a hard-down provider
|
|
146
|
-
(`500 api_error "unknown error"`) now triggers the configured fallback
|
|
147
|
-
model instead of aborting the run. `isRetryableModelFailure` extended.
|
|
148
|
-
- **🧊 Cold-start race eliminated (v0.8.6 → v0.8.10)** — under tsx, concurrent
|
|
149
|
-
subagent spawns raced module instantiation (`existsSync` / `CREW_README` /
|
|
150
|
-
`effectiveRunConfig` / `validateWorkflowForTeam`). Fixed graph-wide: warm at
|
|
151
|
-
registration + gate at spawn boundaries + per-site latches. 6/6 repro clean.
|
|
152
|
-
- **🔒 Cross-project leak fixed (v0.8.8)** — ambient status / compaction no
|
|
153
|
-
longer bleed foreign-project runs into the current session. Cwd-scope
|
|
154
|
-
barrier (`isInProjectScope`), version-independent.
|
|
155
|
-
- **🩺 Doctor runtime-warmup status (v0.8.7)** — `team doctor` shows whether
|
|
156
|
-
the module-graph warmup fired.
|
|
157
|
-
- **🔍 Cold-verifier agent (v0.8.4)** — adversarial cross-check that re-derives
|
|
158
|
-
claims WITHOUT trusting prior analysis, catching confirmation bias.
|
|
159
|
-
- **⚡ Per-write validator (v0.8.5)** — zero-cost `JSON.parse` on every
|
|
160
|
-
`write`/`edit`, appends a `🔴` blocker on malformed files.
|
|
161
|
-
- **🎨 Terminal status (v0.8.3)** — tab title + Ghostty native progress bar.
|
|
162
|
-
- **🧠 Skill confidence revived (v0.8.2)** — `adjustConfidence()` was dead
|
|
163
|
-
code; the effectiveness system now actually learns.
|
|
164
|
-
- **🔧 Tool-restriction unification (v0.8.0)** — single `resolveToolPolicy`
|
|
165
|
-
across both spawn paths.
|
|
166
|
-
- **🎯 F6/F1 interop granularity (v0.7.9)** — 7 skill roots, `.pi/agents/`
|
|
167
|
-
tier, tool wildcards, `excludeExtensions` denylist.
|
|
168
|
-
|
|
169
|
-
#### v0.7.0 — Phase 0 + Phase 1 roadmap
|
|
170
|
-
- **🛡️ Compaction resilience (O10)** — in-flight runs survive auto-compact.
|
|
171
|
-
- **💰 Cost visibility (O1)** — per-role token + cost attribution.
|
|
172
|
-
- **✋ Plan-level HITL (O5)** — `requirePlanApproval` gates any workflow.
|
|
173
|
-
- **🧠 Cross-run memory (O4)** — `.crew/knowledge.md` injected every run.
|
|
174
|
-
- **🎯 Single-agent cliff hedge** — `team plan singleAgent=true`.
|
|
175
42
|
|
|
176
|
-
---
|
|
177
43
|
|
|
178
44
|
## Features
|
|
179
45
|
|
|
@@ -697,7 +563,7 @@ npm run ci # full CI-equivalent check
|
|
|
697
563
|
npm pack --dry-run # package verification
|
|
698
564
|
```
|
|
699
565
|
|
|
700
|
-
Stats: **
|
|
566
|
+
Stats: **431 source files** (87K lines) · **606 test files** (85K lines) · **~5,860 tests, 0 failures** · **CI: Ubuntu ✅ macOS ✅ Windows ✅**
|
|
701
567
|
|
|
702
568
|
---
|
|
703
569
|
|
package/package.json
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* Philosophy (Round 6 stress-test): radically downsized. Just a Markdown
|
|
10
10
|
* file the user can edit, surfaced into every run. No vector DB, no
|
|
11
11
|
* embeddings, no graph. Simple = trustworthy.
|
|
12
|
+
*
|
|
13
|
+
* B2 section-aware injection (2026-06-28): knowledge.md splits cleanly into
|
|
14
|
+
* CONVENTIONS (always-relevant: Code Style, Environment, Architecture,
|
|
15
|
+
* Testing, Release Process — ~2.5KB) and SESSION-LOG (per-version post-
|
|
16
|
+
* mortems, incidents, fix detail — ~29KB, rarely relevant). Conventions
|
|
17
|
+
* are ALWAYS injected; session-log sections are injected on-demand via
|
|
18
|
+
* header-token IDF scoring against the task/goal, capped at
|
|
19
|
+
* MAX_SESSION_LOG_BYTES. Non-matched session-log is summarized as a section
|
|
20
|
+
* index with a `read` path-hint so the worker can recover any omitted topic.
|
|
21
|
+
* Design doc: research-findings/b2-section-aware-design.md.
|
|
12
22
|
*/
|
|
13
23
|
import * as fs from "node:fs";
|
|
14
24
|
import * as path from "node:path";
|
|
@@ -17,20 +27,179 @@ import { projectCrewRoot } from "../utils/paths.ts";
|
|
|
17
27
|
|
|
18
28
|
/** The knowledge file, relative to the project crew root. */
|
|
19
29
|
export const KNOWLEDGE_FILENAME = "knowledge.md";
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Session-log injection cap (B2). Conventions are always injected in full;
|
|
33
|
+
* matched session-log sections are capped here to bound total prompt size.
|
|
34
|
+
* Sized so 2-3 typical sections (~1-2.5KB each) fit, while the largest single
|
|
35
|
+
* outlier (v0.9.10 IN PROGRESS, ~7KB) is excluded from a full-match scenario.
|
|
36
|
+
*/
|
|
37
|
+
const MAX_SESSION_LOG_BYTES = 5_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Relevance context for section-aware injection. When omitted (or when goal/
|
|
41
|
+
* taskText are both absent), injection falls back to conventions-only — no
|
|
42
|
+
* IDF computation, no session-log body. This keeps callers without query
|
|
43
|
+
* context (main-session hook, legacy tests) stable.
|
|
44
|
+
*/
|
|
45
|
+
export interface KnowledgeQuery {
|
|
46
|
+
/** The team run goal (broad topic signal). */
|
|
47
|
+
goal?: string;
|
|
48
|
+
/** The step/task instruction text (narrow topic signal). */
|
|
49
|
+
taskText?: string;
|
|
50
|
+
/** The worker role (reserved for future role-floor/boost; not scored yet). */
|
|
51
|
+
role?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Headers of sections always treated as CONVENTIONS (universal project rules).
|
|
56
|
+
* Anything NOT matching these (after the convention run) is SESSION-LOG.
|
|
57
|
+
* Header-matching is prefix + case-insensitive against the H2 title.
|
|
58
|
+
*/
|
|
59
|
+
const CONVENTION_HEADERS = ["Code Style", "Environment", "Architecture", "Testing", "Release Process"];
|
|
22
60
|
|
|
23
61
|
/** Resolve the knowledge file path for a cwd (may not exist yet). */
|
|
24
62
|
export function knowledgePath(cwd: string): string {
|
|
25
63
|
return path.join(projectCrewRoot(cwd), KNOWLEDGE_FILENAME);
|
|
26
64
|
}
|
|
27
65
|
|
|
28
|
-
|
|
29
|
-
|
|
66
|
+
interface KnowledgeSection {
|
|
67
|
+
/** H2 header text (without leading `## `). */
|
|
68
|
+
header: string;
|
|
69
|
+
/** Full section body including the `## ` header line. */
|
|
70
|
+
body: string;
|
|
71
|
+
/** Header tokens (lowercased, stopword-filtered) for IDF scoring. */
|
|
72
|
+
headerTokens: Set<string>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const STOPWORDS = new Set([
|
|
76
|
+
"the", "a", "an", "is", "are", "of", "to", "in", "for", "and", "or", "not",
|
|
77
|
+
"with", "this", "that", "it", "be", "on", "at", "by", "do", "does", "how",
|
|
78
|
+
"what", "when", "from", "fix", "fixes", "fixed", "v0", "v1", "released",
|
|
79
|
+
"progress", "uncommitted", "not", "pushed", "published", "session", "post",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** Tokenize header text into a Set of lowercased non-stopword tokens. */
|
|
83
|
+
function tokenizeHeader(header: string): Set<string> {
|
|
84
|
+
const tokens = new Set<string>();
|
|
85
|
+
for (const raw of header.toLowerCase().split(/[^a-z0-9.]+/)) {
|
|
86
|
+
// keep dot-paths (e.g. "run.lock", "config.ts") and alphanumerics >= 2 chars
|
|
87
|
+
const t = raw.replace(/^\.+|\.+$/g, "");
|
|
88
|
+
if (t.length >= 2 && !STOPWORDS.has(t) && !/^\d+$/.test(t)) tokens.add(t);
|
|
89
|
+
}
|
|
90
|
+
return tokens;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse knowledge.md into (conventions, sessionLog) sections by H2 header. */
|
|
94
|
+
function parseKnowledgeSections(content: string): { conventions: KnowledgeSection[]; sessionLog: KnowledgeSection[] } {
|
|
95
|
+
const lines = content.split(/\r?\n/);
|
|
96
|
+
const sections: KnowledgeSection[] = [];
|
|
97
|
+
let current: { header: string; lines: string[] } | null = null;
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const m = /^##\s+(.+?)\s*$/.exec(line);
|
|
100
|
+
if (m) {
|
|
101
|
+
if (current) sections.push({ header: current.header, body: current.lines.join("\n"), headerTokens: tokenizeHeader(current.header) });
|
|
102
|
+
current = { header: m[1]!, lines: [line] };
|
|
103
|
+
} else if (current) {
|
|
104
|
+
current.lines.push(line);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (current) sections.push({ header: current.header, body: current.lines.join("\n"), headerTokens: tokenizeHeader(current.header) });
|
|
108
|
+
|
|
109
|
+
// Classify: a section is a CONVENTION if its header starts-with one of the
|
|
110
|
+
// known convention titles. Once we leave the convention run (first non-
|
|
111
|
+
// matching H2 after conventions), everything else is SESSION-LOG. This is
|
|
112
|
+
// self-healing: if knowledge.md is restructured, the classifier adapts.
|
|
113
|
+
const conventions: KnowledgeSection[] = [];
|
|
114
|
+
const sessionLog: KnowledgeSection[] = [];
|
|
115
|
+
let leftConventionRun = false;
|
|
116
|
+
for (const sec of sections) {
|
|
117
|
+
const isConvention = !leftConventionRun && CONVENTION_HEADERS.some((c) => sec.header.toLowerCase().startsWith(c.toLowerCase()));
|
|
118
|
+
if (isConvention) conventions.push(sec);
|
|
119
|
+
else {
|
|
120
|
+
leftConventionRun = true;
|
|
121
|
+
sessionLog.push(sec);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { conventions, sessionLog };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Compute IDF (inverse document frequency) over session-log header tokens. */
|
|
128
|
+
function computeIdf(sections: KnowledgeSection[]): Map<string, number> {
|
|
129
|
+
const N = sections.length;
|
|
130
|
+
const df = new Map<string, number>();
|
|
131
|
+
for (const s of sections) for (const token of s.headerTokens) df.set(token, (df.get(token) ?? 0) + 1);
|
|
132
|
+
const idf = new Map<string, number>();
|
|
133
|
+
for (const [token, freq] of df) idf.set(token, Math.log(N / freq)); // standard IDF; freq >= 1
|
|
134
|
+
return idf;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Tokenize a free-form query (goal/taskText) the same way as headers. */
|
|
138
|
+
function tokenizeQuery(query: string): Set<string> {
|
|
139
|
+
const tokens = new Set<string>();
|
|
140
|
+
for (const raw of query.toLowerCase().split(/[^a-z0-9.]+/)) {
|
|
141
|
+
const t = raw.replace(/^\.+|\.+$/g, "");
|
|
142
|
+
if (t.length >= 2 && !STOPWORDS.has(t) && !/^\d+$/.test(t)) tokens.add(t);
|
|
143
|
+
}
|
|
144
|
+
return tokens;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Score a section by summed IDF of query tokens present in its header. */
|
|
148
|
+
function scoreSection(queryTokens: Set<string>, headerTokens: Set<string>, idf: Map<string, number>): number {
|
|
149
|
+
let score = 0;
|
|
150
|
+
for (const token of queryTokens) if (headerTokens.has(token)) score += idf.get(token) ?? 0;
|
|
151
|
+
return score;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Select session-log sections relevant to the query, capped at budgetBytes.
|
|
156
|
+
* Greedy by descending score (then original order), drop-whole policy with a
|
|
157
|
+
* head-slice fallback for the single best match if nothing else fits (so a
|
|
158
|
+
* matched query never returns zero bytes).
|
|
159
|
+
*/
|
|
160
|
+
function selectSessionLog(query: string, sessionLog: KnowledgeSection[], budgetBytes: number): KnowledgeSection[] {
|
|
161
|
+
if (sessionLog.length === 0 || !query.trim()) return [];
|
|
162
|
+
const idf = computeIdf(sessionLog);
|
|
163
|
+
const queryTokens = tokenizeQuery(query);
|
|
164
|
+
// Match = ANY header-token overlap with the query. IDF still drives RANKING
|
|
165
|
+
// (rarer overlap ranks first), but a token that happens to appear in every
|
|
166
|
+
// section (IDF=0) must still count as a match — otherwise a query for a
|
|
167
|
+
// common-but-relevant keyword (e.g. "redaction") would return nothing.
|
|
168
|
+
const scored = sessionLog
|
|
169
|
+
.map((sec, idx) => {
|
|
170
|
+
const overlap = [...queryTokens].filter((t) => sec.headerTokens.has(t));
|
|
171
|
+
return { sec, score: overlap.reduce((sum, t) => sum + (idf.get(t) ?? 0), 0), idx, hasOverlap: overlap.length > 0 };
|
|
172
|
+
})
|
|
173
|
+
.filter((x) => x.hasOverlap);
|
|
174
|
+
if (scored.length === 0) return [];
|
|
175
|
+
scored.sort((a, b) => b.score - a.score || a.idx - b.idx);
|
|
176
|
+
|
|
177
|
+
const selected: KnowledgeSection[] = [];
|
|
178
|
+
let used = 0;
|
|
179
|
+
let bestMatch: { sec: KnowledgeSection; score: number } | undefined;
|
|
180
|
+
for (const { sec, score } of scored) {
|
|
181
|
+
if (score > (bestMatch?.score ?? -1)) bestMatch = { sec, score };
|
|
182
|
+
if (used + sec.body.length <= budgetBytes) {
|
|
183
|
+
selected.push(sec);
|
|
184
|
+
used += sec.body.length;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Head-slice fallback: if the best match didn't fit (or nothing fit), inject
|
|
188
|
+
// a head-sliced copy of the single best match so the query isn't empty.
|
|
189
|
+
if (selected.length === 0 && bestMatch) {
|
|
190
|
+
const sliced = bestMatch.sec.body.slice(0, Math.max(0, budgetBytes));
|
|
191
|
+
selected.push({ ...bestMatch.sec, body: `${sliced}\n\n<!-- section truncated (session-log budget ${budgetBytes} bytes). Full file: use \`read\`. -->` });
|
|
192
|
+
}
|
|
193
|
+
return selected;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Read knowledge content. "" if absent/empty. */
|
|
197
|
+
export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
30
198
|
try {
|
|
31
199
|
const p = knowledgePath(cwd);
|
|
32
200
|
const stat = tryStat(p);
|
|
33
201
|
if (!stat) {
|
|
202
|
+
sectionCache.delete(p);
|
|
34
203
|
knowledgeCache.delete(p);
|
|
35
204
|
return "";
|
|
36
205
|
}
|
|
@@ -39,14 +208,46 @@ export function readKnowledge(cwd: string): string {
|
|
|
39
208
|
// For a run with N workers this is N redundant readFileSync of the same
|
|
40
209
|
// file. Cache by (mtimeMs, size) and only re-read when the file changes.
|
|
41
210
|
const cacheKey = `${stat.mtimeMs}:${stat.size}`;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
211
|
+
|
|
212
|
+
// B2: when no query context is provided (main-session hook, legacy
|
|
213
|
+
// callers, tests), keep the simple head-only path. This preserves the
|
|
214
|
+
// 4 existing unit tests exactly (they call readKnowledge(cwd)).
|
|
215
|
+
if (!query || (!query.goal && !query.taskText)) {
|
|
216
|
+
const cached = knowledgeCache.get(p);
|
|
217
|
+
if (cached && cached.key === cacheKey) return cached.content;
|
|
218
|
+
let content = fs.readFileSync(p, "utf8").trim();
|
|
219
|
+
if (content.length > MAX_KNOWLEDGE_HEAD_BYTES) {
|
|
220
|
+
content = `${content.slice(0, MAX_KNOWLEDGE_HEAD_BYTES)}\n\n<!-- knowledge.md truncated at ${MAX_KNOWLEDGE_HEAD_BYTES} bytes (head shown). Full file: ${p} — use the \`read\` tool if you need sections beyond the head. -->`;
|
|
221
|
+
}
|
|
222
|
+
knowledgeCache.set(p, { key: cacheKey, content });
|
|
223
|
+
return content;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Section-aware path: cache parsed sections, select per-query.
|
|
227
|
+
const cachedSections = sectionCache.get(p);
|
|
228
|
+
let parsed: { conventions: KnowledgeSection[]; sessionLog: KnowledgeSection[] };
|
|
229
|
+
if (cachedSections && cachedSections.key === cacheKey) {
|
|
230
|
+
parsed = { conventions: cachedSections.conventions, sessionLog: cachedSections.sessionLog };
|
|
231
|
+
} else {
|
|
232
|
+
const content = fs.readFileSync(p, "utf8").trim();
|
|
233
|
+
parsed = parseKnowledgeSections(content);
|
|
234
|
+
sectionCache.set(p, { key: cacheKey, conventions: parsed.conventions, sessionLog: parsed.sessionLog });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const queryText = [query.goal, query.taskText].filter(Boolean).join(" \n ");
|
|
238
|
+
const matchedSessionLog = selectSessionLog(queryText, parsed.sessionLog, MAX_SESSION_LOG_BYTES);
|
|
239
|
+
|
|
240
|
+
const parts: string[] = [];
|
|
241
|
+
// Conventions: always full.
|
|
242
|
+
for (const sec of parsed.conventions) parts.push(sec.body);
|
|
243
|
+
// Matched session-log (drop-whole, budget-capped).
|
|
244
|
+
for (const sec of matchedSessionLog) parts.push(sec.body);
|
|
245
|
+
// Always: section-index of ALL session-log headers (recovery safety net).
|
|
246
|
+
if (parsed.sessionLog.length > 0) {
|
|
247
|
+
const indexLines = parsed.sessionLog.map((s) => ` - ${s.header}`);
|
|
248
|
+
parts.push(`<!-- Session-log sections in knowledge.md (not injected unless matched above — use \`read\` for detail):\n${indexLines.join("\n")}\nFull file: ${p} -->`);
|
|
47
249
|
}
|
|
48
|
-
|
|
49
|
-
return content;
|
|
250
|
+
return parts.join("\n").trim();
|
|
50
251
|
} catch {
|
|
51
252
|
return "";
|
|
52
253
|
}
|
|
@@ -68,9 +269,19 @@ interface CachedKnowledge {
|
|
|
68
269
|
}
|
|
69
270
|
const knowledgeCache = new Map<string, CachedKnowledge>();
|
|
70
271
|
|
|
272
|
+
interface CachedSections {
|
|
273
|
+
key: string;
|
|
274
|
+
conventions: KnowledgeSection[];
|
|
275
|
+
sessionLog: KnowledgeSection[];
|
|
276
|
+
}
|
|
277
|
+
const sectionCache = new Map<string, CachedSections>();
|
|
278
|
+
|
|
279
|
+
/** Head cap for the no-query (legacy / main-session) path. */
|
|
280
|
+
const MAX_KNOWLEDGE_HEAD_BYTES = 2_000;
|
|
281
|
+
|
|
71
282
|
/** Build the injected prompt fragment (empty if no knowledge). */
|
|
72
|
-
export function buildKnowledgeFragment(cwd: string): string {
|
|
73
|
-
const content = readKnowledge(cwd);
|
|
283
|
+
export function buildKnowledgeFragment(cwd: string, query?: KnowledgeQuery): string {
|
|
284
|
+
const content = readKnowledge(cwd, query);
|
|
74
285
|
if (!content) return "";
|
|
75
286
|
return [
|
|
76
287
|
"",
|
|
@@ -85,8 +296,18 @@ export function buildKnowledgeFragment(cwd: string): string {
|
|
|
85
296
|
|
|
86
297
|
/**
|
|
87
298
|
* Register the knowledge-injection hook. Appends project knowledge to the
|
|
88
|
-
* system prompt on
|
|
89
|
-
*
|
|
299
|
+
* MAIN session's system prompt on `before_agent_start`. This hook does NOT
|
|
300
|
+
* fire for crew workers: they are spawned with `--no-extensions`, so the
|
|
301
|
+
* extension layer (and this hook) never loads in their process. Workers
|
|
302
|
+
* instead receive knowledge via `buildKnowledgeFragment(task.cwd)` injected
|
|
303
|
+
* into their prompt stablePrefix by `prompt-builder.ts`. Do NOT "fix" this
|
|
304
|
+
* perceived gap by making the hook reach workers — it would cause
|
|
305
|
+
* double-injection. (Verified by research workflow 2026-06-28.)
|
|
306
|
+
*
|
|
307
|
+
* The hook calls buildKnowledgeFragment(cwd) with NO query — so the main
|
|
308
|
+
* session gets conventions-only (no session-log noise), which is the right
|
|
309
|
+
* default for an interactive session that doesn't have a single task focus.
|
|
310
|
+
* Workers (which DO have a task) use the section-aware path via prompt-builder.
|
|
90
311
|
*/
|
|
91
312
|
export function registerKnowledgeInjection(pi: ExtensionAPI): void {
|
|
92
313
|
pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team-tool entry point for the `chain` feature.
|
|
3
|
+
*
|
|
4
|
+
* Dispatched from `handleRun` (run.ts) via an early-return guard:
|
|
5
|
+
* if (params.chain) → handleChainRun(params, ctx, handleRun)
|
|
6
|
+
*
|
|
7
|
+
* `handleRun` is passed by reference (NOT imported) to break the
|
|
8
|
+
* run.ts ↔ chain-dispatch.ts import cycle. This file imports only
|
|
9
|
+
* chain-runner, handoff-manager, chain-executor, and the shared
|
|
10
|
+
* tool-result helpers — none of which import run.ts.
|
|
11
|
+
*
|
|
12
|
+
* @see src/extension/team-tool/chain-executor.ts (ChainTeamRunExecutor)
|
|
13
|
+
* @see src/runtime/chain-runner.ts (ChainRunner.runChain, parseChainString)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ChainRunner, parseChainString } from "../../runtime/chain-runner.ts";
|
|
17
|
+
import { HandoffManager } from "../../runtime/handoff-manager.ts";
|
|
18
|
+
import { ChainTeamRunExecutor, type HandleRunFn } from "./chain-executor.ts";
|
|
19
|
+
import { result, type TeamContext } from "./context.ts";
|
|
20
|
+
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
21
|
+
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Execute a chain expression (`"a -> b -> c"`). Each step runs a real team run
|
|
25
|
+
* via the injected `handleRun`, with handoff context passed forward through the
|
|
26
|
+
* goal text. Returns a multi-line summary plus structured `data` for the TUI.
|
|
27
|
+
*
|
|
28
|
+
* @param params - Must contain `chain`. `team`/`workflow`/`model` are forwarded
|
|
29
|
+
* as per-step overrides when a step does not declare its own.
|
|
30
|
+
* @param ctx - The team tool context (cwd is used to load run manifests).
|
|
31
|
+
* @param handleRun - The run.ts `handleRun` function reference (injected).
|
|
32
|
+
*/
|
|
33
|
+
export async function handleChainRun(
|
|
34
|
+
params: TeamToolParamsValue,
|
|
35
|
+
ctx: TeamContext,
|
|
36
|
+
handleRun: HandleRunFn,
|
|
37
|
+
): Promise<PiTeamsToolResult> {
|
|
38
|
+
const chainString = params.chain;
|
|
39
|
+
if (!chainString || chainString.trim().length === 0) {
|
|
40
|
+
return result("Chain expression is empty.", { action: "run", status: "error" }, true);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const spec = parseChainString(chainString);
|
|
44
|
+
if (spec.steps.length === 0) {
|
|
45
|
+
return result(
|
|
46
|
+
"Chain must contain at least one step. Use the form: \"step1 -> step2 -> step3\".",
|
|
47
|
+
{ action: "run", status: "error" },
|
|
48
|
+
true,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Construct the concrete executor with per-step overrides forwarded from the
|
|
53
|
+
// chain invocation (overridden by any step that parsed to a @team reference).
|
|
54
|
+
const executor = new ChainTeamRunExecutor({
|
|
55
|
+
handleRun,
|
|
56
|
+
ctx,
|
|
57
|
+
overrides: {
|
|
58
|
+
team: params.team,
|
|
59
|
+
workflow: params.workflow,
|
|
60
|
+
model: params.model,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Surface the global continueOnError from the parsed spec (e.g. --continue-on-error=true).
|
|
65
|
+
const runner = new ChainRunner(executor, new HandoffManager());
|
|
66
|
+
const chainResult = await runner.runChain(spec, {}, undefined);
|
|
67
|
+
|
|
68
|
+
// Build a readable multi-line summary.
|
|
69
|
+
const lines: string[] = [
|
|
70
|
+
`Chain ${chainResult.success ? "completed" : "ended"}: ${spec.steps.length} step(s), ${chainResult.totalHandoffs.length} handoff(s).`,
|
|
71
|
+
"",
|
|
72
|
+
];
|
|
73
|
+
for (const s of chainResult.steps) {
|
|
74
|
+
const runId = executor.stepRunIds[s.step - 1];
|
|
75
|
+
const tag =
|
|
76
|
+
s.outcome === "success" ? "✓"
|
|
77
|
+
: s.outcome === "failure" ? "✗"
|
|
78
|
+
: s.outcome === "partial" ? "≈"
|
|
79
|
+
: "⊘";
|
|
80
|
+
lines.push(
|
|
81
|
+
`${tag} Step ${s.step} [${s.name}]: ${s.outcome} (${s.duration}ms)${runId ? ` runId=${runId}` : ""}${s.error ? ` | ${s.error}` : ""}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push(
|
|
86
|
+
`Total: ${chainResult.totalDuration}ms${chainResult.totalTokens !== undefined ? `, ${chainResult.totalTokens} tokens` : ""}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return result(
|
|
90
|
+
lines.join("\n"),
|
|
91
|
+
{
|
|
92
|
+
action: "run",
|
|
93
|
+
status: chainResult.success ? "ok" : "error",
|
|
94
|
+
data: {
|
|
95
|
+
chain: true,
|
|
96
|
+
steps: chainResult.steps.length,
|
|
97
|
+
totalTokens: chainResult.totalTokens,
|
|
98
|
+
runIds: executor.stepRunIds,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
!chainResult.success,
|
|
102
|
+
);
|
|
103
|
+
}
|