@namzu/sdk 44.0.0 → 44.1.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  3. package/dist/bridge/sse/mapper.js +1 -0
  4. package/dist/bridge/sse/mapper.js.map +1 -1
  5. package/dist/runtime/query/events.d.ts.map +1 -1
  6. package/dist/runtime/query/events.js +1 -0
  7. package/dist/runtime/query/events.js.map +1 -1
  8. package/dist/runtime/query/index.d.ts +15 -0
  9. package/dist/runtime/query/index.d.ts.map +1 -1
  10. package/dist/runtime/query/index.js +1 -0
  11. package/dist/runtime/query/index.js.map +1 -1
  12. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  13. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  14. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  15. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  16. package/dist/runtime/query/iteration/phases/tool-review.js +11 -1
  17. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  18. package/dist/tools/task/create.d.ts.map +1 -1
  19. package/dist/tools/task/create.js +4 -1
  20. package/dist/tools/task/create.js.map +1 -1
  21. package/dist/tools/task/list.d.ts.map +1 -1
  22. package/dist/tools/task/list.js +4 -1
  23. package/dist/tools/task/list.js.map +1 -1
  24. package/dist/tools/task/present.d.ts +18 -0
  25. package/dist/tools/task/present.d.ts.map +1 -0
  26. package/dist/tools/task/present.js +88 -0
  27. package/dist/tools/task/present.js.map +1 -0
  28. package/dist/tools/task/update.d.ts.map +1 -1
  29. package/dist/tools/task/update.js +9 -1
  30. package/dist/tools/task/update.js.map +1 -1
  31. package/dist/turn/reporter.js +1 -1
  32. package/dist/turn/reporter.js.map +1 -1
  33. package/dist/types/session/events.d.ts +8 -0
  34. package/dist/types/session/events.d.ts.map +1 -1
  35. package/dist/types/session/events.js.map +1 -1
  36. package/dist/utils/log/redact.js +1 -1
  37. package/dist/utils/log/redact.js.map +1 -1
  38. package/dist/utils/log/sinks.d.ts +1 -1
  39. package/dist/utils/log/sinks.js +2 -2
  40. package/dist/utils/log/sinks.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/bridge/sse/mapper.ts +1 -0
  43. package/src/runtime/query/events.ts +1 -0
  44. package/src/runtime/query/index.ts +17 -0
  45. package/src/runtime/query/iteration/phases/context.ts +2 -0
  46. package/src/runtime/query/iteration/phases/tool-review.ts +12 -1
  47. package/src/tools/task/create.ts +4 -1
  48. package/src/tools/task/list.ts +4 -1
  49. package/src/tools/task/present.ts +102 -0
  50. package/src/tools/task/update.ts +9 -1
  51. package/src/turn/reporter.ts +1 -1
  52. package/src/types/session/events.ts +8 -0
  53. package/src/utils/log/redact.ts +1 -1
  54. package/src/utils/log/sinks.ts +2 -2
@@ -466,6 +466,7 @@ const MAPPING: {
466
466
  subject: e.subject,
467
467
  status: e.status,
468
468
  owner: e.owner ?? null,
469
+ ...(e.deleted ? { deleted: true } : {}),
469
470
  }),
470
471
  },
471
472
 
@@ -283,6 +283,7 @@ export class EventTranslator {
283
283
  status: task.status,
284
284
  owner: task.owner,
285
285
  ...(task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
286
+ ...(event.type === 'task.deleted' ? { deleted: true as const } : {}),
286
287
  })
287
288
  break
