@akira-tl/forgerelay 0.8.3 → 0.8.5

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.
@@ -0,0 +1,42 @@
1
+ const TASK_REMINDER = "Reminder: this Workspace has unfinished active Tasks. Review workspace.tasks and update Task state when material progress, requirements, blockers, or conclusions changed.";
2
+ export class WorkspaceTaskReminderTracker {
3
+ interval;
4
+ tasks;
5
+ callsSinceUpdate = new Map();
6
+ constructor(interval, tasks) {
7
+ this.interval = interval;
8
+ this.tasks = tasks;
9
+ }
10
+ reset(workspaceId) {
11
+ this.callsSinceUpdate.delete(workspaceId);
12
+ }
13
+ forget(workspaceId) {
14
+ this.callsSinceUpdate.delete(workspaceId);
15
+ }
16
+ recordWork(workspaceId) {
17
+ if (this.interval === 0)
18
+ return undefined;
19
+ let summary;
20
+ try {
21
+ summary = this.tasks.readSummary(workspaceId);
22
+ }
23
+ catch {
24
+ // Reminder delivery is advisory. Invalid external Task state must still be
25
+ // surfaced by workspace.tasks itself, not turn unrelated work into failure.
26
+ this.callsSinceUpdate.delete(workspaceId);
27
+ return undefined;
28
+ }
29
+ const hasUnfinishedActiveTasks = summary.lists.some((list) => list.state === "active" && list.unfinishedTaskCount > 0);
30
+ if (!hasUnfinishedActiveTasks) {
31
+ this.callsSinceUpdate.delete(workspaceId);
32
+ return undefined;
33
+ }
34
+ const next = (this.callsSinceUpdate.get(workspaceId) ?? 0) + 1;
35
+ if (next < this.interval) {
36
+ this.callsSinceUpdate.set(workspaceId, next);
37
+ return undefined;
38
+ }
39
+ this.callsSinceUpdate.set(workspaceId, 0);
40
+ return TASK_REMINDER;
41
+ }
42
+ }
@@ -80,6 +80,20 @@ export class WorkspaceTaskStore {
80
80
  read(workspaceId) {
81
81
  return this.ensureWorkspace(workspaceId);
82
82
  }
83
+ readSummary(workspaceId) {
84
+ return taskSummary(this.ensureWorkspace(workspaceId));
85
+ }
86
+ inspectSummary(workspaceId) {
87
+ const id = normalizeWorkspaceId(workspaceId);
88
+ const loaded = this.tryReadState(id);
89
+ return loaded ? taskSummary(snapshot(loaded.state, loaded.fingerprint)) : undefined;
90
+ }
91
+ readHeaders(workspaceId, listId) {
92
+ return taskHeaders(this.ensureWorkspace(workspaceId), listId);
93
+ }
94
+ readTaskDetail(workspaceId, listId, taskId) {
95
+ return taskDetail(this.ensureWorkspace(workspaceId), listId, taskId);
96
+ }
83
97
  createList(workspaceId, input) {
84
98
  return this.mutate(workspaceId, (state) => {
85
99
  if (state.lists.length >= MAX_TASK_LISTS) {
@@ -266,6 +280,61 @@ function snapshot(state, stateFingerprint) {
266
280
  fingerprint: stateFingerprint,
267
281
  };
268
282
  }
283
+ function taskSummary(snapshotValue) {
284
+ return {
285
+ level: "summary",
286
+ version: snapshotValue.version,
287
+ revision: snapshotValue.revision,
288
+ fingerprint: snapshotValue.fingerprint,
289
+ lists: snapshotValue.lists.map((list) => ({
290
+ id: list.id,
291
+ name: list.name,
292
+ state: list.state,
293
+ revision: list.revision,
294
+ taskCount: list.tasks.length,
295
+ unfinishedTaskCount: list.tasks.filter((task) => task.status !== "completed").length,
296
+ })),
297
+ };
298
+ }
299
+ function taskHeaders(snapshotValue, listId) {
300
+ const lists = listId === undefined
301
+ ? snapshotValue.lists
302
+ : [snapshotValue.lists[requireListIndex(snapshotValue, listId)]];
303
+ return {
304
+ level: "headers",
305
+ version: snapshotValue.version,
306
+ revision: snapshotValue.revision,
307
+ fingerprint: snapshotValue.fingerprint,
308
+ lists: lists.map((list) => ({
309
+ id: list.id,
310
+ name: list.name,
311
+ state: list.state,
312
+ revision: list.revision,
313
+ tasks: list.tasks.map((task) => ({
314
+ id: task.id,
315
+ status: task.status,
316
+ subject: task.subject,
317
+ })),
318
+ })),
319
+ };
320
+ }
321
+ function taskDetail(snapshotValue, listId, taskId) {
322
+ const list = requireList(snapshotValue, listId);
323
+ const task = list.tasks[requireTaskIndex(list, taskId)];
324
+ return {
325
+ level: "detail",
326
+ version: snapshotValue.version,
327
+ revision: snapshotValue.revision,
328
+ fingerprint: snapshotValue.fingerprint,
329
+ list: {
330
+ id: list.id,
331
+ name: list.name,
332
+ state: list.state,
333
+ revision: list.revision,
334
+ },
335
+ task: { ...task },
336
+ };
337
+ }
269
338
  function cloneState(state) {
270
339
  return {
271
340
  version: state.version,
@@ -46,6 +46,37 @@ export class WorkspaceRegistry {
46
46
  }
47
47
  return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, bootstrapContext);
48
48
  }
49
+ async inspectWorkspace(workspaceId) {
50
+ let session = this.store?.getSession(workspaceId);
51
+ if (!session) {
52
+ const workspace = this.workspaces.get(workspaceId);
53
+ if (workspace) {
54
+ session = {
55
+ id: workspace.id,
56
+ root: workspace.root,
57
+ status: "active",
58
+ mode: workspace.mode,
59
+ sourceRoot: workspace.sourceRoot,
60
+ baseRef: workspace.worktree?.baseRef,
61
+ baseSha: workspace.worktree?.baseSha,
62
+ branch: workspace.worktree?.branch,
63
+ targetBranch: workspace.worktree?.targetBranch,
64
+ managed: workspace.worktree?.managed ?? false,
65
+ createdAt: "",
66
+ lastUsedAt: "",
67
+ };
68
+ }
69
+ }
70
+ if (!session)
71
+ throw new Error(`Unknown workspaceId: ${workspaceId}.`);
72
+ const entry = await this.inventoryEntryForSession(session, Date.now(), false);
73
+ const { current: _current, ...inspection } = entry;
74
+ return {
75
+ kind: "workspace",
76
+ location: "local",
77
+ ...inspection,
78
+ };
79
+ }
49
80
  async listWorkspaces(input = {}, openOptions = {}) {
50
81
  this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
51
82
  const now = Date.now();
@@ -77,36 +108,7 @@ export class WorkspaceRegistry {
77
108
  const rootKey = input.root
78
109
  ? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
79
110
  : undefined;
80
- const entries = await Promise.all(sessions.map(async (session) => {
81
- const rootValid = await this.validSessionRoot(session) !== undefined;
82
- const lastUsedAt = Date.parse(session.lastUsedAt);
83
- const idleMs = Number.isFinite(lastUsedAt) ? Math.max(0, now - lastUsedAt) : 0;
84
- const state = session.status !== "active"
85
- ? "closed"
86
- : !rootValid
87
- ? "invalid"
88
- : idleMs >= WORKSPACE_STALE_REMINDER_MS
89
- ? "stale"
90
- : "active";
91
- const projectRoot = session.sourceRoot ?? session.root;
92
- return {
93
- label: `${basename(resolve(projectRoot)) || "workspace"}/${session.id}`,
94
- workspaceId: session.id,
95
- root: session.root,
96
- status: session.status,
97
- state,
98
- mode: session.mode,
99
- sourceRoot: session.sourceRoot,
100
- branch: session.branch,
101
- targetBranch: session.targetBranch,
102
- managed: session.managed,
103
- createdAt: session.createdAt,
104
- lastUsedAt: session.lastUsedAt,
105
- idleMs,
106
- rootValid,
107
- current: currentWorkspaceIds.has(session.id),
108
- };
109
- }));
111
+ const entries = await Promise.all(sessions.map((session) => this.inventoryEntryForSession(session, now, currentWorkspaceIds.has(session.id))));
110
112
  const filtered = [];
111
113
  for (let index = 0; index < sessions.length; index += 1) {
112
114
  const session = sessions[index];
@@ -151,6 +153,36 @@ export class WorkspaceRegistry {
151
153
  },
152
154
  };
153
155
  }
156
+ async inventoryEntryForSession(session, now, current) {
157
+ const rootValid = await this.validSessionRoot(session) !== undefined;
158
+ const lastUsedAt = Date.parse(session.lastUsedAt);
159
+ const idleMs = Number.isFinite(lastUsedAt) ? Math.max(0, now - lastUsedAt) : 0;
160
+ const state = session.status !== "active"
161
+ ? "closed"
162
+ : !rootValid
163
+ ? "invalid"
164
+ : idleMs >= WORKSPACE_STALE_REMINDER_MS
165
+ ? "stale"
166
+ : "active";
167
+ const projectRoot = session.sourceRoot ?? session.root;
168
+ return {
169
+ label: `${basename(resolve(projectRoot)) || "workspace"}/${session.id}`,
170
+ workspaceId: session.id,
171
+ root: session.root,
172
+ status: session.status,
173
+ state,
174
+ mode: session.mode,
175
+ sourceRoot: session.sourceRoot,
176
+ branch: session.branch,
177
+ targetBranch: session.targetBranch,
178
+ managed: session.managed,
179
+ createdAt: session.createdAt,
180
+ lastUsedAt: session.lastUsedAt,
181
+ idleMs,
182
+ rootValid,
183
+ current,
184
+ };
185
+ }
154
186
  async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
155
187
  const session = this.store?.getSession(workspaceId);
156
188
  const context = session?.status === "closed" && session.mode === "worktree"
@@ -49,9 +49,9 @@ returning the full project context and does not record the current fingerprint a
49
49
  already delivered. Context-delivery state remains conversation-scoped and does not
50
50
  change the persistent Workspace identity.
51
51
 
52
- Do not enumerate Workspace state on every normal open. When the user wants to
53
- continue earlier work, inspect known Workspaces, or clean up accumulated state, use
54
- the same Core tool in inventory mode:
52
+ Do not enumerate Workspace state on every normal open. Use the same Core tool in
53
+ inventory mode only when the user wants to discover known Workspaces, continue earlier
54
+ work, or organize accumulated state:
55
55
 
56
56
  ```text
57
57
  open_workspace(action="list")
@@ -66,6 +66,16 @@ root/source root, or stale-only state. Entries include a compact label such as
66
66
  root validity, and whether that Workspace is currently selected by this
67
67
  conversation. Listing is observational and does not refresh `lastUsedAt`.
68
68
 
69
+ When one known Workspace needs more bounded detail but must not be opened or resumed,
70
+ use `open_workspace(action="inspect", workspaceId="...")`. Inspection is a strict
71
+ allowlist projection: ordinary/worktree lifecycle metadata, Composite member
72
+ summaries, safe Relay presentation metadata, and an existing Task List summary may be
73
+ returned. It does not return bootstrap instructions, Skills, Capability guides,
74
+ Subagent bodies, files, process/Activity output, credentials, routes, or Task bodies;
75
+ it does not bind the conversation, mark bootstrap delivered, refresh `lastUsedAt`, or
76
+ grant execution authority. Explicitly open the target Workspace before mutating or
77
+ executing against it.
78
+
69
79
  Treat persisted status and derived state separately. `status="active"` means the
70
80
  record has not been explicitly closed. A valid recent record has `state="active"`;
71
81
  a valid active record idle for more than two days has `state="stale"`; an active
@@ -327,17 +327,52 @@ Hooks, Skills, language services, and Activity facts remain owned by the underly
327
327
  Workspace. The Composite Activity Panel only aggregates their presentation into one
328
328
  Host Turn.
329
329
 
330
- Use `open_workspace(action="list")` only when the Agent needs to inspect known
331
- Workspaces, continue earlier work, or organize Workspace state. The inventory is
332
- paginated (50 records by default, at most 100) and can filter by Workspace ID,
333
- persisted status, derived state, mode, canonical root/source root, or stale-only
334
- state. Reading inventory does not refresh `lastUsedAt`. Persisted
335
- `status="active"` means the record has not been explicitly closed; the derived
336
- `state` distinguishes `active`, `stale`, `invalid`, and `closed`. A missing checkout
337
- or externally removed managed-worktree root can therefore remain diagnostically
338
- `status="active"` while appearing as `state="invalid"`. Canonical identity means
339
- ordinary same-target opens no longer accumulate duplicate inventory rows;
340
- `action="list"` remains the formal on-demand inventory path.
330
+ ## Workspace Tasks
331
+
332
+ `workspace.tasks` keeps lightweight Task Lists in ForgeRelay-owned private Workspace
333
+ state. Task data is not project bootstrap context and is not written into checkout or
334
+ managed-worktree Git contents. Reads use progressive disclosure: default `get` returns
335
+ List summaries and unfinished counts, `level="headers"` adds Task ID/status/subject,
336
+ and `level="detail"` returns full content for one explicitly selected Task.
337
+
338
+ ForgeRelay can append a bounded forgotten-update reminder after semantic Workspace
339
+ work when active Lists still contain unfinished Tasks. The counter defaults to 30
340
+ successful semantic work calls, resets on any Task mutation, treats one
341
+ `batch.execute` as one call, and does not count Workspace inventory/lifecycle UI,
342
+ Activity queries, Task reads, or Bash/process follow-up polling/input as new work.
343
+ The counter is process-local and may reset on server restart; Task state itself
344
+ remains durable.
345
+
346
+ | Variable | Default | Purpose |
347
+ | --- | --- | --- |
348
+ | `FORGERELAY_TASK_REMINDER_INTERVAL` | `30` | Successful semantic work calls between Task update reminders; `0` disables reminders. |
349
+
350
+ The same value may be persisted as `taskReminderInterval` in `config.json`. The
351
+ legacy-compatible `DEVSPACE_TASK_REMINDER_INTERVAL` environment name is also accepted.
352
+
353
+ Use `open_workspace(action="list")` only when the Agent needs lightweight inventory
354
+ to discover known Workspaces, continue earlier work, or organize Workspace state. The
355
+ inventory is paginated (50 records by default, at most 100) and can filter by Workspace
356
+ ID, persisted status, derived state, mode, canonical root/source root, or stale-only
357
+ state. Reading inventory does not refresh `lastUsedAt`. Persisted `status="active"`
358
+ means the record has not been explicitly closed; the derived `state` distinguishes
359
+ `active`, `stale`, `invalid`, and `closed`. A missing checkout or externally removed
360
+ managed-worktree root can therefore remain diagnostically `status="active"` while
361
+ appearing as `state="invalid"`. Canonical identity means ordinary same-target opens no
362
+ longer accumulate duplicate inventory rows; `action="list"` remains the formal
363
+ on-demand inventory path.
364
+
365
+ `open_workspace(action="inspect", workspaceId="...")` is the bounded read-only detail
366
+ path for one known Workspace. It uses an explicit allowlist and never opens/resumes the
367
+ target, changes conversation bindings or bootstrap-delivery records, refreshes
368
+ `lastUsedAt`, or grants file/process/Git/Capability authority. Safe projections include
369
+ ordinary/worktree lifecycle metadata, Composite member availability summaries, Relay
370
+ alias/execution-location presentation metadata, and an already-existing Task List
371
+ summary. Inspection never returns AGENTS/CLAUDE contents, Skills, Capability-guide
372
+ paths or contents, Subagent bodies/sessions, files, Git diffs, process/Activity output,
373
+ Hook/review artifacts, credentials, network/SSH routes, or Task bodies. Relay inspection
374
+ reports only that the Gateway route is known; it does not probe or claim the remote
375
+ Workspace lifecycle state.
341
376
 
342
377
  For checkout-backed Workspaces, `close_workspace` defaults to `action="close"`:
343
378
  it marks the persistent Workspace closed, removes current conversation bindings, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -40,14 +40,17 @@
40
40
  "dev": "node scripts/debug/serve.mjs",
41
41
  "debug:serve": "node scripts/debug/serve.mjs",
42
42
  "debug:accept": "node scripts/debug/accept.mjs",
43
+ "debug:accept:relay": "node scripts/debug/relay-accept.mjs",
43
44
  "lsp:interop": "node scripts/lsp-interop.mjs",
45
+ "wiki:check": "node scripts/wiki/sync.mjs check",
46
+ "wiki:publish": "node scripts/wiki/sync.mjs publish",
44
47
  "ci:verify": "node scripts/ci/verify.mjs",
45
48
  "release:parity": "node scripts/release-parity.mjs",
46
49
  "release:pack": "node scripts/release/pack.mjs",
47
50
  "release:publish": "node scripts/release/publish.mjs",
48
51
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
49
52
  "start": "node dist/cli.js serve",
50
- "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/workspace-tasks.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
53
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/workspace-tasks.test.ts && tsx src/workspace-task-reminders.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
51
54
  "typecheck": "tsc -p tsconfig.json --noEmit",
52
55
  "release:check": "node scripts/release-version.mjs check",
53
56
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -48,6 +48,10 @@ mkdirSync(acceptanceRoot, { recursive: true });
48
48
  setupGitProject(checkoutWorkspace);
49
49
  setupGitProject(lifecycleDeleteWorkspace);
50
50
  writeFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "keep checkout files\n");
51
+ writeFileSync(
52
+ join(lifecycleDeleteWorkspace, "AGENTS.md"),
53
+ "INSPECTION_ACCEPTANCE_BOOTSTRAP_SECRET\n",
54
+ );
51
55
  setupCodeIntelligenceAcceptanceProject({
52
56
  root: checkoutWorkspace,
53
57
  fakeLanguageServer,
@@ -316,16 +320,77 @@ try {
316
320
  workspaceId,
317
321
  name: "workspace.tasks",
318
322
  action: "run",
319
- arguments: { operation: "get" },
323
+ arguments: {
324
+ operation: "get",
325
+ level: "detail",
326
+ listId: checkoutListId,
327
+ taskId: checkoutTaskId,
328
+ },
320
329
  });
321
330
  assert.equal(reloadedCheckoutTasks.isError, undefined);
322
- assert.equal(reloadedCheckoutTasks.structuredContent.result.lists[0].tasks[0].id, checkoutTaskId);
323
- assert.equal(
324
- reloadedCheckoutTasks.structuredContent.result.lists[0].tasks[0].content,
325
- "reloaded external task edit",
326
- );
331
+ assert.equal(reloadedCheckoutTasks.structuredContent.result.task.id, checkoutTaskId);
332
+ assert.equal(reloadedCheckoutTasks.structuredContent.result.task.content, "reloaded external task edit");
327
333
  assert.notEqual(reloadedCheckoutTasks.structuredContent.result.fingerprint, taskFingerprintBeforeExternal);
328
334
 
335
+ const checkoutTaskSummary = callTool(oauth.accessToken, sessionId, 150, "capability", {
336
+ workspaceId,
337
+ name: "workspace.tasks",
338
+ action: "run",
339
+ arguments: { operation: "get" },
340
+ });
341
+ const summaryProjection = checkoutTaskSummary.structuredContent.result;
342
+ assert.equal(summaryProjection.level, "summary");
343
+ assert.equal(summaryProjection.lists[0].id, checkoutListId);
344
+ assert.equal(summaryProjection.lists[0].taskCount, 1);
345
+ assert.equal(summaryProjection.lists[0].unfinishedTaskCount, 1);
346
+ assert.equal(JSON.stringify(summaryProjection).includes(checkoutTaskId), false);
347
+ assert.equal(JSON.stringify(summaryProjection).includes("reloaded external task edit"), false);
348
+
349
+ const checkoutTaskHeaders = callTool(oauth.accessToken, sessionId, 151, "capability", {
350
+ workspaceId,
351
+ name: "workspace.tasks",
352
+ action: "run",
353
+ arguments: { operation: "get", level: "headers", listId: checkoutListId },
354
+ });
355
+ const headersProjection = checkoutTaskHeaders.structuredContent.result;
356
+ assert.equal(headersProjection.level, "headers");
357
+ assert.equal(headersProjection.lists.length, 1);
358
+ assert.equal(headersProjection.lists[0].tasks[0].id, checkoutTaskId);
359
+ assert.equal(headersProjection.lists[0].tasks[0].subject, "Verify v0.8.3 Task persistence");
360
+ assert.equal("content" in headersProjection.lists[0].tasks[0], false);
361
+
362
+ const checkoutTaskDetail = callTool(oauth.accessToken, sessionId, 152, "capability", {
363
+ workspaceId,
364
+ name: "workspace.tasks",
365
+ action: "run",
366
+ arguments: {
367
+ operation: "get",
368
+ level: "detail",
369
+ listId: checkoutListId,
370
+ taskId: checkoutTaskId,
371
+ },
372
+ });
373
+ const detailProjection = checkoutTaskDetail.structuredContent.result;
374
+ assert.equal(detailProjection.level, "detail");
375
+ assert.equal(detailProjection.task.id, checkoutTaskId);
376
+ assert.equal(detailProjection.task.content, "reloaded external task edit");
377
+ pass("workspace.tasks progressive disclosure", "summary -> headers -> one Task detail through real MCP");
378
+
379
+ for (let index = 0; index < 29; index += 1) {
380
+ const semanticWork = callTool(oauth.accessToken, sessionId, 160 + index, "read", {
381
+ workspaceId,
382
+ path: "README.md",
383
+ });
384
+ assert.doesNotMatch(toolText(semanticWork), /Reminder: this Workspace has unfinished active Tasks/);
385
+ }
386
+ const reminderWork = callTool(oauth.accessToken, sessionId, 189, "read", {
387
+ workspaceId,
388
+ path: "README.md",
389
+ });
390
+ assert.match(toolText(reminderWork), /Reminder: this Workspace has unfinished active Tasks/);
391
+ assert.equal(toolText(reminderWork).includes("reloaded external task edit"), false);
392
+ pass("workspace.tasks reminder", "default 30 semantic work calls emitted one body-free reminder");
393
+
329
394
  const repeatedOpen = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
330
395
  path: checkoutWorkspace,
331
396
  newWorkspace: true,
@@ -391,14 +456,16 @@ try {
391
456
  workspaceId,
392
457
  name: "workspace.tasks",
393
458
  action: "run",
394
- arguments: { operation: "get" },
459
+ arguments: {
460
+ operation: "get",
461
+ level: "detail",
462
+ listId: checkoutListId,
463
+ taskId: checkoutTaskId,
464
+ },
395
465
  });
396
466
  assert.equal(resumedCheckoutTasks.isError, undefined);
397
- assert.equal(resumedCheckoutTasks.structuredContent.result.lists[0].tasks[0].id, checkoutTaskId);
398
- assert.equal(
399
- resumedCheckoutTasks.structuredContent.result.lists[0].tasks[0].content,
400
- "reloaded external task edit",
401
- );
467
+ assert.equal(resumedCheckoutTasks.structuredContent.result.task.id, checkoutTaskId);
468
+ assert.equal(resumedCheckoutTasks.structuredContent.result.task.content, "reloaded external task edit");
402
469
  assert.equal(resumedOriginal.structuredContent.contextFingerprint, opened.structuredContent.contextFingerprint);
403
470
 
404
471
  const deleteOpened = callTool(oauth.accessToken, sessionId, 91, "open_workspace", {
@@ -414,7 +481,72 @@ try {
414
481
  arguments: { operation: "list.create", name: "delete with workspace" },
415
482
  });
416
483
  assert.equal(deleteTaskList.isError, undefined);
484
+ const deleteTaskListId = deleteTaskList.structuredContent.result.lists[0].id;
485
+ const inspectionTask = callTool(oauth.accessToken, sessionId, 190, "capability", {
486
+ workspaceId: deleteWorkspaceId,
487
+ name: "workspace.tasks",
488
+ action: "run",
489
+ arguments: {
490
+ operation: "task.create",
491
+ listId: deleteTaskListId,
492
+ subject: "Inspect safely",
493
+ content: "INSPECTION_ACCEPTANCE_TASK_BODY_SECRET",
494
+ status: "in_progress",
495
+ },
496
+ });
497
+ assert.equal(inspectionTask.isError, undefined);
417
498
  assert.ok(existsSync(deleteTaskStatePath));
499
+
500
+ const inspectionBefore = callTool(oauth.accessToken, sessionId, 191, "open_workspace", {
501
+ action: "list",
502
+ workspaceId: deleteWorkspaceId,
503
+ }, workspaceConversationMeta);
504
+ const inspectionBeforeEntry = inspectionBefore.structuredContent.workspaces[0];
505
+ assert.equal(inspectionBeforeEntry.current, false);
506
+ const inspectedWorkspace = callTool(oauth.accessToken, sessionId, 192, "open_workspace", {
507
+ action: "inspect",
508
+ workspaceId: deleteWorkspaceId,
509
+ }, workspaceConversationMeta);
510
+ assert.equal(inspectedWorkspace.structuredContent.action, "inspect");
511
+ const inspectionProjection = inspectedWorkspace.structuredContent.inspection;
512
+ assert.equal(inspectionProjection.workspaceId, deleteWorkspaceId);
513
+ assert.equal(inspectionProjection.kind, "workspace");
514
+ assert.equal(inspectionProjection.location, "local");
515
+ assert.equal(inspectionProjection.root, lifecycleDeleteWorkspace);
516
+ assert.equal(inspectionProjection.taskSummary.level, "summary");
517
+ assert.equal(inspectionProjection.taskSummary.lists[0].taskCount, 1);
518
+ assert.equal(inspectionProjection.taskSummary.lists[0].unfinishedTaskCount, 1);
519
+ const inspectionJson = JSON.stringify(inspectedWorkspace);
520
+ for (const forbidden of [
521
+ "INSPECTION_ACCEPTANCE_BOOTSTRAP_SECRET",
522
+ "INSPECTION_ACCEPTANCE_TASK_BODY_SECRET",
523
+ "\"fingerprint\"",
524
+ "\"agentsFiles\"",
525
+ "\"availableAgentsFiles\"",
526
+ "\"capabilityGuides\"",
527
+ "\"skillDiagnostics\"",
528
+ "\"agentProviders\"",
529
+ "\"contextFingerprint\"",
530
+ "\"capabilityFingerprint\"",
531
+ "\"memberContext\"",
532
+ ]) {
533
+ assert.equal(inspectionJson.includes(forbidden), false, `Workspace inspect leaked ${forbidden}`);
534
+ }
535
+ const inspectionAfter = callTool(oauth.accessToken, sessionId, 193, "open_workspace", {
536
+ action: "list",
537
+ workspaceId: deleteWorkspaceId,
538
+ }, workspaceConversationMeta);
539
+ assert.equal(inspectionAfter.structuredContent.workspaces[0].lastUsedAt, inspectionBeforeEntry.lastUsedAt);
540
+ assert.equal(inspectionAfter.structuredContent.workspaces[0].current, false);
541
+ const callerAfterInspection = callTool(oauth.accessToken, sessionId, 194, "open_workspace", {
542
+ workspaceId,
543
+ context: "auto",
544
+ }, workspaceConversationMeta);
545
+ assert.equal(callerAfterInspection.structuredContent.workspaceId, workspaceId);
546
+ assert.equal(callerAfterInspection.structuredContent.agentsFiles, undefined);
547
+ assert.equal(callerAfterInspection.structuredContent.capabilityGuides, undefined);
548
+ pass("Workspace inspection", "cross-Workspace metadata + Task summary stayed read-only and bootstrap-free");
549
+
418
550
  const deleteClosed = callTool(oauth.accessToken, sessionId, 92, "close_workspace", {
419
551
  workspaceId: deleteWorkspaceId,
420
552
  });
@@ -729,6 +861,18 @@ try {
729
861
  });
730
862
  assert.equal(closedWorktreeInventory.structuredContent.workspaces.length, 1);
731
863
  assert.equal(closedWorktreeInventory.structuredContent.workspaces[0].state, "closed");
864
+ const closedWorktreeInspection = callTool(oauth.accessToken, sessionId, 195, "open_workspace", {
865
+ action: "inspect",
866
+ workspaceId: worktreeWorkspaceId,
867
+ });
868
+ const closedWorktreeProjection = closedWorktreeInspection.structuredContent.inspection;
869
+ assert.equal(closedWorktreeProjection.kind, "workspace");
870
+ assert.equal(closedWorktreeProjection.mode, "worktree");
871
+ assert.equal(closedWorktreeProjection.managed, true);
872
+ assert.equal(closedWorktreeProjection.state, "closed");
873
+ assert.equal(closedWorktreeProjection.rootValid, false);
874
+ assert.equal(existsSync(managedWorktreePath), false);
875
+ assert.equal(JSON.stringify(closedWorktreeInspection).includes("\"fingerprint\""), false);
732
876
 
733
877
  const reopenedWorktree = callTool(oauth.accessToken, sessionId, 111, "open_workspace", {
734
878
  workspaceId: worktreeWorkspaceId,
@@ -848,6 +992,20 @@ try {
848
992
  arguments: { operation: "get" },
849
993
  });
850
994
  assert.equal(closedCompositeTasks.isError, true);
995
+ const closedCompositeInspection = callTool(oauth.accessToken, sessionId, 196, "open_workspace", {
996
+ action: "inspect",
997
+ workspaceId: compositeWorkspaceId,
998
+ });
999
+ const closedCompositeProjection = closedCompositeInspection.structuredContent.inspection;
1000
+ assert.equal(closedCompositeProjection.kind, "composite");
1001
+ assert.equal(closedCompositeProjection.state, "closed");
1002
+ assert.equal(closedCompositeProjection.members[0].workspaceId, workspaceId);
1003
+ assert.equal(closedCompositeProjection.members[0].known, true);
1004
+ assert.equal(closedCompositeProjection.taskSummary.lists[0].taskCount, 1);
1005
+ assert.equal(
1006
+ JSON.stringify(closedCompositeInspection).includes("Composite-owned state"),
1007
+ false,
1008
+ );
851
1009
 
852
1010
  const reopenedComposite = callTool(oauth.accessToken, sessionId, 121, "open_workspace", {
853
1011
  workspaceId: compositeWorkspaceId,
@@ -860,14 +1018,16 @@ try {
860
1018
  workspaceId: compositeWorkspaceId,
861
1019
  name: "workspace.tasks",
862
1020
  action: "run",
863
- arguments: { operation: "get" },
1021
+ arguments: {
1022
+ operation: "get",
1023
+ level: "detail",
1024
+ listId: compositeTaskListId,
1025
+ taskId: compositeTaskId,
1026
+ },
864
1027
  });
865
1028
  assert.equal(reopenedCompositeTasks.isError, undefined);
866
- assert.equal(reopenedCompositeTasks.structuredContent.result.lists[0].tasks[0].id, compositeTaskId);
867
- assert.equal(
868
- reopenedCompositeTasks.structuredContent.result.lists[0].tasks[0].content,
869
- "Composite-owned state",
870
- );
1029
+ assert.equal(reopenedCompositeTasks.structuredContent.result.task.id, compositeTaskId);
1030
+ assert.equal(reopenedCompositeTasks.structuredContent.result.task.content, "Composite-owned state");
871
1031
  const reopenedCompositeRead = callTool(oauth.accessToken, sessionId, 122, "read", {
872
1032
  workspaceId: compositeWorkspaceId,
873
1033
  member: "code",