iterate-plugin 3.5.7 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -22
- package/README.zh-CN.md +79 -19
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +516 -35
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +193 -0
- package/dist/index.js +61 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +54 -14
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +61 -14
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +106 -28
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/dist/working-directory.js +114 -0
- package/lib/client.js +1912 -512
- package/lib/parse.js +1001 -103
- package/package.json +16 -9
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +1031 -369
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +549 -36
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +211 -0
- package/src/index.ts +60 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +50 -12
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +60 -13
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +114 -23
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
- package/src/working-directory.ts +126 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/file-lock.ts — generic cross-process advisory lock for `.iterate/` state.
|
|
3
|
+
*
|
|
4
|
+
* Several plugin writers mutate small JSON/JSONL state files with a
|
|
5
|
+
* read-modify-write cycle (fix registry, decision log, defense events,
|
|
6
|
+
* experience bank). `writeJsonAtomic` only makes a SINGLE write atomic — two
|
|
7
|
+
* plugin processes racing on the same project can still lose an update between
|
|
8
|
+
* their read and their write. The decision log already guards itself with a
|
|
9
|
+
* purpose-built mutex (see `tools/decision-log.ts`); this module generalizes
|
|
10
|
+
* that exact pattern so every other writer can serialize the same way.
|
|
11
|
+
*
|
|
12
|
+
* Semantics (identical to the decision-log lock, deliberately):
|
|
13
|
+
* - exclusive create (`openSync(..., 'wx')`) with the holder pid stamped in;
|
|
14
|
+
* - a lock whose owner process is gone, or whose mtime is ancient, is
|
|
15
|
+
* stolen (unlink + retry);
|
|
16
|
+
* - bounded wait — on timeout the lock degrades to "proceed unlocked"
|
|
17
|
+
* (returns a callable no-op release) instead of wedging the tool call;
|
|
18
|
+
* - the returned release is always callable, idempotent, and never throws.
|
|
19
|
+
*
|
|
20
|
+
* Best-effort by design: an advisory lock that cannot be taken must never turn
|
|
21
|
+
* a working tool into a failed one; the worst case is the pre-existing
|
|
22
|
+
* (unlocked) behavior.
|
|
23
|
+
*
|
|
24
|
+
* In-process callers get synchronous mutual exclusion through the same API —
|
|
25
|
+
* tools run on one thread, so acquiring twice in one process would self-deadlock;
|
|
26
|
+
* `withProjectLock` is therefore NOT reentrant and must not be nested for the
|
|
27
|
+
* same (projectRoot, name).
|
|
28
|
+
*/
|
|
29
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync, } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
const LOCK_DIR = '.iterate';
|
|
32
|
+
const DEFAULT_WAIT_MS = 5000;
|
|
33
|
+
const DEFAULT_STALE_MS = 10000;
|
|
34
|
+
const POLL_MS = 25;
|
|
35
|
+
/** True when a pid refers to a live process (or we lack permission to tell). */
|
|
36
|
+
function processAlive(pid) {
|
|
37
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
38
|
+
return false;
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return err.code === 'EPERM';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Synchronous sleep (Atomics.wait is a reliable sleep in Node). */
|
|
48
|
+
function sleepSync(ms) {
|
|
49
|
+
const sab = new Int32Array(new SharedArrayBuffer(4));
|
|
50
|
+
Atomics.wait(sab, 0, 0, ms);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Validate a lock name into a safe file-name fragment.
|
|
54
|
+
* Throws on anything that could escape `.iterate/` (path separators, dots).
|
|
55
|
+
*/
|
|
56
|
+
function lockFileName(name) {
|
|
57
|
+
if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
|
58
|
+
throw new Error(`invalid lock name: ${JSON.stringify(name)}`);
|
|
59
|
+
}
|
|
60
|
+
return `.${name}.lock`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
|
|
64
|
+
*
|
|
65
|
+
* Returns a release function: always callable, idempotent, never throws.
|
|
66
|
+
* A no-op (but callable) release is returned when the lock directory cannot be
|
|
67
|
+
* created or the wait times out — callers then proceed unlocked, which is the
|
|
68
|
+
* historical best-effort behavior, rather than failing the operation.
|
|
69
|
+
*/
|
|
70
|
+
export function acquireProjectLock(projectRoot, name, options = {}) {
|
|
71
|
+
const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
|
|
72
|
+
? options.waitMs
|
|
73
|
+
: DEFAULT_WAIT_MS;
|
|
74
|
+
const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
|
|
75
|
+
? options.staleMs
|
|
76
|
+
: DEFAULT_STALE_MS;
|
|
77
|
+
const dir = join(projectRoot, LOCK_DIR);
|
|
78
|
+
if (!existsSync(dir)) {
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return () => { };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const lockPath = join(dir, lockFileName(name));
|
|
87
|
+
const start = Date.now();
|
|
88
|
+
for (;;) {
|
|
89
|
+
let fd = null;
|
|
90
|
+
try {
|
|
91
|
+
fd = openSync(lockPath, 'wx');
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code !== 'EEXIST')
|
|
95
|
+
return () => { };
|
|
96
|
+
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
97
|
+
let stale = false;
|
|
98
|
+
try {
|
|
99
|
+
const st = statSync(lockPath);
|
|
100
|
+
if (Date.now() - st.mtimeMs > staleMs)
|
|
101
|
+
stale = true;
|
|
102
|
+
else {
|
|
103
|
+
const pid = Number(readFileSync(lockPath, 'utf-8'));
|
|
104
|
+
if (!processAlive(pid))
|
|
105
|
+
stale = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
stale = true; // unreadable/vanishing lock → retry as stale
|
|
110
|
+
}
|
|
111
|
+
if (stale) {
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(lockPath);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// another holder stole it first — retry immediately
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (Date.now() - start > waitMs)
|
|
121
|
+
return () => { };
|
|
122
|
+
sleepSync(POLL_MS);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// Owned the lock: stamp the holder pid, then release once on return.
|
|
126
|
+
try {
|
|
127
|
+
writeSync(fd, String(process.pid));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// pid stamp is advisory
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
closeSync(fd);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// best-effort
|
|
137
|
+
}
|
|
138
|
+
return makeLockRelease(lockPath);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Build the release function for a lock file this process just acquired.
|
|
143
|
+
*
|
|
144
|
+
* Advisory locks here are STOLEN when the recorded owner dies or the mtime
|
|
145
|
+
* goes ancient: the steal unlinks our file and creates a new one stamped with
|
|
146
|
+
* the STEALER's pid. An unguarded release would then unlink the new holder's
|
|
147
|
+
* lock — turning our release into a double-release that admits a third process
|
|
148
|
+
* into a "held" critical section. So the release re-reads the stamp and only
|
|
149
|
+
* unlinks a file that is still ours (same pid) or unstamped (the shape left
|
|
150
|
+
* when our own advisory pid write failed, or when the content is not a pid).
|
|
151
|
+
*
|
|
152
|
+
* Always callable, idempotent, and never throws.
|
|
153
|
+
*/
|
|
154
|
+
export function makeLockRelease(lockPath) {
|
|
155
|
+
let released = false;
|
|
156
|
+
return () => {
|
|
157
|
+
if (released)
|
|
158
|
+
return;
|
|
159
|
+
released = true;
|
|
160
|
+
let raw = '';
|
|
161
|
+
try {
|
|
162
|
+
raw = readFileSync(lockPath, 'utf-8').trim();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Already gone (or unreadable) — nothing of ours left to remove.
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// A stamp naming another pid means the lock was stolen and reassigned:
|
|
169
|
+
// it is no longer ours to delete.
|
|
170
|
+
if (raw !== '' && Number(raw) !== process.pid)
|
|
171
|
+
return;
|
|
172
|
+
try {
|
|
173
|
+
unlinkSync(lockPath);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// already gone
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Run `fn` while holding the advisory lock named `name`.
|
|
182
|
+
* The release is guaranteed via `finally`; `fn`'s value or error propagates
|
|
183
|
+
* unchanged. Not reentrant for the same (projectRoot, name) — see header.
|
|
184
|
+
*/
|
|
185
|
+
export function withProjectLock(projectRoot, name, fn, options = {}) {
|
|
186
|
+
const release = acquireProjectLock(projectRoot, name, options);
|
|
187
|
+
try {
|
|
188
|
+
return fn();
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
release();
|
|
192
|
+
}
|
|
193
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -43,38 +43,71 @@ import { registerQualityGateTool } from "./tools/quality-gate.js";
|
|
|
43
43
|
import { registerDefenseEventsTool } from "./tools/defense-events.js";
|
|
44
44
|
import { registerSessionHooks } from "./session-hooks.js";
|
|
45
45
|
import { registerLiveCapture } from "./live.js";
|
|
46
|
+
import { installWorkingDirectoryContext } from "./working-directory.js";
|
|
46
47
|
import { ITERATE_SKILL_PROMPT } from "./skill-prompt.js";
|
|
47
48
|
export const name = 'iterate-plugin';
|
|
48
49
|
export const inject = ['tools', 'systemPrompt'];
|
|
49
50
|
export function apply(ctx) {
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
registerPruneTool(ctx);
|
|
64
|
-
registerTranscriptTool(ctx);
|
|
65
|
-
// v3.0: Quality Command Center tools
|
|
66
|
-
registerExperienceBankTool(ctx);
|
|
67
|
-
registerQualityGateTool(ctx);
|
|
68
|
-
registerDefenseEventsTool(ctx);
|
|
69
|
-
// 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
|
|
70
|
-
// and the live reviewer-activity feed onto tools/result.
|
|
51
|
+
// 0. Wire the SAFETY hooks BEFORE any tool is registered.
|
|
52
|
+
// Registration is a list of 17 independent calls; if one of them threw
|
|
53
|
+
// (duplicate name, malformed definition, hostile ctx) while the gate was
|
|
54
|
+
// registered last, the tools registered up to that point would keep
|
|
55
|
+
// running with NO `tools/pre-execute` approval gate — a fail-open hole
|
|
56
|
+
// created by load order. Registering the gate first means a partial load
|
|
57
|
+
// can only ever err on the side of MORE gating, never less.
|
|
58
|
+
//
|
|
59
|
+
// The working-directory context is installed first for the same reason:
|
|
60
|
+
// every cwd read below (gate policy, live capture, tool project roots)
|
|
61
|
+
// must see dsh 0.2.1-alpha.2+ directory switches, and installing it last
|
|
62
|
+
// would leave a partially-loaded plugin resolving the wrong workspace.
|
|
63
|
+
installWorkingDirectoryContext(ctx);
|
|
71
64
|
registerSessionHooks(ctx);
|
|
72
65
|
registerLiveCapture(ctx);
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
// 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
|
|
67
|
+
// isolated: one failing tool must not take down the other 16, the gate
|
|
68
|
+
// above, or the prompt below (a partial plugin that still gates and
|
|
69
|
+
// teaches the workflow beats an aborted `apply`). Failures are logged,
|
|
70
|
+
// never swallowed silently.
|
|
71
|
+
const toolRegistrations = [
|
|
72
|
+
['iterate_config', () => registerConfigTool(ctx)],
|
|
73
|
+
['iterate_validate', () => registerValidateTool(ctx)],
|
|
74
|
+
['iterate_decision_log', () => registerDecisionLogTool(ctx)],
|
|
75
|
+
['iterate_context', () => registerContextTool(ctx)],
|
|
76
|
+
['iterate_review', () => registerReviewTool(ctx)],
|
|
77
|
+
['iterate_triage', () => registerTriageTool(ctx)],
|
|
78
|
+
['iterate_fix', () => registerFixTool(ctx)],
|
|
79
|
+
['iterate_diff', () => registerDiffTool(ctx)],
|
|
80
|
+
['iterate_rollback', () => registerRollbackTool(ctx)],
|
|
81
|
+
['iterate_checkpoint', () => registerCheckpointTool(ctx)],
|
|
82
|
+
['iterate_status', () => registerStatusTool(ctx)],
|
|
83
|
+
['iterate_history', () => registerHistoryTool(ctx)],
|
|
84
|
+
['iterate_prune', () => registerPruneTool(ctx)],
|
|
85
|
+
['iterate_transcript', () => registerTranscriptTool(ctx)],
|
|
86
|
+
// v3.0: Quality Command Center tools
|
|
87
|
+
['iterate_experience', () => registerExperienceBankTool(ctx)],
|
|
88
|
+
['iterate_quality_gate', () => registerQualityGateTool(ctx)],
|
|
89
|
+
['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
|
|
90
|
+
];
|
|
91
|
+
for (const [toolName, register] of toolRegistrations) {
|
|
92
|
+
try {
|
|
93
|
+
register();
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.warn(`[iterate] failed to register ${toolName}; continuing.`, err);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// 2. Inject the iterate skill prompt as a system prompt section.
|
|
100
|
+
// This teaches the model how to write iterate workflow scripts using the
|
|
101
|
+
// tools. Guarded for the same reason as above: a prompt-injection failure
|
|
102
|
+
// must not undo a load that already wired the gate and the tools.
|
|
103
|
+
try {
|
|
104
|
+
ctx.systemPrompt.section({
|
|
105
|
+
name: 'iterate-skill',
|
|
106
|
+
order: 100,
|
|
107
|
+
text: ITERATE_SKILL_PROMPT,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.warn('[iterate] failed to inject the skill prompt section; continuing.', err);
|
|
112
|
+
}
|
|
80
113
|
}
|
package/dist/jobs.js
CHANGED
|
@@ -11,10 +11,19 @@
|
|
|
11
11
|
* - `ctx.jobs` only exists when the dsh host loaded a job registry + a
|
|
12
12
|
* controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
|
|
13
13
|
* equivalent). When it is missing, `start()` throws or is absent — we
|
|
14
|
-
* detect both
|
|
15
|
-
* pure enhancement and never breaks
|
|
14
|
+
* detect both (including a throwing `start` GETTER) and fall through to
|
|
15
|
+
* plain execution, so the Job Panel is a pure enhancement and never breaks
|
|
16
|
+
* a tool call.
|
|
16
17
|
* - The registry is memory-only and panel rows are read-only (no progress
|
|
17
18
|
* updates), so these jobs are completion records, not control channels.
|
|
19
|
+
* - Cancellation is honest, not theatrical. `JobHooks.cancel` is REQUIRED by
|
|
20
|
+
* `@deepseek-ai/dsh-jobs` (the registry calls it on kill/teardown), but the
|
|
21
|
+
* wrapped tool `fn` exposes no abort channel — a panel kill cannot stop it.
|
|
22
|
+
* So `cancel` records the REQUEST (synchronous + idempotent, first reason
|
|
23
|
+
* wins), best-effort surfaces it on the record's progress line, and lets the
|
|
24
|
+
* job settle with `fn`'s TRUE outcome, with the request annotated in
|
|
25
|
+
* `detail`. Settling the record `killed` while `fn()` keeps running would
|
|
26
|
+
* show a stopped job that is still running — a lie in the Job Panel.
|
|
18
27
|
*/
|
|
19
28
|
/**
|
|
20
29
|
* Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
|
|
@@ -30,11 +39,25 @@ function safeJobs(ctx) {
|
|
|
30
39
|
return undefined;
|
|
31
40
|
}
|
|
32
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Read `jobs.start` defensively: the `start` GETTER itself can throw on a
|
|
44
|
+
* hostile/proxied registry, and that read must degrade the same way as a
|
|
45
|
+
* missing registry — never escape `runWithJob`.
|
|
46
|
+
*/
|
|
47
|
+
function safeStart(jobs) {
|
|
48
|
+
try {
|
|
49
|
+
return jobs?.start;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
33
55
|
/**
|
|
34
56
|
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
35
|
-
* with the execution's outcome
|
|
36
|
-
*
|
|
37
|
-
*
|
|
57
|
+
* with the execution's ACTUAL outcome (a requested cancel never falsifies it
|
|
58
|
+
* — see the header). When the host exposes no job registry (or refuses the
|
|
59
|
+
* start), `fn` runs untouched and `null` is returned — the Job Panel is an
|
|
60
|
+
* enhancement, never a dependency.
|
|
38
61
|
*
|
|
39
62
|
* @param ctx the dsh plugin context (may or may not expose `jobs`).
|
|
40
63
|
* @param kind iterate job kind registered via {@link IterateJobKind}.
|
|
@@ -44,21 +67,43 @@ function safeJobs(ctx) {
|
|
|
44
67
|
*/
|
|
45
68
|
export async function runWithJob(ctx, kind, label, fn) {
|
|
46
69
|
const jobs = safeJobs(ctx);
|
|
47
|
-
|
|
70
|
+
const start = safeStart(jobs);
|
|
71
|
+
if (!jobs || !start || typeof start !== 'function') {
|
|
48
72
|
return { result: await fn(), jobId: null };
|
|
49
73
|
}
|
|
50
74
|
let settle;
|
|
51
75
|
const done = new Promise((resolve) => {
|
|
52
76
|
settle = resolve;
|
|
53
77
|
});
|
|
78
|
+
// Honest cancel bookkeeping: the request is recorded (first reason wins),
|
|
79
|
+
// but `done` is NOT settled here — `fn()` is still running and the panel
|
|
80
|
+
// must not claim otherwise. The settlement below carries `fn`'s real
|
|
81
|
+
// outcome, annotated with the request.
|
|
82
|
+
let cancelRequested = null;
|
|
83
|
+
const annotate = (detail) => cancelRequested === null
|
|
84
|
+
? detail
|
|
85
|
+
: `${detail} (cancel requested: ${cancelRequested} — the wrapped tool call has no abort channel and ran to completion)`;
|
|
54
86
|
let jobId = null;
|
|
55
87
|
try {
|
|
56
|
-
jobId =
|
|
88
|
+
jobId = start.call(jobs, {
|
|
57
89
|
kind,
|
|
58
90
|
label,
|
|
59
|
-
run: () => ({
|
|
91
|
+
run: (job) => ({
|
|
60
92
|
done,
|
|
61
|
-
cancel: () =>
|
|
93
|
+
cancel: (reason) => {
|
|
94
|
+
if (cancelRequested !== null)
|
|
95
|
+
return; // idempotent — first reason wins
|
|
96
|
+
cancelRequested = reason ?? 'cancel requested';
|
|
97
|
+
// Best-effort panel hint while the record is stopping; the registry
|
|
98
|
+
// drops writes it cannot apply, and a throwing update must not
|
|
99
|
+
// propagate out of the registry's kill path.
|
|
100
|
+
try {
|
|
101
|
+
job?.updateProgress?.('cancel requested — waiting for the tool call to finish (no abort channel)');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* progress hint is cosmetic */
|
|
105
|
+
}
|
|
106
|
+
},
|
|
62
107
|
}),
|
|
63
108
|
});
|
|
64
109
|
}
|
|
@@ -69,13 +114,13 @@ export async function runWithJob(ctx, kind, label, fn) {
|
|
|
69
114
|
}
|
|
70
115
|
try {
|
|
71
116
|
const result = await fn();
|
|
72
|
-
settle({ status: 'completed', detail: 'done' });
|
|
117
|
+
settle({ status: 'completed', detail: annotate('done') });
|
|
73
118
|
return { result, jobId };
|
|
74
119
|
}
|
|
75
120
|
catch (error) {
|
|
76
121
|
settle({
|
|
77
122
|
status: 'failed',
|
|
78
|
-
detail: error instanceof Error ? error.message : 'execution failed',
|
|
123
|
+
detail: annotate(error instanceof Error ? error.message : 'execution failed'),
|
|
79
124
|
});
|
|
80
125
|
throw error;
|
|
81
126
|
}
|
package/dist/live.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* Safety:
|
|
19
19
|
* - Read-only observer: never mutates source files; writes only the NDJSON
|
|
20
20
|
* live file under `.iterate/`.
|
|
21
|
+
* - Privacy switch: when the project's effective config sets
|
|
22
|
+
* `observatory.capture: false`, nothing is persisted (the file stays
|
|
23
|
+
* absent/unchanged). An unreadable config keeps capture ON (defaults).
|
|
21
24
|
* - The live file is byte-capped (rewrite to last N lines when it grows too
|
|
22
25
|
* large) so it can never grow unbounded.
|
|
23
26
|
* - Any capture failure is swallowed (fire-and-forget) so it can never block
|
|
@@ -27,7 +30,8 @@ import { mkdir, readFile, stat, appendFile } from 'node:fs/promises';
|
|
|
27
30
|
import { existsSync } from 'node:fs';
|
|
28
31
|
import { writeTextAtomicAsync } from "./atomic-fs.js";
|
|
29
32
|
import { join } from 'node:path';
|
|
30
|
-
import { resolveProjectRoot } from "./config-loader.js";
|
|
33
|
+
import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
|
|
34
|
+
import { sessionCwdOf } from "./working-directory.js";
|
|
31
35
|
/** Keep at most this many live activity entries. */
|
|
32
36
|
export const LIVE_MAX_ENTRIES = 300;
|
|
33
37
|
/** Rewrite the live file when its byte size exceeds this threshold. */
|
|
@@ -51,14 +55,41 @@ function enqueueLiveWrite(task) {
|
|
|
51
55
|
liveWriteQueue = next.catch(() => { });
|
|
52
56
|
return next;
|
|
53
57
|
}
|
|
54
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* True when the project's effective config still allows live capture.
|
|
60
|
+
* `observatory.capture: false` opts the project out — nothing is persisted.
|
|
61
|
+
* Failure modes keep TODAY'S behavior: `loadEffectiveConfig` never throws on
|
|
62
|
+
* an unreadable config (it falls back to defaults, capture on), and any
|
|
63
|
+
* unexpected loader error here also degrades to capture ON rather than
|
|
64
|
+
* silently eating activity the operator expected to see.
|
|
65
|
+
*/
|
|
66
|
+
function captureEnabled(projectRoot) {
|
|
67
|
+
try {
|
|
68
|
+
return loadEffectiveConfig(projectRoot).config.observatory?.capture !== false;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Append one activity record to the project's live feed (byte-capped).
|
|
76
|
+
* Never rejects: every failure (capture disabled, `.iterate` existing as a
|
|
77
|
+
* regular file, locked/unwritable feed) is swallowed — the header promises
|
|
78
|
+
* this path can never crash or block a tool call (`void appendLive(...)`
|
|
79
|
+
* would otherwise turn a rejection into an unhandled rejection).
|
|
80
|
+
*/
|
|
55
81
|
export function appendLive(projectRoot, entry) {
|
|
56
82
|
return enqueueLiveWrite(async () => {
|
|
57
|
-
const file = liveFilePath(projectRoot);
|
|
58
|
-
const line = JSON.stringify(entry) + '\n';
|
|
59
|
-
await mkdir(join(projectRoot, '.iterate'), { recursive: true });
|
|
60
|
-
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
61
83
|
try {
|
|
84
|
+
// Privacy: capture off → do not touch the feed at all.
|
|
85
|
+
if (!captureEnabled(projectRoot))
|
|
86
|
+
return;
|
|
87
|
+
const file = liveFilePath(projectRoot);
|
|
88
|
+
const line = JSON.stringify(entry) + '\n';
|
|
89
|
+
// mkdir is INSIDE the guard: `.iterate` may be a regular file (or the
|
|
90
|
+
// dir unwritable) — it must reject neither the caller nor the queue.
|
|
91
|
+
await mkdir(join(projectRoot, '.iterate'), { recursive: true });
|
|
92
|
+
// Amortized O(1): only read+rewrite when the file has grown past the cap.
|
|
62
93
|
const st = await stat(file).catch(() => null);
|
|
63
94
|
if (st && st.size > LIVE_MAX_BYTES) {
|
|
64
95
|
const raw = await readFile(file, 'utf-8');
|
|
@@ -77,7 +108,7 @@ export function appendLive(projectRoot, entry) {
|
|
|
77
108
|
}
|
|
78
109
|
/** Resolve the project root a tool execution belongs to, if any. */
|
|
79
110
|
function projectRootOf(exec) {
|
|
80
|
-
const cwd = exec
|
|
111
|
+
const cwd = sessionCwdOf(exec);
|
|
81
112
|
if (!cwd)
|
|
82
113
|
return null;
|
|
83
114
|
const resolved = resolveProjectRoot(undefined, cwd);
|
|
@@ -167,12 +198,21 @@ export async function readLive(projectRoot) {
|
|
|
167
198
|
*/
|
|
168
199
|
export function registerLiveCapture(ctx) {
|
|
169
200
|
ctx.on('tools/result', (exec) => {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
// Defensive: a hostile/proxied exec whose getters throw (`agent`,
|
|
202
|
+
// `name`, `arguments`) must not abort the observer — unlike the guarded
|
|
203
|
+
// siblings below, an unguarded throw here would lose the record and
|
|
204
|
+
// surface an error from a read-only hook. Degrade to a no-op.
|
|
205
|
+
try {
|
|
206
|
+
const root = projectRootOf(exec);
|
|
207
|
+
if (!root)
|
|
208
|
+
return;
|
|
209
|
+
const entry = classifyTool(exec.name, exec.arguments, root);
|
|
210
|
+
if (!entry)
|
|
211
|
+
return;
|
|
212
|
+
void appendLive(root, entry);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// An observer must never throw.
|
|
216
|
+
}
|
|
177
217
|
});
|
|
178
218
|
}
|