@gajae-code/coding-agent 0.4.3 → 0.4.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/async/job-manager.d.ts +19 -1
  3. package/dist/types/cli/fast-help.d.ts +1 -0
  4. package/dist/types/cli/setup-cli.d.ts +16 -1
  5. package/dist/types/commands/coordinator.d.ts +19 -0
  6. package/dist/types/commands/harness.d.ts +3 -0
  7. package/dist/types/commands/mcp-serve.d.ts +24 -0
  8. package/dist/types/commands/setup.d.ts +47 -0
  9. package/dist/types/config/model-registry.d.ts +3 -0
  10. package/dist/types/config/models-config-schema.d.ts +5 -0
  11. package/dist/types/coordinator/contract.d.ts +4 -0
  12. package/dist/types/coordinator-mcp/policy.d.ts +24 -0
  13. package/dist/types/coordinator-mcp/safety.d.ts +26 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +58 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +13 -0
  16. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  17. package/dist/types/harness-control-plane/finalize.d.ts +5 -0
  18. package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
  19. package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
  20. package/dist/types/harness-control-plane/receipts.d.ts +46 -0
  21. package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
  22. package/dist/types/harness-control-plane/types.d.ts +9 -1
  23. package/dist/types/main.d.ts +2 -2
  24. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  25. package/dist/types/modes/utils/abort-message.d.ts +4 -0
  26. package/dist/types/session/session-manager.d.ts +8 -0
  27. package/dist/types/setup/hermes-setup.d.ts +78 -0
  28. package/dist/types/task/fork-context-advisory.d.ts +13 -0
  29. package/dist/types/task/receipt.d.ts +1 -0
  30. package/dist/types/task/render.d.ts +7 -1
  31. package/dist/types/task/roi-reconciliation.d.ts +27 -0
  32. package/dist/types/task/types.d.ts +10 -0
  33. package/dist/types/tools/subagent-render.d.ts +25 -0
  34. package/dist/types/tools/subagent.d.ts +5 -1
  35. package/package.json +8 -7
  36. package/scripts/build-binary.ts +4 -0
  37. package/src/async/job-manager.ts +43 -1
  38. package/src/cli/fast-help.ts +80 -0
  39. package/src/cli/setup-cli.ts +95 -2
  40. package/src/cli.ts +109 -16
  41. package/src/commands/coordinator.ts +113 -0
  42. package/src/commands/harness.ts +92 -9
  43. package/src/commands/mcp-serve.ts +63 -0
  44. package/src/commands/setup.ts +34 -1
  45. package/src/config/models-config-schema.ts +1 -0
  46. package/src/coordinator/contract.ts +21 -0
  47. package/src/coordinator-mcp/policy.ts +160 -0
  48. package/src/coordinator-mcp/safety.ts +80 -0
  49. package/src/coordinator-mcp/server.ts +1519 -0
  50. package/src/cursor.ts +30 -2
  51. package/src/extensibility/extensions/types.ts +13 -0
  52. package/src/gjc-runtime/launch-worktree.ts +12 -1
  53. package/src/gjc-runtime/session-state-sidecar.ts +117 -0
  54. package/src/harness-control-plane/finalize.ts +39 -5
  55. package/src/harness-control-plane/owner.ts +9 -1
  56. package/src/harness-control-plane/phase-rollup.ts +96 -0
  57. package/src/harness-control-plane/receipt-ingest.ts +127 -0
  58. package/src/harness-control-plane/receipts.ts +229 -1
  59. package/src/harness-control-plane/rpc-adapter.ts +8 -0
  60. package/src/harness-control-plane/types.ts +29 -1
  61. package/src/internal-urls/docs-index.generated.ts +6 -4
  62. package/src/main.ts +7 -3
  63. package/src/modes/components/hook-selector.ts +109 -5
  64. package/src/modes/components/status-line.ts +6 -6
  65. package/src/modes/controllers/event-controller.ts +5 -4
  66. package/src/modes/controllers/extension-ui-controller.ts +16 -1
  67. package/src/modes/interactive-mode.ts +4 -5
  68. package/src/modes/print-mode.ts +1 -1
  69. package/src/modes/theme/theme.ts +2 -2
  70. package/src/modes/utils/abort-message.ts +41 -0
  71. package/src/modes/utils/context-usage.ts +15 -8
  72. package/src/modes/utils/ui-helpers.ts +5 -6
  73. package/src/prompts/agents/architect.md +6 -0
  74. package/src/prompts/agents/critic.md +6 -0
  75. package/src/prompts/agents/planner.md +8 -1
  76. package/src/sdk.ts +9 -4
  77. package/src/session/agent-session.ts +22 -5
  78. package/src/session/session-manager.ts +20 -0
  79. package/src/setup/hermes/templates/operator-instructions.v1.md +30 -0
  80. package/src/setup/hermes-setup.ts +484 -0
  81. package/src/task/fork-context-advisory.ts +99 -0
  82. package/src/task/index.ts +33 -2
  83. package/src/task/receipt.ts +2 -0
  84. package/src/task/render.ts +14 -0
  85. package/src/task/roi-reconciliation.ts +90 -0
  86. package/src/task/types.ts +7 -0
  87. package/src/tools/ask.ts +30 -10
  88. package/src/tools/index.ts +2 -2
  89. package/src/tools/renderers.ts +2 -0
  90. package/src/tools/subagent-render.ts +169 -0
  91. package/src/tools/subagent.ts +49 -7
  92. package/src/utils/title-generator.ts +16 -2
