ace-handbook-integration-pi 0.5.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -88
- data/README.md +2 -2
- data/handbook/extensions/ace-wake/index.js +4 -0
- data/handbook/extensions/ace-wake/wake/inbox-discovery.js +111 -0
- data/handbook/extensions/ace-wake/wake/watch-subscription.js +388 -43
- data/handbook/prompts/continue-work.md +38 -0
- data/lib/ace/handbook/integration/pi/version.rb +1 -1
- metadata +4 -3
- data/handbook/prompts/work-backlog.md +0 -38
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e1e1f851408ab94551b1f23f43b258fdd064b8df2790f0bb2a80ab05363983a
|
|
4
|
+
data.tar.gz: 7fa298d85b1ce4844320dc980d323b28079c2a05a37dd26ea3dc46d159a41f47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75c4ed2da5fce4f238a8e8d7b401ee4ac524548f15fb3e5f574a9bd5f526f7b73eecbefc80241e814e6ac71d055253e619dffb2c87443b8c949f94b34ce304d1
|
|
7
|
+
data.tar.gz: 53bff4c326bad616aff1ddfcf417d16496cd3177f38c6cb35c95d40b2bd5fdcbe6b923883e55241af306c5f03671b96d1538dd2c244417af5928b96fd769560c
|
data/CHANGELOG.md
CHANGED
|
@@ -4,100 +4,28 @@ All notable changes to ace-handbook-integration-pi will be documented in this fi
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
-
## [0.5.0] - 2026-09-28
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
- Shipped the **ace-wake** Pi extension (`handbook/extensions/ace-wake/index.js`, projected to `.pi/extensions/`): session-scoped `/loop` timers and `/watch` file subscriptions that wake the live agent through queued `sendUserMessage` messages only. Wakes are bounded, source-prefixed, coalesce per source while one is queued, and reach busy agents as queued follow-ups that never interrupt an in-flight tool operation. Concurrent sources deliver into an active run immediately (serialization guards only the idle-to-running transition). Delivery ambiguities from Pi's void `sendUserMessage` resolve toward redelivery over loss: unconfirmed attempts re-fire on a bounded recovery window, and watch changes refused while no model is selected retry on that window without needing another filesystem event. Definitions persist as session entries and reconcile exactly once per session start, reload, or branch switch with no missed-tick replay. No cron, systemd, or external heartbeat dependency.
|
|
11
|
-
- Provider manifest now declares `extensions_dir: .pi/extensions`; `ace-handbook sync` projects extension assets and prunes stale projected files via a projection receipt without touching user-authored extensions.
|
|
12
|
-
- Command flags accept quoted values both after the flag (`--message "check the build"`) and inline (`--message="check the build"`); an unterminated quote fails the command instead of storing a truncated value, while prose apostrophes mid-token stay literal.
|
|
13
|
-
|
|
14
|
-
### Changed
|
|
15
|
-
- Renamed the backlog-running prompt from `/loop` to `/work-backlog` (template `handbook/prompts/work-backlog.md`) so the timer command owns the `/loop` name. Behavior unchanged; no alias is provided.
|
|
16
|
-
- Package verification now includes the JavaScript wake suite (run on the installed Node runtime) and an installed-extension acceptance scenario inside the real Pi SDK runtime (`test/feat/`).
|
|
17
|
-
|
|
18
|
-
## [0.4.0] - 2026-09-27
|
|
19
|
-
|
|
20
|
-
### Added
|
|
21
|
-
- Provider manifest now declares `prompts_dir: .pi/prompts`; canonical pi prompt templates from `handbook/prompts/` project there via `ace-handbook sync`.
|
|
22
|
-
- Shipped the canonical `loop` prompt template: the ACE overseer loop running in-process inside a pi session (`/loop`), cycling the task backlog through the `as-task-work` discipline with executed-evidence stop conditions. Projected to `.pi/prompts/loop.md`.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
## [0.3.11] - 2026-09-02
|
|
26
|
-
|
|
27
|
-
### Technical
|
|
28
|
-
- Included in the coordinated all-package patch release preparation for ACE monorepo review.
|
|
29
|
-
## [0.3.10] - 2026-08-12
|
|
30
|
-
|
|
31
|
-
### Technical
|
|
32
|
-
- Raised the `ace-handbook` dependency floor to `~> 0.31` after the generic release workflow baselines release.
|
|
33
|
-
|
|
34
|
-
## [0.3.9] - 2026-08-12
|
|
35
|
-
|
|
36
|
-
### Technical
|
|
37
|
-
- Aligned gemspec dependency floors with current ACE package minor release lines and safe external minor dependency bumps (no major version jumps).
|
|
38
|
-
|
|
39
|
-
## [0.3.8] - 2026-04-22
|
|
40
|
-
|
|
41
|
-
### Technical
|
|
42
|
-
- **ace-handbook-integration-pi v0.3.8**: Bumped patch release to keep dependency constraints aligned with `ace-handbook v0.28.0`.
|
|
43
|
-
|
|
44
|
-
## [0.3.7] - 2026-04-13
|
|
45
|
-
|
|
46
|
-
### Changed
|
|
47
|
-
- **ace-handbook-integration-pi v0.3.7**: Published handbook and HITL migration release changes for PI handbook integration.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
## [0.3.6] - 2026-04-11
|
|
51
|
-
|
|
52
|
-
### Technical
|
|
53
|
-
- Migrated deterministic coverage to `test/fast/` and documented `ace-handbook-integration-pi` as a fast-only package (no `test/feat/` or `test/e2e/` layer).
|
|
54
|
-
|
|
55
|
-
## [0.3.5] - 2026-03-29
|
|
56
|
-
|
|
57
|
-
### Technical
|
|
58
|
-
- Updated the `ace-handbook` runtime dependency range from `~> 0.21` to `~> 0.22` to follow the new handbook minor release.
|
|
59
|
-
|
|
60
|
-
## [0.3.4] - 2026-03-29
|
|
61
|
-
|
|
62
|
-
### Technical
|
|
63
|
-
- Normalized published gem metadata so RubyGems and Ruby Toolbox use current release information instead of the 1980 fallback date.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
## [0.3.3] - 2026-03-29
|
|
8
|
+
## [0.6.0] - 2026-10-20
|
|
67
9
|
|
|
68
10
|
### Fixed
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
- Standardized PI provider casing in README terminology (`PI-specific`, `PI-native`) to match package naming and changelog language.
|
|
75
|
-
|
|
76
|
-
## [0.3.1] - 2026-03-22
|
|
77
|
-
|
|
78
|
-
### Technical
|
|
79
|
-
- Refreshed the package README with standardized handbook integration sections, PI-specific provider wording, and ACE/`ace-handbook` linkage.
|
|
80
|
-
|
|
81
|
-
## [0.3.0] - 2026-03-12
|
|
82
|
-
|
|
83
|
-
### Changed
|
|
84
|
-
- Replaced the legacy `ace-handbook-integration-agent` dependency with a direct `ace-handbook` runtime dependency.
|
|
85
|
-
- Updated the package entrypoint and docs to treat PI integration as a thin provider plugin on top of `ace-handbook`.
|
|
86
|
-
|
|
87
|
-
## [0.2.0] - 2026-03-10
|
|
11
|
+
- `ace-wake` target drop could silently lose a deferred report wake: when a discovered extra target's watcher errored (e.g. `ace-overseer prune`), `dropTarget` in `wake/watch-subscription.js` recomputed the aggregate baseline from CURRENT stats for every remaining path — absorbing an undelivered change (a deferred wake left the fingerprint at its pre-report state, `dirty=true`) into the baseline, so the next settlement boundary saw `next === fingerprint`, cleared dirty, and never woke. The baseline is now adjusted by removing ONLY the dropped target's segment from the recorded fingerprint strings (both `fingerprint` and `previousFingerprint`), keeping every remaining target at its recorded state so the pending change still delivers. Because this shape never touches the filesystem, it also removes the unguarded primary stat that previously ran inside the native `onError` callback: a primary deleted moments before the extra target's error could throw uncaught into the extension host (the primary's own watcher error still deactivates through the documented path). Covered by two new fake-layer scenarios in `test/js/watch-subscription.test.mjs`: a pending undelivered primary wake survives an extra target dropped at settlement, and `dropTarget` no longer throws when the primary was just deleted.
|
|
12
|
+
- `ace-wake` runtime discovery refresh (`wake/watch-subscription.js`): worktree discovery re-ran only inside `handleChange` — the one-shot ~500 ms post-registration reconcile plus events on already-watched paths. A worktree linked long after start whose FIRST report landed in its own (then-unwatched) inbox triggered no refresh pass at all, so the wake was missed until another watched path changed or a re-registration. Settlement boundaries now refresh discovery for EVERY active subscription: `flushDirty()` runs the full refresh pass (target discovery + ancestor→direct re-attachment) for all entries, not only dirty ones, and delivers any fingerprint change as a wake. Covered by a fake-layer scenario (late-linked worktree, report in its own inbox, no watched-path event, wake at the settlement boundary); the real-git late-worktree scenario in `test/js/report-wake-worktree.test.mjs` now expresses production ordering — it waits out the one-shot reconcile before linking the worktree and proves the settlement boundary is the channel that wakes.
|
|
13
|
+
- `ace-wake` discovery micro-race: a report created between the discovery stat proving a target absent and the baseline fingerprint recomputation was absorbed into the baseline (the `absent` state was never recorded), so the first report into that inbox never woke. The baseline now records the PROVEN "absent" state (derived from the ancestor climb, never a re-stat) at registration and at every discovery refresh, and attaching a newly discovered target schedules a follow-up reconcile pass mirroring `start()` — a race-window report stays a pending change and is delivered by that pass. A fake-layer test injects the report exactly inside the stat-throw/recompute window and asserts the wake.
|
|
14
|
+
- `ace-wake` watcher runtime errors on a discovered extra target no longer kill the whole subscription: with runtime discovery, `ace-overseer prune` removing a watched worktree used to deactivate the watch for ALL worktrees (never retried). A runtime error on the primary target keeps the documented deactivate posture; an error on a discovered extra target drops only that target's watcher (the disappearance is absorbed into the baseline — a pruned worktree is not a report) while the subscription stays active, and discovery re-attaches the target if it reappears. A fake-layer test asserts watcher teardown without deactivation and that the primary keeps waking.
|
|
15
|
+
- `ace-wake` report watches now discover worktrees linked AFTER the watch started: inbox expansion (`expandInboxWatchTargets` over `git worktree list`) re-runs on every change/reconcile refresh pass instead of only at registration, so a report filed in a task worktree created while the overseer session is already watching still wakes it — no re-registration, reload, or new queue/cache. A newly discovered target that is still absent joins the baseline fingerprint as its stable "absent" state, so only the first report into it wakes.
|
|
88
16
|
|
|
89
17
|
### Added
|
|
90
|
-
-
|
|
18
|
+
- L7.3 commissioned-work continuation (task 8xg.t.sbz.6.2): new `/continue-work` resume prompt (`handbook/prompts/continue-work.md`) for a pi overseer session. After a report wake or session restart it reads `_overseer` tasks, `ace-assign` assignments, and current project truth, then continues the SAME commissioned process through the existing `ace-overseer` gates (`process-reports`, `work-on`, `handoff`) with single-execution, delivery-repetition-≠-work-repetition, and no-self-seeking rules. Report wake wiring is documented: a `/watch` on `.ace-local/hitl` wakes a waiting session, active turns receive queued follow-ups, and restarts re-register the watch exactly once.
|
|
19
|
+
- JavaScript acceptance suite for the report wake path (`test/js/report-wake.test.mjs`): wake-on-report for an idle session, queued follow-up (never a second execution) during an active turn with coalesced repeats, exactly-once re-registration after restart without replay, and idempotence of an identical repeated notification. Shared session harness extracted to `test/js/session-harness.mjs`; a real-filesystem test proves a file created inside a watched directory (the `.ace-local/hitl` inbox wiring) wakes exactly once.
|
|
91
20
|
|
|
92
21
|
### Changed
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
## [0.1.1] - 2026-03-10
|
|
22
|
+
- `ace-wake` `/watch` now handles directory targets explicitly: the directory itself is watched and any child event is accepted (the mtime/size fingerprint filters noise), so a report file created in `.ace-local/hitl` wakes the session on every platform — Linux inotify delivers child-create events only to a watch on the directory itself, which the previous parent-watch design could never observe. File targets keep the parent-watch + basename-filter behavior that survives atomic replacement.
|
|
23
|
+
- Removed the superseded `/work-backlog` prompt template: its self-seeking backlog cycling (pick any pending project task) contradicts the approved file-based lab contract; `/continue-work` replaces it with no alias.
|
|
96
24
|
|
|
97
|
-
###
|
|
98
|
-
-
|
|
99
|
-
|
|
100
|
-
|
|
25
|
+
### Fixed
|
|
26
|
+
- `ace-wake` watcher re-attachment (`refreshAttachments` in `wake/watch-subscription.js`) could crash the extension host: `watchFactory` may throw synchronously (EMFILE, EPERM, stat race) and re-attachment is reached from native watcher callbacks and the reconcile timer with no guard. The replacement watcher is now attached BEFORE the old ancestor watcher is closed — on an empty result the old watch survives — and a synchronous throw is caught and deactivates the definition (`re-attachment failed: …`, all watchers closed) per the documented runtime-error posture (visible error via `onDeactivate`, never retried) instead of escaping into the host. Covered by a new fake scenario in `test/js/report-wake.test.mjs` (synchronous `watchFactory` throw during re-attachment deactivates the watch, delivers no wake, and leaves no watchers).
|
|
27
|
+
- Cross-worktree report wake (PR393 P2): `ace-hitl create` writes a coworker's report inside THAT coworker's worktree (its own `.ace-local/hitl`), so the documented watch on the main checkout's inbox never fired — reproduced with a real linked worktree, a real `ace-hitl create` and the real watcher (zero wakes). A `/watch` on `.ace-local/hitl` now expands to the inbox of every linked worktree using the same `git worktree list --porcelain` discovery the HITL list uses (new `wake/inbox-discovery.js`, the default `resolveTargets` port of `createWatchPort`, overridable for tests); no second inbox, queue, or cache. The watch fingerprint aggregates all targets — a worktree inbox that does not exist yet is a stable "absent" state watched through its nearest existing ancestor, so the first report into a freshly created inbox still wakes; expansion re-runs at every re-registration (session start/reload/branch switch). `test/js/report-wake.test.mjs` gains cross-worktree scenarios (including a not-yet-existing inbox and exactly-once re-registration across targets) and a new real-git + real-`bin/ace-hitl` scenario in `test/js/report-wake-worktree.test.mjs` fails with the old single-target watch.
|
|
28
|
+
- `ace-wake` watcher re-attachment for formerly-absent inbox targets: when a worktree's `.ace-local/hitl` did not exist at registration, the watcher attached to the nearest existing ancestor — but Linux inotify is non-recursive, so a SECOND report created inside the now-existing inbox fired no event until the next re-registration. Every change/reconcile pass now re-checks targets watched through an ancestor and, once the target exists, closes the ancestor watch and watches the real target directly (`refreshAttachments` in `wake/watch-subscription.js`). Proven by a new fake scenario in `test/js/report-wake.test.mjs` (second report wakes; the ancestor watch is closed and the inbox watched directly) and a second real-`ace-hitl create` step in `test/js/report-wake-worktree.test.mjs`.
|
|
29
|
+
- `expandInboxWatchTargets` now realpath-normalizes its results (worktree roots before joining the inbox suffix, since the inbox itself may not exist yet): symlinked roots (`/tmp` → `/private/tmp`) previously survived as distinct strings, defeating the target dedup in `createWatchPort`'s `resolveTargetsFor` and double-watching one physical inbox.
|
|
30
|
+
- `test/js/report-wake-worktree.test.mjs` no longer sleeps fixed 700/900 ms gaps: an awaited-event helper (`waitFor`, 25 ms poll with a 5 s deadline) awaits each expected wake directly, removing slow-machine flake and shortening the fast path.
|
|
101
31
|
|
|
102
|
-
### Added
|
|
103
|
-
- Initial package scaffold for PI handbook integration support.
|
data/README.md
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
**Consume canonical skills from PI tooling** - keep provider-specific behavior while preserving shared semantics, so agents running under pi-agent get the same skill intent as any other provider.
|
|
23
23
|
|
|
24
|
-
**
|
|
24
|
+
**Resume commissioned work with `/continue-work`** - the canonical `handbook/prompts/continue-work.md` template projects to `.pi/prompts/continue-work.md` (`ace-handbook sync --provider pi`). It is the L7.3 resume prompt for a pi overseer session: after a report wake or a session restart it reads `_overseer` tasks, `ace-assign` assignments, and current project truth, then continues the SAME commissioned process through the existing `ace-overseer` gates (`process-reports`, `work-on`, `handoff`) - one execution, no self-seeking of new work.
|
|
25
25
|
|
|
26
|
-
**Wake idle agents with `/loop` timers and `/watch` file subscriptions** - the bundled ace-wake extension (projected to `.pi/extensions/ace-wake/index.js`) queues bounded wake messages to the live agent through `sendUserMessage` only. Wakes coalesce per source, busy agents receive queued follow-ups instead of interruptions, definitions survive reload/restart with exactly-once re-registration, and there is no cron,
|
|
26
|
+
**Wake idle agents with `/loop` timers and `/watch` file subscriptions** - the bundled ace-wake extension (projected to `.pi/extensions/ace-wake/index.js`) queues bounded wake messages to the live agent through `sendUserMessage` only. Wakes coalesce per source, busy agents receive queued follow-ups instead of interruptions, definitions survive reload/restart with exactly-once re-registration, and there is no cron, daemon, heartbeat, or delivery-queue engine. See [docs/usage.md](docs/usage.md).
|
|
27
27
|
|
|
28
28
|
**Keep provider updates constrained** - update projection assets inside this package instead of canonical definitions, keeping changes isolated from [ace-handbook](../ace-handbook).
|
|
29
29
|
|
|
@@ -169,6 +169,10 @@ function createRegistry(pi, ctx, ports) {
|
|
|
169
169
|
watchFactory: ports.watchFactory ?? nodeWatchFactory,
|
|
170
170
|
statFn: ports.statFn ?? nodeStatFn,
|
|
171
171
|
baseDir: ctx.cwd,
|
|
172
|
+
// The report-inbox watch covers every linked worktree's
|
|
173
|
+
// .ace-local/hitl by default (the same git worktree discovery
|
|
174
|
+
// ace-hitl's list uses); injectable for deterministic tests.
|
|
175
|
+
resolveTargets: ports.resolveTargets,
|
|
172
176
|
scheduleReconcile: ports.scheduleReconcile,
|
|
173
177
|
onDeactivate: () => registry?.refreshStatus(),
|
|
174
178
|
}),
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import * as nodePath from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cross-worktree discovery for the ace-hitl report inbox.
|
|
7
|
+
*
|
|
8
|
+
* `ace-hitl create` writes a coworker's report inside THAT coworker's
|
|
9
|
+
* worktree (its own `.ace-local/hitl`), while the overseer session usually
|
|
10
|
+
* waits in the main checkout — a watch on the main checkout's inbox alone
|
|
11
|
+
* never fires. The HITL list already solves this with multi-worktree
|
|
12
|
+
* discovery over `git worktree list --porcelain`; this module reuses the
|
|
13
|
+
* SAME discovery (no second inbox, queue, or cache) to expand a configured
|
|
14
|
+
* `.ace-local/hitl` watch to every linked worktree's inbox directory.
|
|
15
|
+
*
|
|
16
|
+
* Expansion happens at watch registration, at every re-registration
|
|
17
|
+
* (session start/reload/branch switch), and on every watch refresh pass,
|
|
18
|
+
* so worktrees linked while the session is already watching are picked
|
|
19
|
+
* up without waiting for a re-registration.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The inbox path suffix that marks a watch as the ace-hitl report inbox. */
|
|
23
|
+
export const HITL_INBOX_SEGMENTS = [".ace-local", "hitl"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* List the roots of every linked worktree (including the main checkout)
|
|
27
|
+
* using the same porcelain output ace-hitl's WorktreeScopeResolver reads.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} baseDir session working directory (git runs there)
|
|
30
|
+
* @param {(args: string[], options: object) => string} [execGit] injectable
|
|
31
|
+
* git runner for tests; defaults to execFileSync with output decoding
|
|
32
|
+
* @returns {string[]} unique worktree roots, main first; [] when git fails
|
|
33
|
+
*/
|
|
34
|
+
export function discoverWorktreeRoots(baseDir, execGit = defaultExecGit) {
|
|
35
|
+
let output;
|
|
36
|
+
try {
|
|
37
|
+
output = execGit(["worktree", "list", "--porcelain"], { cwd: baseDir });
|
|
38
|
+
} catch {
|
|
39
|
+
// Not a repository (or git missing): the caller keeps whatever targets
|
|
40
|
+
// it can resolve on its own.
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
return output
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.filter((line) => line.startsWith("worktree "))
|
|
46
|
+
.map((line) => line.slice("worktree ".length).trim())
|
|
47
|
+
.filter((path) => path.length > 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the watch targets for a configured path. A path that names an
|
|
52
|
+
* ace-hitl inbox (any `.ace-local/hitl` directory) expands to that same
|
|
53
|
+
* inbox in EVERY discovered worktree, so a report written inside a
|
|
54
|
+
* coworker's worktree wakes the waiting session. Any other path stays a
|
|
55
|
+
* single target — the watch never invents unrelated directories.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} baseDir session working directory
|
|
58
|
+
* @param {string} configuredPath the path the user configured
|
|
59
|
+
* @param {(args: string[], options: object) => string} [execGit]
|
|
60
|
+
* @returns {string[]} canonical target directories, sorted and unique
|
|
61
|
+
*/
|
|
62
|
+
export function expandInboxWatchTargets(baseDir, configuredPath, execGit = defaultExecGit) {
|
|
63
|
+
const primary = realpathOrSelf(nodePath.resolve(baseDir, configuredPath));
|
|
64
|
+
if (!namesInbox(primary)) {
|
|
65
|
+
return [primary];
|
|
66
|
+
}
|
|
67
|
+
const roots = discoverWorktreeRoots(baseDir, execGit);
|
|
68
|
+
if (roots.length === 0) {
|
|
69
|
+
return [primary];
|
|
70
|
+
}
|
|
71
|
+
// Realpath the (existing) worktree ROOT, not the not-yet-existing inbox:
|
|
72
|
+
// /tmp → /private/tmp style symlinked roots would otherwise survive as
|
|
73
|
+
// separate strings and defeat dedup against the canonicalized primary.
|
|
74
|
+
const targets = new Set(roots.map((root) => nodePath.join(realpathOrSelf(root), ...HITL_INBOX_SEGMENTS)));
|
|
75
|
+
targets.add(primary);
|
|
76
|
+
return [...targets].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} path
|
|
81
|
+
* @returns {string} The realpath when the path exists, the path itself otherwise.
|
|
82
|
+
*/
|
|
83
|
+
function realpathOrSelf(path) {
|
|
84
|
+
try {
|
|
85
|
+
return nodeFs.realpathSync(path);
|
|
86
|
+
} catch {
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {string} path @returns {boolean} */
|
|
92
|
+
function namesInbox(path) {
|
|
93
|
+
const segments = nodePath.normalize(path).split(nodePath.sep);
|
|
94
|
+
const last = HITL_INBOX_SEGMENTS.length;
|
|
95
|
+
for (let index = segments.length - last; index >= 0; index -= 1) {
|
|
96
|
+
const window = segments.slice(index, index + last);
|
|
97
|
+
if (window.join(nodePath.sep) === HITL_INBOX_SEGMENTS.join(nodePath.sep)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string[]} args
|
|
106
|
+
* @param {{cwd: string}} options
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function defaultExecGit(args, options) {
|
|
110
|
+
return execFileSync("git", args, { ...options, encoding: "utf8" });
|
|
111
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as nodeFs from "node:fs";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
3
|
import { WakeError } from "./types.js";
|
|
4
|
+
import { expandInboxWatchTargets } from "./inbox-discovery.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Resolve a configured watch path to the filesystem target: relative paths
|
|
@@ -27,18 +28,30 @@ export function canonicalizeWatchPath(baseDir, path) {
|
|
|
27
28
|
*
|
|
28
29
|
* start() resolves the configured path to a canonical absolute target (relative
|
|
29
30
|
* paths resolve against `baseDir`; symlinks resolve to their target), validates
|
|
30
|
-
* readable metadata, and records a baseline fingerprint.
|
|
31
|
+
* readable metadata, and records a baseline fingerprint. An injected
|
|
32
|
+
* `resolveTargets` port may expand the configured path to several targets —
|
|
33
|
+
* the ace-hitl inbox watch expands `.ace-local/hitl` to every linked
|
|
34
|
+
* worktree's inbox, because `ace-hitl create` writes reports inside the
|
|
35
|
+
* coworker's worktree, not the main checkout. Discovery re-runs on every
|
|
36
|
+
* change/reconcile pass, so worktrees linked AFTER registration are
|
|
37
|
+
* picked up without a re-registration or session reload. The fingerprint
|
|
38
|
+
* aggregates all
|
|
39
|
+
* targets; a target that does not exist yet is a stable "absent" state, so
|
|
40
|
+
* the first report into a freshly created inbox still wakes. Only later fingerprint
|
|
31
41
|
* changes wake the agent, so registering a watch never produces an initial
|
|
32
42
|
* wake. When a wake coalesces into one already queued, the fingerprint stays
|
|
33
43
|
* at the previously delivered state: the queued wake covers the change, and a
|
|
34
44
|
* further change queues a fresh follow-up after settlement.
|
|
35
45
|
*
|
|
36
|
-
* A watcher runtime error
|
|
37
|
-
* marks the subscription inactive with a visible error via the
|
|
38
|
-
* `onDeactivate` hook, and never retries: the user removes and
|
|
39
|
-
* watch explicitly.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
46
|
+
* A watcher runtime error on the configured (primary) path closes the
|
|
47
|
+
* handle, marks the subscription inactive with a visible error via the
|
|
48
|
+
* injected `onDeactivate` hook, and never retries: the user removes and
|
|
49
|
+
* re-adds the watch explicitly. A runtime error on a DISCOVERED extra
|
|
50
|
+
* target (e.g. `ace-overseer prune` removed a watched worktree) drops only
|
|
51
|
+
* that target's watcher and keeps the subscription alive for every other
|
|
52
|
+
* worktree; discovery re-attaches the target if it reappears. `fail()`
|
|
53
|
+
* records the same inactive-error state without a native handle, which
|
|
54
|
+
* reconciliation uses for subscriptions that could not be restarted.
|
|
42
55
|
*
|
|
43
56
|
* @param {object} ports
|
|
44
57
|
* @param {(path: string, handlers: {onChange: () => void, onError: (error: Error) => void}) => {close: () => void}} ports.watchFactory
|
|
@@ -62,9 +75,12 @@ export function canonicalizeWatchPath(baseDir, path) {
|
|
|
62
75
|
* }}
|
|
63
76
|
*/
|
|
64
77
|
const RECONCILE_DELAY_MS = 500;
|
|
78
|
+
// How far above a not-yet-existing target an ancestor watch may climb
|
|
79
|
+
// (enough for <worktree>/.ace-local/hitl inside any sane worktree layout).
|
|
80
|
+
const MAX_ANCESTOR_CLIMB = 6;
|
|
65
81
|
|
|
66
|
-
export function createWatchPort({ watchFactory, statFn, baseDir = process.cwd(), canonicalizeFn = canonicalizeWatchPath, scheduleReconcile = defaultScheduleReconcile, onDeactivate }) {
|
|
67
|
-
/** @type {Map<string, {watcher: {close: () => void}
|
|
82
|
+
export function createWatchPort({ watchFactory, statFn, baseDir = process.cwd(), canonicalizeFn = canonicalizeWatchPath, resolveTargets = expandInboxWatchTargets, scheduleReconcile = defaultScheduleReconcile, onDeactivate }) {
|
|
83
|
+
/** @type {Map<string, {watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], definition: import("./types.js").WatchDefinition, fingerprint: string, previousFingerprint: string, dirty: boolean}>} */
|
|
68
84
|
const active = new Map();
|
|
69
85
|
/** @type {Map<string, string>} */
|
|
70
86
|
const errors = new Map();
|
|
@@ -78,21 +94,21 @@ export function createWatchPort({ watchFactory, statFn, baseDir = process.cwd(),
|
|
|
78
94
|
errors.delete(definition.name);
|
|
79
95
|
|
|
80
96
|
const canonicalPath = canonicalizeFn(baseDir, definition.path);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
// The configured path itself must be readable; expansion targets may
|
|
98
|
+
// legitimately not exist yet (an inbox in a worktree that never
|
|
99
|
+
// received a report), so only the primary is validated strictly.
|
|
100
|
+
readBaseline(canonicalPath);
|
|
101
|
+
const targetPaths = resolveTargetsFor(resolveTargets, baseDir, definition.path, canonicalPath);
|
|
102
|
+
const watchers = targetPaths.flatMap((target) => attachWatcher(target, definition, onWake));
|
|
103
|
+
const paths = watchers.map((entry) => entry.path);
|
|
104
|
+
// A target watched through an ancestor was PROVEN absent just now;
|
|
105
|
+
// its baseline records that fact rather than a re-stat, so a report
|
|
106
|
+
// created between the proof and this recomputation stays a change
|
|
107
|
+
// (the scheduled reconcile below delivers it).
|
|
108
|
+
const provenAbsent = new Set(watchers.filter((each) => each.watchPath !== each.path).map((each) => each.path));
|
|
109
|
+
const fingerprint = aggregateFingerprint(paths, canonicalPath, provenAbsent);
|
|
94
110
|
|
|
95
|
-
active.set(definition.name, {
|
|
111
|
+
active.set(definition.name, { watchers, paths, primaryPath: canonicalPath, definition, fingerprint, previousFingerprint: fingerprint, dirty: false });
|
|
96
112
|
// Close the registration gap: a change between baseline capture and
|
|
97
113
|
// watcher effectiveness produces no callback, so re-stat once.
|
|
98
114
|
scheduleReconcile(() => handleChange(definition, onWake));
|
|
@@ -112,23 +128,32 @@ export function createWatchPort({ watchFactory, statFn, baseDir = process.cwd(),
|
|
|
112
128
|
},
|
|
113
129
|
|
|
114
130
|
/**
|
|
115
|
-
* Re-check subscriptions
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
131
|
+
* Re-check subscriptions after the agent settles (pending markers
|
|
132
|
+
* cleared). Target discovery refreshes for EVERY active subscription:
|
|
133
|
+
* a worktree linked while the session was watching whose first report
|
|
134
|
+
* landed in its own (then-unwatched) inbox produced no watched-path
|
|
135
|
+
* event, so this settlement boundary is the channel that notices it.
|
|
136
|
+
* A subscription whose current state still differs from its last
|
|
137
|
+
* delivered fingerprint fires a fresh wake — no new filesystem event
|
|
138
|
+
* required.
|
|
119
139
|
*
|
|
120
140
|
* @param {(definition: import("./types.js").WatchDefinition) => {delivered: boolean, reason?: string} | undefined} onWake
|
|
121
141
|
*/
|
|
122
142
|
flushDirty(onWake) {
|
|
123
143
|
for (const entry of active.values()) {
|
|
124
|
-
|
|
144
|
+
// Settlement boundaries refresh target discovery for EVERY active
|
|
145
|
+
// subscription, not only dirty ones: a worktree linked long after
|
|
146
|
+
// start whose first report lands in its own (not yet watched)
|
|
147
|
+
// inbox produces no watched-path event, so this pass is the only
|
|
148
|
+
// channel that can notice the new target and wake.
|
|
149
|
+
refreshAttachments(entry, entry.definition, onWake);
|
|
150
|
+
if (active.get(entry.definition.name)?.definition !== entry.definition) {
|
|
125
151
|
continue;
|
|
126
152
|
}
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
153
|
+
const next = currentFingerprint(entry, entry.definition.name);
|
|
154
|
+
if (next === undefined) {
|
|
129
155
|
continue;
|
|
130
156
|
}
|
|
131
|
-
const next = fingerprintOf(current);
|
|
132
157
|
if (next === entry.fingerprint) {
|
|
133
158
|
entry.dirty = false;
|
|
134
159
|
continue;
|
|
@@ -202,10 +227,12 @@ export function createWatchPort({ watchFactory, statFn, baseDir = process.cwd(),
|
|
|
202
227
|
function stopExisting(name) {
|
|
203
228
|
const entry = active.get(name);
|
|
204
229
|
if (entry !== undefined) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
for (const { watcher } of entry.watchers) {
|
|
231
|
+
try {
|
|
232
|
+
watcher.close();
|
|
233
|
+
} catch {
|
|
234
|
+
// Closing an already-broken watcher must not mask the original error.
|
|
235
|
+
}
|
|
209
236
|
}
|
|
210
237
|
active.delete(name);
|
|
211
238
|
}
|
|
@@ -219,11 +246,16 @@ export function createWatchPort({ watchFactory, statFn, baseDir = process.cwd(),
|
|
|
219
246
|
if (entry === undefined || entry.definition !== definition) {
|
|
220
247
|
return;
|
|
221
248
|
}
|
|
222
|
-
|
|
223
|
-
|
|
249
|
+
refreshAttachments(entry, definition, onWake);
|
|
250
|
+
// Re-attachment failure deactivates the subscription; a deactivated
|
|
251
|
+
// definition must not deliver a wake from the same pass.
|
|
252
|
+
if (active.get(definition.name)?.definition !== definition) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const next = currentFingerprint(entry, definition.name);
|
|
256
|
+
if (next === undefined) {
|
|
224
257
|
return;
|
|
225
258
|
}
|
|
226
|
-
const next = fingerprintOf(current);
|
|
227
259
|
if (next === entry.fingerprint) {
|
|
228
260
|
return;
|
|
229
261
|
}
|
|
@@ -269,6 +301,304 @@ export function createWatchPort({ watchFactory, statFn, baseDir = process.cwd(),
|
|
|
269
301
|
return undefined;
|
|
270
302
|
}
|
|
271
303
|
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Resolve the watch targets for one configured path. An injected
|
|
307
|
+
* `resolveTargets` port (the ace-hitl inbox expansion) may return several
|
|
308
|
+
* directories; the validated primary canonical path is always watched,
|
|
309
|
+
* whatever the port returns.
|
|
310
|
+
*
|
|
311
|
+
* @param {((baseDir: string, path: string) => string[]) | undefined} resolveTargets
|
|
312
|
+
* @param {string} baseDir
|
|
313
|
+
* @param {string} configuredPath
|
|
314
|
+
* @param {string} canonicalPath
|
|
315
|
+
* @returns {string[]}
|
|
316
|
+
*/
|
|
317
|
+
function resolveTargetsFor(resolveTargets, baseDir, configuredPath, canonicalPath) {
|
|
318
|
+
if (!resolveTargets) {
|
|
319
|
+
return [canonicalPath];
|
|
320
|
+
}
|
|
321
|
+
const expanded = resolveTargets(baseDir, configuredPath)
|
|
322
|
+
.filter((path) => typeof path === "string" && path.length > 0)
|
|
323
|
+
.map((path) => nodePath.resolve(path));
|
|
324
|
+
return [...new Set([canonicalPath, ...expanded])];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Attach one native watcher for a target. A target that does not exist
|
|
329
|
+
* yet (a worktree inbox with no report so far) is watched through its
|
|
330
|
+
* nearest EXISTING ancestor directory: the ancestor's events re-stat the
|
|
331
|
+
* target, so the first report — which creates the inbox — still wakes.
|
|
332
|
+
* When no ancestor is readable the target is skipped until the next
|
|
333
|
+
* re-registration.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} target
|
|
336
|
+
* @param {import("./types.js").WatchDefinition} definition
|
|
337
|
+
* @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
|
|
338
|
+
* @returns {Array<{path: string, watchPath: string, watcher: {close: () => void}}>}
|
|
339
|
+
*/
|
|
340
|
+
function attachWatcher(target, definition, onWake) {
|
|
341
|
+
const handlers = {
|
|
342
|
+
onChange: () => handleChange(definition, onWake),
|
|
343
|
+
onError: (error) => {
|
|
344
|
+
// A callback from a removed registration must never touch its
|
|
345
|
+
// replacement.
|
|
346
|
+
const entry = active.get(definition.name);
|
|
347
|
+
if (entry === undefined || entry.definition !== definition) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (target !== entry.primaryPath) {
|
|
351
|
+
// A discovered extra target died (the worktree was pruned):
|
|
352
|
+
// drop only this target's watcher, keep the subscription.
|
|
353
|
+
dropTarget(entry, target);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
deactivate(definition.name, describeError(error));
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
let watchPath = target;
|
|
360
|
+
let attempts = 0;
|
|
361
|
+
for (;;) {
|
|
362
|
+
try {
|
|
363
|
+
statFn(watchPath);
|
|
364
|
+
break;
|
|
365
|
+
} catch {
|
|
366
|
+
if (attempts >= MAX_ANCESTOR_CLIMB || watchPath === nodePath.dirname(watchPath)) {
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
attempts += 1;
|
|
370
|
+
watchPath = nodePath.dirname(watchPath);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return [{ path: target, watchPath, watcher: watchFactory(watchPath, handlers) }];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Re-run target discovery and re-attach targets that were watched
|
|
378
|
+
* through an ancestor because they did not exist yet. Runs on every
|
|
379
|
+
* change/reconcile pass — exactly when a formerly-absent target may
|
|
380
|
+
* have appeared, and when a worktree linked AFTER registration may have
|
|
381
|
+
* become discoverable: discovery (the ace-hitl inbox expansion over
|
|
382
|
+
* `git worktree list`) no longer runs only at registration, so a task
|
|
383
|
+
* worktree created while the session is already watching gets its
|
|
384
|
+
* inbox watched without any re-registration or reload.
|
|
385
|
+
*
|
|
386
|
+
* Linux inotify is non-recursive, so an ancestor's watch never sees a
|
|
387
|
+
* SECOND report created inside the now-existing target; once the target
|
|
388
|
+
* exists, close the ancestor watch and watch the real target directly.
|
|
389
|
+
*
|
|
390
|
+
* A newly discovered target that does not exist yet joins the baseline
|
|
391
|
+
* fingerprint as its PROVEN "absent" state (never a re-stat, so a report
|
|
392
|
+
* created between the discovery stat and the baseline recomputation stays
|
|
393
|
+
* a pending change) and a follow-up reconcile pass — mirroring start() —
|
|
394
|
+
* delivers any change that landed in the attach gap; its later appearance
|
|
395
|
+
* (the first report) is the change that wakes, not its discovery.
|
|
396
|
+
*
|
|
397
|
+
* @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
|
|
398
|
+
* @param {import("./types.js").WatchDefinition} definition
|
|
399
|
+
* @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
|
|
400
|
+
*/
|
|
401
|
+
function refreshAttachments(entry, definition, onWake) {
|
|
402
|
+
refreshDiscoveredTargets(entry, definition, onWake);
|
|
403
|
+
if (active.get(definition.name)?.definition !== definition) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
for (let index = 0; index < entry.watchers.length; index += 1) {
|
|
407
|
+
const attached = entry.watchers[index];
|
|
408
|
+
if (attached.watchPath === attached.path) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
statFn(attached.path);
|
|
413
|
+
} catch {
|
|
414
|
+
// Still absent: the ancestor watch remains the only channel.
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
// Attach the replacement BEFORE closing the old watcher:
|
|
419
|
+
// watchFactory can throw synchronously (EMFILE, EPERM, stat race),
|
|
420
|
+
// and handleChange runs inside native watcher callbacks — an
|
|
421
|
+
// uncaught throw here would crash the extension host. On failure
|
|
422
|
+
// the documented runtime-error posture applies: deactivate and
|
|
423
|
+
// never retry.
|
|
424
|
+
const replacement = attachWatcher(attached.path, definition, onWake);
|
|
425
|
+
if (replacement.length > 0) {
|
|
426
|
+
try {
|
|
427
|
+
attached.watcher.close();
|
|
428
|
+
} catch {
|
|
429
|
+
// Closing an already-broken watcher must not block re-attachment.
|
|
430
|
+
}
|
|
431
|
+
entry.watchers[index] = replacement[0];
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
deactivate(definition.name, `re-attachment failed: ${describeError(error)}`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Re-run target discovery for one subscription and attach watchers for
|
|
442
|
+
* targets that appeared since the last pass (a worktree linked after
|
|
443
|
+
* registration, whose inbox the registration-time expansion could not
|
|
444
|
+
* have seen). A new target that already exists keeps the OLD baseline
|
|
445
|
+
* fingerprint, so the pass's fingerprint comparison delivers the wake
|
|
446
|
+
* the missed event never could; a new target that is still absent joins
|
|
447
|
+
* the baseline as its stable "absent" state, so only its later
|
|
448
|
+
* appearance (the first report) wakes.
|
|
449
|
+
*
|
|
450
|
+
* @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
|
|
451
|
+
* @param {import("./types.js").WatchDefinition} definition
|
|
452
|
+
* @param {(definition: import("./types.js").WatchDefinition) => unknown} onWake
|
|
453
|
+
*/
|
|
454
|
+
function refreshDiscoveredTargets(entry, definition, onWake) {
|
|
455
|
+
if (!resolveTargets) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const targetPaths = resolveTargetsFor(resolveTargets, baseDir, definition.path, entry.primaryPath);
|
|
459
|
+
let attachedAny = false;
|
|
460
|
+
for (const target of targetPaths) {
|
|
461
|
+
if (entry.watchers.some((attached) => attached.path === target)) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
let attachedWatcher;
|
|
465
|
+
try {
|
|
466
|
+
attachedWatcher = attachWatcher(target, definition, onWake);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
deactivate(definition.name, `re-attachment failed: ${describeError(error)}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (attachedWatcher.length === 0) {
|
|
472
|
+
// No readable ancestor yet: retried on the next pass.
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
entry.watchers.push(attachedWatcher[0]);
|
|
476
|
+
entry.paths = entry.watchers.map((each) => each.path);
|
|
477
|
+
attachedAny = true;
|
|
478
|
+
if (attachedWatcher[0].watchPath !== target) {
|
|
479
|
+
// The baseline records the "absent" state PROVEN by the ancestor
|
|
480
|
+
// climb, never a re-stat: a report created between the two must
|
|
481
|
+
// remain a pending change. The follow-up reconcile scheduled
|
|
482
|
+
// below delivers it without waiting for another event.
|
|
483
|
+
const baseline = aggregateFingerprint(entry.paths, entry.primaryPath, new Set([target]));
|
|
484
|
+
entry.previousFingerprint = baseline;
|
|
485
|
+
entry.fingerprint = baseline;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (attachedAny) {
|
|
489
|
+
// Mirror start(): a change landing in the attach gap (the race
|
|
490
|
+
// above, or watcher initialization latency on the new target)
|
|
491
|
+
// produces no callback, so re-stat once.
|
|
492
|
+
scheduleReconcile(() => handleChange(definition, onWake));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Drop the watcher of one discovered extra target after a runtime error
|
|
498
|
+
* (typically `ace-overseer prune` removing the worktree): only that
|
|
499
|
+
* target's handle closes; the subscription stays active for the primary
|
|
500
|
+
* and every other worktree. The target's disappearance is absorbed into
|
|
501
|
+
* the baseline — a pruned worktree is not a report and must not wake.
|
|
502
|
+
*
|
|
503
|
+
* The baselines are adjusted by removing ONLY the dropped target's
|
|
504
|
+
* segment from the recorded fingerprint strings. Re-statting every
|
|
505
|
+
* remaining target would fold an undelivered change (a deferred wake
|
|
506
|
+
* left the fingerprint at its pre-change state) into the baseline,
|
|
507
|
+
* silently losing the pending report; the segment removal keeps every
|
|
508
|
+
* remaining target at its recorded state, so the settlement boundary
|
|
509
|
+
* still delivers it. It also never touches the filesystem at all, so a
|
|
510
|
+
* primary deleted moments earlier cannot throw from inside the native
|
|
511
|
+
* onError callback (the primary's own watcher error deactivates the
|
|
512
|
+
* subscription through the documented path).
|
|
513
|
+
*
|
|
514
|
+
* @param {{watchers: Array<{path: string, watchPath: string, watcher: {close: () => void}}>, paths: string[], primaryPath: string, fingerprint: string, previousFingerprint: string}} entry
|
|
515
|
+
* @param {string} target
|
|
516
|
+
*/
|
|
517
|
+
function dropTarget(entry, target) {
|
|
518
|
+
const remaining = [];
|
|
519
|
+
for (const attached of entry.watchers) {
|
|
520
|
+
if (attached.path === target) {
|
|
521
|
+
try {
|
|
522
|
+
attached.watcher.close();
|
|
523
|
+
} catch {
|
|
524
|
+
// Closing an already-broken watcher must not mask the original error.
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
remaining.push(attached);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (remaining.length === entry.watchers.length) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
entry.watchers = remaining;
|
|
534
|
+
entry.paths = remaining.map((each) => each.path);
|
|
535
|
+
// The `=` terminator makes the prefix match the segment of exactly this
|
|
536
|
+
// target, never a target whose path merely extends it.
|
|
537
|
+
const droppedPrefix = `${target}=`;
|
|
538
|
+
entry.fingerprint = withoutSegment(entry.fingerprint, droppedPrefix);
|
|
539
|
+
entry.previousFingerprint = withoutSegment(entry.previousFingerprint, droppedPrefix);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Remove one target's `path=fingerprint` segment from an aggregate
|
|
544
|
+
* fingerprint string, keeping every other segment verbatim.
|
|
545
|
+
*
|
|
546
|
+
* @param {string} fingerprint
|
|
547
|
+
* @param {string} segmentPrefix The exact `path=` prefix of the removed target.
|
|
548
|
+
* @returns {string}
|
|
549
|
+
*/
|
|
550
|
+
function withoutSegment(fingerprint, segmentPrefix) {
|
|
551
|
+
return fingerprint
|
|
552
|
+
.split("|")
|
|
553
|
+
.filter((segment) => !segment.startsWith(segmentPrefix))
|
|
554
|
+
.join("|");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Current aggregate fingerprint of a subscription. An unreadable
|
|
559
|
+
* PRIMARY deactivates the subscription (the configured path itself is
|
|
560
|
+
* the contract); an unreadable extra target is a stable "absent" state
|
|
561
|
+
* that changes to a real fingerprint once the target appears.
|
|
562
|
+
*
|
|
563
|
+
* @param {{paths: string[], primaryPath: string}} entry
|
|
564
|
+
* @param {string} name
|
|
565
|
+
* @returns {string | undefined}
|
|
566
|
+
*/
|
|
567
|
+
function currentFingerprint(entry, name) {
|
|
568
|
+
try {
|
|
569
|
+
return aggregateFingerprint(entry.paths, entry.primaryPath);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
deactivate(name, describeError(error));
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* @param {string[]} paths sorted target paths
|
|
578
|
+
* @param {string} primaryPath
|
|
579
|
+
* @param {Set<string>} [forcedAbsent] Targets recorded as "absent" without
|
|
580
|
+
* re-statting — the proven-at-discovery baseline of a newly attached
|
|
581
|
+
* target, so a report created in the stat/recompute gap stays a change.
|
|
582
|
+
* @returns {string}
|
|
583
|
+
*/
|
|
584
|
+
function aggregateFingerprint(paths, primaryPath, forcedAbsent) {
|
|
585
|
+
return [...paths]
|
|
586
|
+
.sort()
|
|
587
|
+
.map((path) => {
|
|
588
|
+
if (forcedAbsent?.has(path)) {
|
|
589
|
+
return `${path}=absent`;
|
|
590
|
+
}
|
|
591
|
+
if (path === primaryPath) {
|
|
592
|
+
return `${path}=${fingerprintOf(statFn(path))}`;
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
return `${path}=${fingerprintOf(statFn(path))}`;
|
|
596
|
+
} catch {
|
|
597
|
+
return `${path}=absent`;
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
.join("|");
|
|
601
|
+
}
|
|
272
602
|
}
|
|
273
603
|
|
|
274
604
|
/**
|
|
@@ -302,16 +632,31 @@ function describeError(error) {
|
|
|
302
632
|
/**
|
|
303
633
|
* Default watchFactory over node:fs.watch.
|
|
304
634
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
635
|
+
* Directory targets are watched directly and accept any child event: on
|
|
636
|
+
* Linux (inotify) a child file's creation is delivered only to a watch on
|
|
637
|
+
* the directory itself, never to a watch on its parent. The mtime/size
|
|
638
|
+
* fingerprint comparison filters noise, so accepting every event is safe.
|
|
639
|
+
*
|
|
640
|
+
* File targets keep watching the parent directory and filter events to the
|
|
641
|
+
* target's basename: watching the file itself keeps following the original
|
|
642
|
+
* inode, so atomic replacement (write-to-temporary + rename) would silently
|
|
643
|
+
* detach the subscription after one change.
|
|
309
644
|
*
|
|
310
|
-
* @param {string} path Canonical path of the watched file.
|
|
645
|
+
* @param {string} path Canonical path of the watched file or directory.
|
|
311
646
|
* @param {{onChange: () => void, onError: (error: Error) => void}} handlers
|
|
312
647
|
* @returns {{close: () => void}}
|
|
313
648
|
*/
|
|
314
649
|
export function nodeWatchFactory(path, handlers) {
|
|
650
|
+
if (nodeFs.statSync(path).isDirectory()) {
|
|
651
|
+
const watcher = nodeFs.watch(path, { persistent: false }, () => {
|
|
652
|
+
// Any child event is checked: the fingerprint comparison filters
|
|
653
|
+
// non-changes, including nested relative paths some platforms report.
|
|
654
|
+
handlers.onChange();
|
|
655
|
+
});
|
|
656
|
+
watcher.on("error", (error) => handlers.onError(error));
|
|
657
|
+
watcher.unref?.();
|
|
658
|
+
return watcher;
|
|
659
|
+
}
|
|
315
660
|
const directory = nodePath.dirname(path);
|
|
316
661
|
const target = nodePath.basename(path);
|
|
317
662
|
const watcher = nodeFs.watch(
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Resume the commissioned overseer process after a report wake or session restart — same process, same assignments, no self-seeking
|
|
3
|
+
argument-hint: "[report-ref]"
|
|
4
|
+
source: ace-handbook-integration-pi
|
|
5
|
+
last_modified: 2026-10-19
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Resume the **already commissioned** overseer process in this pi session. This prompt runs on two triggers only: a report/notification message woke the session, or the session restarted. Either way the job is the same — continue the existing commissioned process and its assignments. This session is the one overseer execution: a message arriving while you are already working is a follow-up for THIS run, processed in this turn. Never start a second overseer, window, loop, or agent for the same commission.
|
|
9
|
+
|
|
10
|
+
## Arguments
|
|
11
|
+
|
|
12
|
+
`${1:-}` is an optional report/event reference from the waking message. When omitted, discover new reports in step 2.
|
|
13
|
+
|
|
14
|
+
## Resume cycle
|
|
15
|
+
|
|
16
|
+
1. **Read truth, not memory.** Reconstruct the current state from the files that own it — never from what the previous turn believed:
|
|
17
|
+
- Private overseer tasks: `ace-task --in _overseer list`, then `ace-task --in _overseer show <ref>` for every `in-progress` task. These define the commissioned process.
|
|
18
|
+
- Assignments: the `ace-assign` plans under `.ace-local/assign/` for those tasks. Steps are read from the assignment; they are not copied anywhere else.
|
|
19
|
+
- Project truth: `ace-task list`, `ace-overseer status`, `ace-git-worktree list`, and `git status --short` in each involved worktree.
|
|
20
|
+
2. **Process reports.** Run `ace-overseer process-reports` so incoming ace-hitl reports are interpreted against the managing task and the queue continues. Delivery repetition is not work repetition: an already-processed report (same event id) must not trigger the work again — `process-reports` dedups by event state; trust its output, not the count of wake messages.
|
|
21
|
+
3. **Continue the commissioned work.** Run `ace-overseer work-on` to execute the next unblocked step(s) of the commissioned process, and `ace-overseer handoff` when a role finishes and the worktree passes to the next one. Record durable progress in the task folder with `ace-task update` (never edit frontmatter directly).
|
|
22
|
+
|
|
23
|
+
## Stop conditions
|
|
24
|
+
|
|
25
|
+
Stop this cycle and give a summary when one of these holds:
|
|
26
|
+
|
|
27
|
+
- **Commission terminal.** The managing `_overseer` task is `done` — close out with a final report via `ace-hitl`.
|
|
28
|
+
- **Blocked on a decision or an owner.** A blocker cannot be verified or cleared without a human or another owner. Record it with `ace-task update`, send the question via `ace-hitl`, and wait. Do not fill the wait with other work.
|
|
29
|
+
- **Interrupted.** The user interjects. Yield immediately and summarize the state.
|
|
30
|
+
|
|
31
|
+
## Non-negotiable rules
|
|
32
|
+
|
|
33
|
+
- **No self-seeking.** Continue only the commissioned process, its assignments, and unblocked subtasks. When there is no new commissioned work, record that state and wait — do not pick project tasks, backlog items, or "useful" work on your own initiative.
|
|
34
|
+
- **Single execution.** Repeated notifications (timer re-fire, redelivered report, duplicate wake) are transport noise for the same commission: verify against report/task state, then continue once. They never justify a second execution.
|
|
35
|
+
- **One active worker per worktree.** Before handing off or starting a step, confirm with `ace-git-worktree list` / `ace-overseer status` that the previous role finished; sequential roles share one worktree.
|
|
36
|
+
- An unreadable source (missing `_overseer` folder, unreadable assignment, dead runtime) is an error to report, not an empty-and-ready state, and never a reason to start replacement work on the same worktree.
|
|
37
|
+
- Never reset or discard unrelated changes; commit path-scoped with `ace-git-commit <paths>`.
|
|
38
|
+
- Temp files go to `/tmp/` or `.ace-local/<subfolder>/`, never the project root.
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-handbook-integration-pi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-20 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-handbook
|
|
@@ -36,13 +36,14 @@ files:
|
|
|
36
36
|
- README.md
|
|
37
37
|
- handbook/extensions/ace-wake/index.js
|
|
38
38
|
- handbook/extensions/ace-wake/package.json
|
|
39
|
+
- handbook/extensions/ace-wake/wake/inbox-discovery.js
|
|
39
40
|
- handbook/extensions/ace-wake/wake/loop-subscription.js
|
|
40
41
|
- handbook/extensions/ace-wake/wake/types.js
|
|
41
42
|
- handbook/extensions/ace-wake/wake/wake-command-parser.js
|
|
42
43
|
- handbook/extensions/ace-wake/wake/wake-dispatcher.js
|
|
43
44
|
- handbook/extensions/ace-wake/wake/wake-registry.js
|
|
44
45
|
- handbook/extensions/ace-wake/wake/watch-subscription.js
|
|
45
|
-
- handbook/prompts/work
|
|
46
|
+
- handbook/prompts/continue-work.md
|
|
46
47
|
- lib/ace/handbook/integration/pi.rb
|
|
47
48
|
- lib/ace/handbook/integration/pi/version.rb
|
|
48
49
|
homepage: https://github.com/cs3b/ace
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Run the ACE overseer loop in-process — pick, execute, and close backlog tasks one after another until a stop condition fires
|
|
3
|
-
argument-hint: "[task-ref]"
|
|
4
|
-
source: ace-handbook-integration-pi
|
|
5
|
-
last_modified: 2026-09-27
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
Run the ACE overseer loop **in-process**: this pi session is the overseer. Work the task backlog one task at a time from selection to terminal state, then continue with the next task -- no tmux windows, no detached workers, no external orchestrator. The loop lives in this session; every cycle uses the project's ACE CLI tools.
|
|
9
|
-
|
|
10
|
-
## Arguments
|
|
11
|
-
|
|
12
|
-
`${1:-}` is an optional task ref for the first cycle. When omitted, select from the backlog.
|
|
13
|
-
|
|
14
|
-
## Loop cycle
|
|
15
|
-
|
|
16
|
-
Repeat until a stop condition fires:
|
|
17
|
-
|
|
18
|
-
1. **Select.** If `${1:-}` is set and not yet consumed, use it for this cycle and clear it afterwards. Otherwise run `ace-task list` and pick the first `pending` task in priority order that is actionable. Never pick a `done` task; skip tasks whose blocker is confirmed by an executed check (see below).
|
|
19
|
-
2. **Execute.** Load the canonical `as-task-work` skill (`/skill:as-task-work <ref>`, or read `.pi/skills/as-task-work/SKILL.md`) and follow it end-to-end for the chosen ref: load the task spec and plan, mark the task in-progress, then work the plan step by step -- test after every change, commit each logical step with `ace-git-commit <paths>`, and leave the task in-progress (final delivery owns `done`).
|
|
20
|
-
3. **Verify.** Before closing the cycle: `git status --short` is clean for everything your work produced, and the task's plan checklist and success criteria are satisfied -- or blockers are recorded with executed evidence.
|
|
21
|
-
4. **Report.** One progress update per cycle: task ref, outcome (completed / blocked / skipped), and the executed command + observed output that proves it.
|
|
22
|
-
|
|
23
|
-
## Stop conditions
|
|
24
|
-
|
|
25
|
-
Stop the loop and give a final summary only when one of these holds:
|
|
26
|
-
|
|
27
|
-
- **Backlog drained.** `ace-task list` shows no actionable `pending` work.
|
|
28
|
-
- **Human decision required.** A task's spec is ambiguous or incomplete, or a blocker cannot be verified or cleared without the user. Do not assume and do not silently skip -- report the blocker together with the executed check that surfaced it.
|
|
29
|
-
- **Interrupted.** The user interjects. Yield immediately and summarize the state of every cycle run so far.
|
|
30
|
-
|
|
31
|
-
## Non-negotiable rules
|
|
32
|
-
|
|
33
|
-
- Do not stop after intermediate progress; do not pause between cycles for confirmation.
|
|
34
|
-
- A cycle that ends blocked on an owner dependency (confirmed by a runnable, non-secret executed check) does not stop the loop: record the blocker on the task with `ace-task update`, close stale blockers in the same pass, and continue with the next task. Only unverifiable or human-only blockers stop the loop.
|
|
35
|
-
- Status truth: recorded blockers are claims, not state. Confirm or refute each blocker you rely on with an executed check in this session.
|
|
36
|
-
- Never modify task frontmatter directly -- use `ace-task update <ref> --set key=value`.
|
|
37
|
-
- Never reset or discard unrelated changes; commit path-scoped with `ace-git-commit <paths>`.
|
|
38
|
-
- Temp files go to `/tmp/` or `.ace-local/<subfolder>/`, never the project root.
|