288
289
  default: {
@@ -739,6 +739,22 @@ export interface QueryParams {
739
739
  current: import('../../types/permission/index.js').PermissionMode
740
740
  }
741
741
 
742
+ /**
743
+ * Whether a batch that needs no review still goes to `resumeHandler`.
744
+ *
745
+ * A batch every call of which an authorization rule allows, or which a
746
+ * grant from earlier in the turn covers, runs without asking the handler.
747
+ * That is right while the handler would approve it anyway, and wrong under
748
+ * a policy stricter than the rules: a read-only mode such as `plan`, which
749
+ * refuses a change a rule would let through. Consulted once per batch,
750
+ * just before either shortcut; `true` sends the batch to the handler, each
751
+ * call carrying the gate's decision in `authorization`. A rule's `deny`
752
+ * still refuses a call whatever this returns.
753
+ *
754
+ * Absent, both shortcuts apply as they always have.
755
+ */
756
+ reviewAllowedCalls?: () => boolean
757
+
742
758
  /**
743
759
  * A name for the policy `resumeHandler` implements.
744
760
  *
@@ -1480,6 +1496,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1480
1496
  // Turn-scoped. An approval is a statement about this turn's work;
1481
1497
  // carrying one into a later turn would be reuse nobody agreed to.
1482
1498
  toolGrants: new ToolGrantSet(),
1499
+ ...(params.reviewAllowedCalls ? { reviewAllowedCalls: params.reviewAllowedCalls } : {}),
1483
1500
  // Turn-scoped for the same reason. A repeat count carried into a later
1484
1501
  // run is a claim about work nobody repeated, and a module-level map
1485
1502
  // would leak exactly that way.
@@ -166,6 +166,8 @@ export interface IterationContext {
166
166
  * asked about again. Absent on paths that do not review tools.
167
167
  */
168
168
  readonly toolGrants?: ToolGrantSet
169
+ /** See `QueryParams.reviewAllowedCalls`. Absent: allowed and granted batches skip review. */
170
+ readonly reviewAllowedCalls?: () => boolean
169
171
  /**
170
172
  * Absent when the host opted out with `repeatCallAdvisory: false`. The
171
173
  * opt-out is the ABSENCE, not a flag read at every call site, so a code
@@ -226,6 +226,16 @@ export async function* runToolReview(
226
226
  // must not be able to release them.
227
227
  const gateDenied = new Map<string, string>()
228
228
 
229
+ // Sampled once, the first time a shortcut asks, so both shortcuts below
230
+ // read the same answer for this batch. A policy stricter than the rules
231
+ // (a read-only mode) sees the batch rather than letting an allowance or a
232
+ // grant run it past the handler.
233
+ let reviewAllowedSample: boolean | undefined
234
+ const reviewAllowed = (): boolean => {
235
+ reviewAllowedSample ??= ctx.reviewAllowedCalls?.() === true
236
+ return reviewAllowedSample
237
+ }
238
+
229
239
  // The operator's policy runs FIRST, and a grant cannot overrule it.
230
240
  //
231
241
  // The grant short-circuit used to sit above this block and return, so a
@@ -289,7 +299,7 @@ export async function* runToolReview(
289
299
  const allAllowed = gateResults.every((gr) => gr.gateResult.decision === 'allow')
290
300
  const allDenied = gateResults.every((gr) => gr.gateResult.decision === 'deny')
291
301
 
292
- if (allAllowed) {
302
+ if (allAllowed && !reviewAllowed()) {
293
303
  ctx.log.debug('Authorization gate: all tool calls pre-approved', {
294
304
  'namzu.tool.names': gateResults.map((gr) => gr.toolCall.name),
295
305
  })
@@ -334,6 +344,7 @@ export async function* runToolReview(
334
344
  // working directory or a run outside the sandbox.
335
345
  if (
336
346
  ctx.toolGrants &&
347
+ !reviewAllowed() &&
337
348
  gateDenied.size === 0 &&
338
349
  escalated().length === 0 &&
339
350
  toolCallSummaries.every((tc) => ctx.toolGrants?.covers(tc))
@@ -4,6 +4,7 @@ import type { ToolDefinition } from '../../types/tool/index.js'
4
4
  import { asTaskId } from '../../utils/id.js'
5
5
  import { defineTool } from '../defineTool.js'
6
6
  import type { TaskToolScope } from './index.js'
7
+ import { presentTaskCreateCall, presentTaskCreateResult } from './present.js'
7
8
 
8
9
  export function buildTaskCreateTool(
9
10
  taskStore: TaskStore,
@@ -35,6 +36,8 @@ export function buildTaskCreateTool(
35
36
  readOnly: false,
36
37
  destructive: false,
37
38
  concurrencySafe: true,
39
+ presentCall: presentTaskCreateCall,
40
+ presentResult: presentTaskCreateResult,
38
41
  async execute({ subject, description, activeForm, owner, blockedBy, metadata }) {
39
42
  const task = await taskStore.create({
40
43
  sessionId: scope.sessionId,
@@ -50,7 +53,7 @@ export function buildTaskCreateTool(
50
53
  return {
51
54
  success: true,
52
55
  output: `Task created: ${task.id} — "${subject}"${owner ? ` [owner: ${owner}]` : ''}`,
53
- data: { id: task.id, status: task.status, owner: task.owner },
56
+ data: { id: task.id, subject: task.subject, status: task.status, owner: task.owner },
54
57
  }
55
58
  },
56
59
  })
@@ -4,6 +4,7 @@ import { type TaskStore, isTerminalTaskStatus } from '../../types/task/index.js'
4
4
  import type { ToolDefinition } from '../../types/tool/index.js'
5
5
  import { defineTool } from '../defineTool.js'
6
6
  import type { TaskToolScope } from './index.js'
7
+ import { countTasks, presentTaskListCall, presentTaskListResult } from './present.js'
7
8
 
8
9
  export function buildTaskListTool(
9
10
  taskStore: TaskStore,
@@ -19,6 +20,8 @@ export function buildTaskListTool(
19
20
  readOnly: true,
20
21
  destructive: false,
21
22
  concurrencySafe: true,
23
+ presentCall: presentTaskListCall,
24
+ presentResult: presentTaskListResult,
22
25
  async execute() {
23
26
  const all = await taskStore.list({ sessionId: scope.sessionId })
24
27
  // Blockers resolve against every task of the session, shown or not: a
@@ -53,7 +56,7 @@ export function buildTaskListTool(
53
56
  output:
54
57
  tasks.length === 0
55
58
  ? 'No planning tasks yet. This list does not report delegated agent status; use agent_task_list when available.'
56
- : `${stats.total} tasks: ${stats.completed} completed, ${stats.in_progress} in progress, ${stats.pending} pending.`,
59
+ : `${countTasks(stats.total)}: ${stats.completed} completed, ${stats.in_progress} in progress, ${stats.pending} pending.`,
57
60
  data: { tasks: summary, stats },
58
61
  }
59
62
  },
@@ -0,0 +1,102 @@
1
+ import type { ToolResult } from '../../types/tool/index.js'
2
+ import type { ToolCallView, ToolResultView } from '../../types/tool/presentation.js'
3
+
4
+ /**
5
+ * How the task tools read to a person.
6
+ *
7
+ * The model-facing output of these tools names task ids, because the model
8
+ * needs them to call `task_update`. None of that is for the operator: with no
9
+ * opinion of its own a task call fell through to the generic presenter, which
10
+ * printed the arguments (`{"id":"01a0…","status":"completed"}`) as the call
11
+ * row and the model's receipt (`Task 01a0… updated — status: completed`) as
12
+ * the result. So each tool says what happened in words — `Added task ·
13
+ * Write the parser` — and a successful receipt adds nothing beneath it.
14
+ *
15
+ * Deliberately no ids, no owner and no JSON anywhere in these views. A host
16
+ * that wants the ids still has the tool result and its `data`.
17
+ */
18
+
19
+ const MAX_SUBJECT = 100
20
+
21
+ function oneLine(value: string): string {
22
+ const flat = value.replace(/\s+/g, ' ').trim()
23
+ return flat.length > MAX_SUBJECT ? `${flat.slice(0, MAX_SUBJECT - 1)}…` : flat
24
+ }
25
+
26
+ function activity(label: string): ToolCallView {
27
+ return { kind: 'generic', presentation: 'activity', label }
28
+ }
29
+
30
+ /** A successful receipt: the call row already said it. */
31
+ const HIDDEN_RESULT: ToolResultView = { kind: 'generic', label: '', visibility: 'hidden' }
32
+
33
+ /** `N task` / `N tasks`. */
34
+ export function countTasks(count: number): string {
35
+ return `${count} task${count === 1 ? '' : 's'}`
36
+ }
37
+
38
+ /** What a failed task call says, without the id the model passed. */
39
+ function failure(result: ToolResult, fallback: string): ToolResultView {
40
+ const text = `${result.error ?? result.output ?? ''}`
41
+ return {
42
+ kind: 'generic',
43
+ label: /not found/i.test(text) ? 'No task has that id' : fallback,
44
+ }
45
+ }
46
+
47
+ export function presentTaskCreateCall(input: { readonly subject?: unknown }): ToolCallView {
48
+ const subject = typeof input.subject === 'string' ? oneLine(input.subject) : ''
49
+ return activity(subject ? `Add task · ${subject}` : 'Add task')
50
+ }
51
+
52
+ export function presentTaskCreateResult(_input: unknown, result: ToolResult): ToolResultView {
53
+ if (!result.success) return failure(result, 'The task was not added')
54
+ return HIDDEN_RESULT
55
+ }
56
+
57
+ /** The verb for a `task_update` call, from the status it asks for. */
58
+ export function taskUpdateVerb(status: unknown): string {
59
+ switch (status) {
60
+ case 'in_progress':
61
+ return 'Start task'
62
+ case 'completed':
63
+ return 'Complete task'
64
+ case 'deleted':
65
+ return 'Remove task'
66
+ case 'pending':
67
+ return 'Reopen task'
68
+ default:
69
+ return 'Update task'
70
+ }
71
+ }
72
+
73
+ export function presentTaskUpdateCall(input: {
74
+ readonly status?: unknown
75
+ readonly subject?: unknown
76
+ }): ToolCallView {
77
+ const verb = taskUpdateVerb(input.status)
78
+ const subject = typeof input.subject === 'string' ? oneLine(input.subject) : ''
79
+ return activity(subject ? `${verb} · ${subject}` : verb)
80
+ }
81
+
82
+ export function presentTaskUpdateResult(_input: unknown, result: ToolResult): ToolResultView {
83
+ if (!result.success) return failure(result, 'The task was not changed')
84
+ return HIDDEN_RESULT
85
+ }
86
+
87
+ export function presentTaskListCall(): ToolCallView {
88
+ return activity('Check tasks')
89
+ }
90
+
91
+ export function presentTaskListResult(_input: unknown, result: ToolResult): ToolResultView {
92
+ if (!result.success) return { kind: 'generic', label: 'The task list could not be read' }
93
+ const stats = (result.data as { stats?: { total?: unknown; completed?: unknown } } | undefined)
94
+ ?.stats
95
+ const total = typeof stats?.total === 'number' ? stats.total : undefined
96
+ const completed = typeof stats?.completed === 'number' ? stats.completed : undefined
97
+ if (total === undefined || completed === undefined) return HIDDEN_RESULT
98
+ return {
99
+ kind: 'generic',
100
+ label: total === 0 ? 'No tasks yet' : `Tasks · ${completed}/${total} done`,
101
+ }
102
+ }
@@ -3,6 +3,7 @@ import type { TaskStore } from '../../types/task/index.js'
3
3
  import type { ToolDefinition } from '../../types/tool/index.js'
4
4
  import { asTaskId } from '../../utils/id.js'
5
5
  import { defineTool } from '../defineTool.js'
6
+ import { presentTaskUpdateCall, presentTaskUpdateResult } from './present.js'
6
7
 
7
8
  export function buildTaskUpdateTool(taskStore: TaskStore): ToolDefinition {
8
9
  return defineTool({
@@ -34,6 +35,8 @@ export function buildTaskUpdateTool(taskStore: TaskStore): ToolDefinition {
34
35
  readOnly: false,
35
36
  destructive: false,
36
37
  concurrencySafe: true,
38
+ presentCall: presentTaskUpdateCall,
39
+ presentResult: presentTaskUpdateResult,
37
40
  async execute({
38
41
  id,
39
42
  subject,
@@ -85,7 +88,12 @@ export function buildTaskUpdateTool(taskStore: TaskStore): ToolDefinition {
85
88
  return {
86
89
  success: true,
87
90
  output: `Task ${id} updated — status: ${updated.status}`,
88
- data: { id: updated.id, status: updated.status, owner: updated.owner },
91
+ data: {
92
+ id: updated.id,
93
+ subject: updated.subject,
94
+ status: updated.status,
95
+ owner: updated.owner,
96
+ },
89
97
  }
90
98
  },
91
99
  })
@@ -170,7 +170,7 @@ export function createTurnReporter(parentLogger?: Logger): TurnReporter {
170
170
  break
171
171
 
172
172
  case 'task_updated':
173
- log.info('Task updated', {
173
+ log.info(event.deleted ? 'Task removed' : 'Task updated', {
174
174
  'namzu.task.subject': event.subject,
175
175
  [NAMZU.TURN_ID]: event.turnId,
176
176
  'namzu.task.id': event.taskId,
@@ -944,6 +944,14 @@ type CoreSessionEvent =
944
944
  owner?: string
945
945
  /** See `task_created`. Carried on updates because an edge can be added later. */
946
946
  blockedBy?: readonly TaskId[]
947
+ /**
948
+ * The task was removed from the list (`task_update` with status
949
+ * `deleted`). `status` and `subject` are what it had when it went.
950
+ * Absent on every other update: without it a removal reached a
951
+ * reader as an update that changed nothing, so a checklist kept
952
+ * drawing the task as open.
953
+ */
954
+ deleted?: true
947
955
  }
948
956
  | {
949
957
  type: 'plugin_hook_executing'
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Runs once, in the pipeline, ahead of sink dispatch — not inside
4
4
  // jsonLinesSink, and not inside any other individual sink. A scan that only
5
- // jsonLinesSink ran would leave prettySink (the one `namzu run` actually
5
+ // jsonLinesSink ran would leave prettySink (the one `namzu exec` actually
6
6
  // uses) and every host-supplied sink with no second layer at all, which is
7
7
  // the gap a scoped-to-one-sink design would reintroduce: the machine-read
8
8
  // path would be the only protected one.
@@ -19,7 +19,7 @@ export const NOOP_SINK: LogSink = {
19
19
 
20
20
  /**
21
21
  * One JSON object per line — the canonical wire format for the machine-read
22
- * path (`namzu run-stream`'s stderr). JSON string-escaping neutralises
22
+ * path (`namzu exec --json`'s stderr). JSON string-escaping neutralises
23
23
  * `\n`/`\r` by construction, closing the log-forging surface without a
24
24
  * single character-stripping call site, which is bypassable anyway.
25
25
  *
@@ -67,7 +67,7 @@ const SEVERITY_LABEL: Record<LogRecord['severityText'], string> = {
67
67
  // guarantee yet that either is a constant (that lands with the CI gate in
68
68
  // later work) — a remote MCP server that names itself an escape sequence
69
69
  // erasing the previous line and printing a fake refusal is a real forging
70
- // attempt against the terminal `namzu run` writes to today, not a
70
+ // attempt against the terminal `namzu exec` writes to today, not a
71
71
  // hypothetical one.
72
72
  // biome-ignore lint/suspicious/noControlCharactersInRegex: this pattern IS the control-character filter — the escapes below are ASCII text (`\x00`-`\x1F`, `\x7F`), not raw bytes pasted into the source.
73
73
  const CONTROL_BYTE = /[\x00-\x1F\x7F]/g