@@ -46,7 +46,7 @@ export interface ReceiptEnvelope<E = Record<string, unknown>> {
46
46
  export const RECEIPT_SCHEMA_VERSION = 1 as const;
47
47
 
48
48
  /** Deterministic stringify with sorted keys (stable hash basis). */
49
- function canonicalJson(value: unknown): string {
49
+ export function canonicalJson(value: unknown): string {
50
50
  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
51
51
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
52
52
  const obj = value as Record<string, unknown>;
@@ -96,9 +96,50 @@ export interface ValidationOutcome {
96
96
  reasons: string[];
97
97
  }
98
98
 
99
+ /** Reusable non-empty string guard for structural envelope checks. */
100
+ function isNonEmptyString(value: unknown): value is string {
101
+ return typeof value === "string" && value.length > 0;
102
+ }
103
+
104
+ /**
105
+ * Validate the structural envelope fields independently of the hash. A receipt
106
+ * can be hash-self-consistent while carrying empty/missing identity fields (an
107
+ * attacker controls the bytes the hash is computed over), so these checks must
108
+ * run BEFORE any lifecycle transition is allowed. Fail-closed.
109
+ */
110
+ function validateStructure(receipt: ReceiptEnvelope<unknown>): string[] {
111
+ const reasons: string[] = [];
112
+ if (!isNonEmptyString(receipt.receiptId)) reasons.push("envelope-missing-receiptId");
113
+ if (!isNonEmptyString(receipt.sessionId)) reasons.push("envelope-missing-sessionId");
114
+ if (!isNonEmptyString(receipt.source)) reasons.push("envelope-missing-source");
115
+ if (!isNonEmptyString(receipt.createdAt)) reasons.push("envelope-missing-createdAt");
116
+ // Family vocabulary itself is enforced by `validateFamily`; here we only
117
+ // require a non-empty family token so the envelope is well-formed.
118
+ if (!isNonEmptyString(receipt.family)) reasons.push("envelope-missing-family");
119
+ if (typeof receipt.valid !== "boolean") reasons.push("envelope-bad-valid");
120
+ if (typeof receipt.sha256 !== "string") reasons.push("envelope-missing-sha256");
121
+ const subject = receipt.subject as ReceiptSubject | undefined;
122
+ if (!subject || typeof subject !== "object" || Array.isArray(subject) || !isNonEmptyString(subject.workspace)) {
123
+ reasons.push("envelope-bad-subject");
124
+ }
125
+ if (receipt.evidence === null || typeof receipt.evidence !== "object" || Array.isArray(receipt.evidence)) {
126
+ reasons.push("envelope-bad-evidence");
127
+ }
128
+ if (!receipt.artifactHashes || typeof receipt.artifactHashes !== "object" || Array.isArray(receipt.artifactHashes)) {
129
+ reasons.push("envelope-bad-artifactHashes");
130
+ }
131
+ return reasons;
132
+ }
133
+
99
134
  /** Recompute the hash and run structural family checks. Fail-closed. */
100
135
  export function validateReceipt(receipt: ReceiptEnvelope<unknown>): ValidationOutcome {
136
+ // Fail closed on malformed/non-object envelopes (null, undefined, arrays,
137
+ // primitives) instead of throwing while destructuring below.
138
+ if (receipt === null || typeof receipt !== "object" || Array.isArray(receipt)) {
139
+ return { valid: false, reasons: ["malformed-envelope"] };
140
+ }
101
141
  const reasons: string[] = [];
142
+ reasons.push(...validateStructure(receipt));
102
143
  const { sha256, ...rest } = receipt;
103
144
  if (sha256Hex(hashBasis(rest)) !== sha256) reasons.push("hash-mismatch");
104
145
  if (receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) reasons.push("schema-version-mismatch");
@@ -156,6 +197,10 @@ export interface ReviewVerdictEvidence {
156
197
  finalizedAt: string;
157
198
  /** Bounded summary code/reference for the verdict; never raw assistant text. */
158
199
  summaryRef: string | null;
200
+ /** Where the verdict came from: explicit operator input or extracted from final assistant text. */
201
+ verdictSource?: "input" | "assistant";
202
+ /** sha256 of the assistant text the verdict was extracted from, when sourced from the agent. */
203
+ assistantDigest?: string | null;
159
204
  }
160
205
 
161
206
  export interface ReviewFailureEvidence {
@@ -165,6 +210,10 @@ export interface ReviewFailureEvidence {
165
210
  failedAt: string;
166
211
  /** Routing hint for the operator/fallback path. */
167
212
  fallback: string;
213
+ /** sha256 of the assistant text examined for a verdict, when one was available. */
214
+ assistantDigest?: string | null;
215
+ /** Bounded, whitespace-collapsed assistant summary (never an unbounded transcript dump). */
216
+ assistantSummary?: string | null;
168
217
  }
169
218
 
170
219
  function validateFamily(receipt: ReceiptEnvelope<unknown>): string[] {
@@ -181,11 +230,187 @@ function validateFamily(receipt: ReceiptEnvelope<unknown>): string[] {
181
230
  return validateReviewVerdict(receipt.evidence as ReviewVerdictEvidence);
182
231
  case "review-failure":
183
232
  return validateReviewFailure(receipt.evidence as ReviewFailureEvidence);
233
+ case "phase-rollup":
234
+ return validatePhaseRollup(receipt.evidence as PhaseRollupEvidence);
184
235
  default:
185
236
  return [`unknown-family:${receipt.family}`];
186
237
  }
187
238
  }
188
239
 
240
+ // ---- Phase rollup (receipt-of-receipts) ----------------------------------------
241
+
242
+ /** Pointer back to one superseded child task receipt. */
243
+ export interface PhaseRollupChildPointer {
244
+ id: string;
245
+ status: "completed" | "failed" | "aborted" | "merge_failed" | "paused";
246
+ /** Artifact URI holding the child's full output, when available. */
247
+ outputUri: string | null;
248
+ /** Content hash of the child's output artifact, when available. */
249
+ outputSha256: string | null;
250
+ /** Hash of the child receipt itself (canonical JSON), for staleness checks. */
251
+ receiptSha256: string;
252
+ /**
253
+ * Per-child ROI accounting carried into the rollup so the aggregate totals
254
+ * below are recomputable/verifiable from child evidence (not self-reported).
255
+ * `tokens` is the child's effective token count; cost/cloned are null when
256
+ * the child reported no such accounting.
257
+ */
258
+ tokens: number;
259
+ costTotal: number | null;
260
+ clonedTokens: number | null;
261
+ lowRoi: boolean;
262
+ }
263
+
264
+ export interface PhaseRollupEvidence {
265
+ /** Harness lifecycle boundary this rollup was emitted at. */
266
+ phase: string;
267
+ children: PhaseRollupChildPointer[];
268
+ aggregate: {
269
+ childCount: number;
270
+ completed: number;
271
+ failed: number;
272
+ totalTokens: number;
273
+ totalCostTotal: number | null;
274
+ totalClonedTokens: number | null;
275
+ lowRoiChildIds: string[];
276
+ };
277
+ }
278
+
279
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
280
+
281
+ const PHASE_ROLLUP_CHILD_STATUSES = new Set(["completed", "failed", "aborted", "merge_failed", "paused"]);
282
+
283
+ /** Reconcile two recomputed-vs-reported numeric totals (null == "not reported"). */
284
+ function numbersReconcile(actual: number | null, expected: number | null): boolean {
285
+ if (actual === null || expected === null) return actual === expected;
286
+ return Math.abs(actual - expected) <= 1e-9;
287
+ }
288
+
289
+ /** True when two id lists describe the same set (order-independent). */
290
+ function sameIdSet(actual: readonly string[], expected: readonly string[]): boolean {
291
+ if (actual.length !== expected.length) return false;
292
+ const expectedSet = new Set(expected);
293
+ for (const id of actual) {
294
+ if (!expectedSet.has(id)) return false;
295
+ }
296
+ return new Set(actual).size === expectedSet.size;
297
+ }
298
+
299
+ export function validatePhaseRollup(e: PhaseRollupEvidence): string[] {
300
+ const reasons: string[] = [];
301
+ if (!e || typeof e.phase !== "string" || e.phase.length === 0) return ["phase-rollup-missing-phase"];
302
+ if (!Array.isArray(e.children) || e.children.length === 0) {
303
+ reasons.push("phase-rollup-empty-children");
304
+ return reasons;
305
+ }
306
+ const seenIds = new Set<string>();
307
+ let completedFromChildren = 0;
308
+ let failedFromChildren = 0;
309
+ let tokensFromChildren = 0;
310
+ let costFromChildren = 0;
311
+ let clonedFromChildren = 0;
312
+ let anyCost = false;
313
+ let anyCloned = false;
314
+ const lowRoiFromChildren: string[] = [];
315
+ for (const child of e.children) {
316
+ if (!child || typeof child.id !== "string" || child.id.length === 0) {
317
+ reasons.push("phase-rollup-child-missing-id");
318
+ continue;
319
+ }
320
+ if (seenIds.has(child.id)) reasons.push(`phase-rollup-duplicate-child-id:${child.id}`);
321
+ seenIds.add(child.id);
322
+ if (!PHASE_ROLLUP_CHILD_STATUSES.has(child.status)) {
323
+ reasons.push(`phase-rollup-child-bad-status:${child.id}`);
324
+ }
325
+ if (child.status === "completed") completedFromChildren++;
326
+ if (child.status === "failed" || child.status === "merge_failed") failedFromChildren++;
327
+ if (typeof child.receiptSha256 !== "string" || !SHA256_HEX.test(child.receiptSha256)) {
328
+ reasons.push(`phase-rollup-child-bad-receipt-hash:${child.id}`);
329
+ }
330
+ if (child.outputUri !== null && (typeof child.outputUri !== "string" || child.outputUri.length === 0)) {
331
+ reasons.push(`phase-rollup-child-bad-output-uri:${child.id}`);
332
+ }
333
+ if (child.outputSha256 !== null && !SHA256_HEX.test(child.outputSha256)) {
334
+ reasons.push(`phase-rollup-child-bad-output-hash:${child.id}`);
335
+ }
336
+ // Receipt-of-receipts integrity requires BOTH an output URI and its
337
+ // content hash. Reject either one-sided pairing fail-closed: a hash
338
+ // without a URI is unanchored, and a URI without a hash is unverifiable.
339
+ if (child.outputSha256 !== null && child.outputUri === null) {
340
+ reasons.push(`phase-rollup-child-orphan-output-hash:${child.id}`);
341
+ }
342
+ if (child.outputUri !== null && child.outputSha256 === null) {
343
+ reasons.push(`phase-rollup-child-orphan-output-uri:${child.id}`);
344
+ }
345
+ // Per-child ROI accounting must be well-formed before it can be summed
346
+ // for the recomputed aggregate reconciliation below.
347
+ if (typeof child.tokens !== "number" || !Number.isFinite(child.tokens) || child.tokens < 0) {
348
+ reasons.push(`phase-rollup-child-bad-tokens:${child.id}`);
349
+ } else {
350
+ tokensFromChildren += child.tokens;
351
+ }
352
+ if (child.costTotal !== null) {
353
+ if (typeof child.costTotal !== "number" || !Number.isFinite(child.costTotal) || child.costTotal < 0) {
354
+ reasons.push(`phase-rollup-child-bad-cost:${child.id}`);
355
+ } else {
356
+ anyCost = true;
357
+ costFromChildren += child.costTotal;
358
+ }
359
+ }
360
+ if (child.clonedTokens !== null) {
361
+ if (typeof child.clonedTokens !== "number" || !Number.isFinite(child.clonedTokens) || child.clonedTokens < 0) {
362
+ reasons.push(`phase-rollup-child-bad-cloned-tokens:${child.id}`);
363
+ } else {
364
+ anyCloned = true;
365
+ clonedFromChildren += child.clonedTokens;
366
+ }
367
+ }
368
+ if (typeof child.lowRoi !== "boolean") {
369
+ reasons.push(`phase-rollup-child-bad-low-roi:${child.id}`);
370
+ } else if (child.lowRoi) {
371
+ lowRoiFromChildren.push(child.id);
372
+ }
373
+ }
374
+ const aggregate = e.aggregate;
375
+ if (!aggregate || typeof aggregate.childCount !== "number") {
376
+ reasons.push("phase-rollup-missing-aggregate");
377
+ return reasons;
378
+ }
379
+ if (aggregate.childCount !== e.children.length) reasons.push("phase-rollup-child-count-mismatch");
380
+ if (aggregate.completed !== completedFromChildren) reasons.push("phase-rollup-aggregate-completed-mismatch");
381
+ if (aggregate.failed !== failedFromChildren) reasons.push("phase-rollup-aggregate-failed-mismatch");
382
+ for (const field of ["totalTokens", "completed", "failed"] as const) {
383
+ const value = aggregate[field];
384
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
385
+ reasons.push(`phase-rollup-aggregate-bad-${field}`);
386
+ }
387
+ }
388
+ for (const field of ["totalCostTotal", "totalClonedTokens"] as const) {
389
+ const value = aggregate[field];
390
+ if (value !== null && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) {
391
+ reasons.push(`phase-rollup-aggregate-bad-${field}`);
392
+ }
393
+ }
394
+ // Recompute the ROI aggregates from child evidence and fail closed on any
395
+ // self-reported total that does not reconcile. `null` is the canonical
396
+ // "no child reported this metric" value, mirroring the builder.
397
+ if (aggregate.totalTokens !== tokensFromChildren) {
398
+ reasons.push("phase-rollup-aggregate-tokens-mismatch");
399
+ }
400
+ if (!numbersReconcile(aggregate.totalCostTotal, anyCost ? costFromChildren : null)) {
401
+ reasons.push("phase-rollup-aggregate-cost-mismatch");
402
+ }
403
+ if (!numbersReconcile(aggregate.totalClonedTokens, anyCloned ? clonedFromChildren : null)) {
404
+ reasons.push("phase-rollup-aggregate-cloned-tokens-mismatch");
405
+ }
406
+ if (!Array.isArray(aggregate.lowRoiChildIds)) {
407
+ reasons.push("phase-rollup-aggregate-bad-lowRoiChildIds");
408
+ } else if (!sameIdSet(aggregate.lowRoiChildIds, lowRoiFromChildren)) {
409
+ reasons.push("phase-rollup-aggregate-low-roi-mismatch");
410
+ }
411
+ return reasons;
412
+ }
413
+
189
414
  function validateVanish(e: VanishEvidence): string[] {
190
415
  const reasons: string[] = [];
191
416
  if (!e || typeof e.gitDelta !== "string") return ["vanish-missing-evidence"];
@@ -230,6 +455,9 @@ function validateCompletion(e: CompletionEvidence): string[] {
230
455
  reasons.push("completion-missing-validation-receipts");
231
456
  }
232
457
  if (Array.isArray(e.blockers) && e.blockers.length > 0) reasons.push("completion-has-blockers");
458
+ // NOTE: evidence.finalLifecycle vs the lifecycle target is reconciled
459
+ // fail-closed at the ingest layer (`evidenceContradiction` ->
460
+ // `evidence-lifecycle-mismatch`), where the actual transition is gated.
233
461
  return reasons;
234
462
  }
235
463
 
@@ -36,6 +36,8 @@ export interface HarnessRpc {
36
36
  isLive?(): boolean;
37
37
  /** ISO timestamp of the last observed event frame, or null. */
38
38
  lastFrameAt?(): string | null;
39
+ /** Final assistant text from the live session (for review-verdict extraction); null when unavailable. */
40
+ getLastAssistantText?(): Promise<string | null>;
39
41
  }
40
42
 
41
43
  export interface AcceptanceResult {
@@ -240,6 +242,12 @@ export class GajaeCodeRpc implements HarnessRpc {
240
242
  };
241
243
  }
242
244
 
245
+ async getLastAssistantText(): Promise<string | null> {
246
+ const res = await this.#send({ type: "get_last_assistant_text" });
247
+ const data = (res.data ?? {}) as Record<string, unknown>;
248
+ return typeof data.text === "string" ? data.text : null;
249
+ }
250
+
243
251
  async sendPrompt(prompt: string): Promise<{ commandId: string; ack: boolean }> {
244
252
  const id = randomUUID();
245
253
  const ackPromise = new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -29,6 +29,33 @@ export function isReviewVerdict(value: unknown): value is ReviewVerdict {
29
29
  return typeof value === "string" && (REVIEW_VERDICTS as readonly string[]).includes(value);
30
30
  }
31
31
 
32
+ /**
33
+ * Alias verdict tokens accepted from free-form assistant text, mapped to their canonical verdict.
34
+ * `MERGE_READY` is treated as `APPROVE_MERGE_READY`.
35
+ */
36
+ const VERDICT_ALIASES: Readonly<Record<string, ReviewVerdict>> = {
37
+ MERGE_READY: "APPROVE_MERGE_READY",
38
+ };
39
+
40
+ /**
41
+ * Extract a single closed-vocabulary review verdict from free-form assistant text.
42
+ *
43
+ * Scans for canonical verdict tokens (and accepted aliases) as whole words and returns the
44
+ * LAST occurrence — the agent's final stated decision wins over any earlier mention. Returns
45
+ * null when no allowed token is present, so the finalizer fails closed on a missing verdict.
46
+ */
47
+ export function extractReviewVerdict(text: string | null | undefined): ReviewVerdict | null {
48
+ if (typeof text !== "string" || text.length === 0) return null;
49
+ const tokens = [...REVIEW_VERDICTS, ...Object.keys(VERDICT_ALIASES)];
50
+ const pattern = new RegExp(`\\b(${tokens.join("|")})\\b`, "g");
51
+ let last: ReviewVerdict | null = null;
52
+ for (const match of text.matchAll(pattern)) {
53
+ const token = match[1];
54
+ last = VERDICT_ALIASES[token] ?? (token as ReviewVerdict);
55
+ }
56
+ return last;
57
+ }
58
+
32
59
  /** Lifecycle states of an operated session. */
33
60
  export type HarnessLifecycle =
34
61
  | "new"
@@ -68,7 +95,8 @@ export type ReceiptFamily =
68
95
  | "validation"
69
96
  | "completion"
70
97
  | "review-verdict"
71
- | "review-failure";
98
+ | "review-failure"
99
+ | "phase-rollup";
72
100
 
73
101
  /** The CLI verbs / primitives exposed by `gjc harness <verb>`. */
74
102
  export type HarnessVerb =