vibe-coding-master 0.0.15 → 0.0.16

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 CHANGED
@@ -206,7 +206,7 @@ The same file stores recent repository paths. The translation API key is stored
206
206
 
207
207
  The sidebar `Settings` section also stores the UI theme preference in this file. The default is `system`, which follows the OS/browser color-scheme preference; users can cycle between `System`, `Light`, and `Dark`.
208
208
 
209
- The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus short sound that fires when VCM detects that the current full conversation round is complete. The `Try alert` button triggers the same local prompt and sound for testing.
209
+ The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus a soft two-note completion chime that fires when VCM detects that the current full conversation round is complete. The `Try alert` button triggers the same local prompt and sound for testing.
210
210
 
211
211
  Translation behavior:
212
212
 
@@ -258,7 +258,7 @@ For `.gitignore`, VCM uses a gitignore-native managed block:
258
258
 
259
259
  `.ai/vcm/` is the active VCM local control area, and `.claude/worktrees/` is the Claude-compatible task worktree area. The base repo keeps the task index; each task runtime repo keeps its own session, message, orchestration, and translation state.
260
260
 
261
- VCM also JSON-merges `.claude/settings.json` to install a Claude Code `Stop` hook. The hook posts directly to the local VCM backend, so roles do not need a VCM CLI command to report turn completion.
261
+ VCM also JSON-merges `.claude/settings.json` to install Claude Code `UserPromptSubmit` and `Stop` hooks. The hooks post directly to the local VCM backend, so roles do not need a VCM CLI command to confirm delivery or report turn completion.
262
262
 
263
263
  After applying harness changes, VCM reports the exact files changed and reminds the user to review and commit them before starting long-running work.
264
264
 
@@ -276,7 +276,8 @@ Claude Code role
276
276
  -> ends the Claude Code turn
277
277
  -> Stop hook calls VCM backend directly
278
278
  -> VCM scans pending route files
279
- -> VCM validates, snapshots, and dispatches pending messages
279
+ -> VCM validates, snapshots, and dispatches pending messages as delivering
280
+ -> UserPromptSubmit hook confirms accepted VCM messages as submitted
280
281
  ```
281
282
 
282
283
  Examples:
@@ -319,24 +320,25 @@ When it is on, VCM is in auto mode:
319
320
  - Roles write pending route files under `.ai/vcm/handoffs/messages/`.
320
321
  - On Claude Code `Stop`, VCM scans pending route files.
321
322
  - If the target role session is running, idle, and has no active message, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it.
322
- - After successful terminal submission, VCM snapshots the body in message history and clears the source route file.
323
+ - After successful terminal write, VCM snapshots the body as `delivering`.
324
+ - Claude Code `UserPromptSubmit` confirms that the prompt was accepted; VCM then marks the message `submitted` and clears the source route file if it still contains the same message.
323
325
  - When the GUI observes a newly submitted auto message, it switches the active role tab to that message's target role so the user can watch the next step.
324
- - VCM enforces per-role turn-taking: each target role can have at most one in-flight `submitted` message.
326
+ - VCM enforces per-role turn-taking: each target role can have at most one in-flight `delivering` or `submitted` message.
325
327
  - Additional pending files to a busy target role remain non-empty and are not written to that terminal.
326
328
  - When the target role later reaches `Stop`, VCM scans again and may deliver the next pending route file.
327
- - If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears open `pending_approval`, `queued`, `submitted`, and failed message records to `acknowledged`, and clears pending route files.
329
+ - If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears open `pending_approval`, `queued`, `delivering`, `submitted`, and failed message records to `acknowledged`, and clears pending route files.
328
330
 
329
- VCM Harness injects a Claude Code `Stop` hook into `.claude/settings.json`. Role tabs become `running` when VCM submits user input or a message to that embedded terminal, and `idle` after `Stop`; the terminal process status is still tracked separately. The injected role rules require a role to end its turn after writing or updating a message route file, rather than polling, looping, or waiting for another role inside the same Claude Code turn.
331
+ VCM Harness injects Claude Code `UserPromptSubmit` and `Stop` hooks into `.claude/settings.json`. Role tabs become `running` when Claude Code accepts a prompt and `idle` after `Stop`; VCM also marks a role `running` immediately after it writes a message to that embedded terminal. The terminal process status is still tracked separately. The injected role rules require a role to end its turn after writing or updating a message route file, rather than polling, looping, or waiting for another role inside the same Claude Code turn.
330
332
 
331
333
  The implementation keeps only the active manual/auto orchestration mode. It does not expose pause/resume, stage/approve/reject, or a separate agent-facing message CLI.
332
334
 
333
335
  ## Round Completion Alerts
334
336
 
335
- VCM detects conversation completion from hook-driven role activity state, not PTY silence. VCM terminal submission marks a role `running`, and `Stop` marks that role `idle` with a stop timestamp.
337
+ VCM detects conversation completion from hook-driven role activity state, not PTY silence. `UserPromptSubmit` marks a role `running`, and `Stop` marks that role `idle` with a stop timestamp.
336
338
 
337
339
  For role chains, VCM waits for the latest active message's target role to reach hook `Stop`. For example, if PM sends work to Coder and Coder sends a result back to PM, the round is not complete when Coder finishes; it is complete only after PM reaches `Stop` for the final response. If no VCM role message is involved, the latest direct role `Stop` can complete the round.
338
340
 
339
- When `Round alert` is enabled, the frontend polls the task round state, deduplicates each completion id, shows a small `Round complete` prompt, and plays a short local sound.
341
+ When `Round alert` is enabled, the frontend polls the task round state, deduplicates each completion id, shows a small `Round complete` prompt, and plays the local completion chime.
340
342
 
341
343
  ## Resume Behavior
342
344
 
@@ -1,4 +1,7 @@
1
1
  export function registerClaudeHookRoutes(app, deps) {
2
+ app.post("/api/hooks/claude-code", async (request) => {
3
+ return deps.claudeHookService.handleHook(request.body);
4
+ });
2
5
  app.post("/api/hooks/claude-code/stop", async (request) => {
3
6
  return deps.claudeHookService.handleStopHook(request.body);
4
7
  });
@@ -2,64 +2,126 @@ import { isRoleName } from "../../shared/constants.js";
2
2
  import { VcmError } from "../errors.js";
3
3
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
4
4
  export function createClaudeHookService(deps) {
5
- return {
6
- async handleStopHook(input) {
7
- if (!isRoleName(input.role)) {
8
- throw new VcmError({
9
- code: "HOOK_ROLE_INVALID",
10
- message: `Unknown hook role: ${input.role}`,
11
- statusCode: 400
12
- });
13
- }
14
- const eventName = parseStopEvent(input.event.hook_event_name);
15
- const project = await deps.projectService.getCurrentProject();
16
- if (!project) {
17
- throw new VcmError({
18
- code: "PROJECT_NOT_CONNECTED",
19
- message: "Connect a repository before accepting Claude Code hooks.",
20
- statusCode: 409
21
- });
22
- }
23
- const session = await deps.sessionService.recordClaudeHookEvent(project.repoRoot, {
24
- taskSlug: input.taskSlug,
25
- role: input.role,
26
- eventName,
27
- claudeSessionId: stringOrUndefined(input.event.session_id),
28
- transcriptPath: stringOrUndefined(input.event.transcript_path),
29
- cwd: stringOrUndefined(input.event.cwd)
5
+ async function getHookContext(input) {
6
+ if (!isRoleName(input.role)) {
7
+ throw new VcmError({
8
+ code: "HOOK_ROLE_INVALID",
9
+ message: `Unknown hook role: ${input.role}`,
10
+ statusCode: 400
30
11
  });
31
- const config = await deps.projectService.loadConfig(project.repoRoot);
32
- const task = await deps.taskService.loadTask(project.repoRoot, input.taskSlug);
33
- const taskRepoRoot = getTaskRuntimeRepoRoot(task);
34
- const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles({
35
- repoRoot: project.repoRoot,
36
- taskRepoRoot,
37
- stateRepoRoot: taskRepoRoot,
38
- stateRoot: config.stateRoot,
39
- handoffDir: task.handoffDir,
40
- taskSlug: input.taskSlug,
41
- stoppedRole: input.role
12
+ }
13
+ const project = await deps.projectService.getCurrentProject();
14
+ if (!project) {
15
+ throw new VcmError({
16
+ code: "PROJECT_NOT_CONNECTED",
17
+ message: "Connect a repository before accepting Claude Code hooks.",
18
+ statusCode: 409
42
19
  });
43
- return {
44
- ok: true,
45
- eventName,
46
- taskSlug: input.taskSlug,
47
- role: input.role,
48
- sessionUpdated: Boolean(session),
49
- dispatchedCount: dispatched.filter((result) => result.delivered).length
50
- };
51
20
  }
21
+ const config = await deps.projectService.loadConfig(project.repoRoot);
22
+ const task = await deps.taskService.loadTask(project.repoRoot, input.taskSlug);
23
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
24
+ return {
25
+ project,
26
+ config,
27
+ task,
28
+ taskRepoRoot
29
+ };
30
+ }
31
+ async function handleUserPromptSubmitHook(input) {
32
+ const eventName = parseHookEvent(input.event.hook_event_name);
33
+ if (eventName !== "UserPromptSubmit") {
34
+ throwUnsupportedEvent(eventName);
35
+ }
36
+ const context = await getHookContext(input);
37
+ const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
38
+ taskSlug: input.taskSlug,
39
+ role: input.role,
40
+ eventName,
41
+ claudeSessionId: stringOrUndefined(input.event.session_id),
42
+ transcriptPath: stringOrUndefined(input.event.transcript_path),
43
+ cwd: stringOrUndefined(input.event.cwd)
44
+ });
45
+ const submitted = await deps.messageService.confirmPromptSubmitted({
46
+ repoRoot: context.project.repoRoot,
47
+ taskRepoRoot: context.taskRepoRoot,
48
+ stateRepoRoot: context.taskRepoRoot,
49
+ stateRoot: context.config.stateRoot,
50
+ handoffDir: context.task.handoffDir,
51
+ taskSlug: input.taskSlug,
52
+ role: input.role,
53
+ prompt: stringOrUndefined(input.event.prompt)
54
+ });
55
+ return {
56
+ ok: true,
57
+ eventName,
58
+ taskSlug: input.taskSlug,
59
+ role: input.role,
60
+ sessionUpdated: Boolean(session),
61
+ dispatchedCount: 0,
62
+ submittedMessageId: submitted?.id
63
+ };
64
+ }
65
+ async function handleStopHook(input) {
66
+ const eventName = parseHookEvent(input.event.hook_event_name ?? "Stop");
67
+ if (eventName !== "Stop") {
68
+ throwUnsupportedEvent(eventName);
69
+ }
70
+ const context = await getHookContext(input);
71
+ const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
72
+ taskSlug: input.taskSlug,
73
+ role: input.role,
74
+ eventName,
75
+ claudeSessionId: stringOrUndefined(input.event.session_id),
76
+ transcriptPath: stringOrUndefined(input.event.transcript_path),
77
+ cwd: stringOrUndefined(input.event.cwd)
78
+ });
79
+ const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles({
80
+ repoRoot: context.project.repoRoot,
81
+ taskRepoRoot: context.taskRepoRoot,
82
+ stateRepoRoot: context.taskRepoRoot,
83
+ stateRoot: context.config.stateRoot,
84
+ handoffDir: context.task.handoffDir,
85
+ taskSlug: input.taskSlug,
86
+ stoppedRole: input.role
87
+ });
88
+ return {
89
+ ok: true,
90
+ eventName,
91
+ taskSlug: input.taskSlug,
92
+ role: input.role,
93
+ sessionUpdated: Boolean(session),
94
+ dispatchedCount: dispatched.filter((result) => result.delivered).length
95
+ };
96
+ }
97
+ return {
98
+ async handleHook(input) {
99
+ const eventName = parseHookEvent(input.event.hook_event_name);
100
+ if (eventName === "UserPromptSubmit") {
101
+ return handleUserPromptSubmitHook(input);
102
+ }
103
+ return handleStopHook(input);
104
+ },
105
+ handleStopHook
52
106
  };
53
107
  }
54
- function parseStopEvent(value) {
55
- if (value === undefined || value === "Stop") {
56
- return "Stop";
108
+ function parseHookEvent(value) {
109
+ if (value === "UserPromptSubmit" || value === "Stop") {
110
+ return value;
57
111
  }
58
112
  throw new VcmError({
59
113
  code: "HOOK_EVENT_UNSUPPORTED",
60
114
  message: `Unsupported Claude Code hook event: ${String(value)}`,
61
115
  statusCode: 400,
62
- hint: "VCM accepts Stop hooks only."
116
+ hint: "VCM accepts UserPromptSubmit and Stop hooks only."
117
+ });
118
+ }
119
+ function throwUnsupportedEvent(eventName) {
120
+ throw new VcmError({
121
+ code: "HOOK_EVENT_UNSUPPORTED",
122
+ message: `Unsupported Claude Code hook event: ${eventName}`,
123
+ statusCode: 400,
124
+ hint: "Use the matching VCM hook endpoint for this event."
63
125
  });
64
126
  }
65
127
  function stringOrUndefined(value) {
@@ -9,8 +9,8 @@ export const VCM_HARNESS_VERSION = 1;
9
9
  const MANAGED_BLOCK_PATTERN = /<!-- VCM:BEGIN(?:\s+version=(\d+))? -->[\s\S]*?<!-- VCM:END -->/m;
10
10
  const HASH_MANAGED_BLOCK_PATTERN = /# VCM:BEGIN(?:\s+version=(\d+))?\n[\s\S]*?# VCM:END/m;
11
11
  const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
12
- const VCM_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
13
- const VCM_HOOK_EVENTS = ["Stop"];
12
+ const VCM_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
13
+ const VCM_HOOK_EVENTS = ["UserPromptSubmit", "Stop"];
14
14
  const HARNESS_FILES = [
15
15
  {
16
16
  kind: "root-claude",
@@ -227,7 +227,7 @@ async function analyzeClaudeSettingsFile(fs, repoRoot) {
227
227
  path: CLAUDE_SETTINGS_PATH,
228
228
  action,
229
229
  reason: exists
230
- ? "Claude Code hook settings do not contain the VCM Stop hook bridge."
230
+ ? "Claude Code hook settings do not contain the VCM UserPromptSubmit/Stop hook bridge."
231
231
  : "Claude Code hook settings are missing; VCM will create them."
232
232
  },
233
233
  nextContent: action === "ok" ? undefined : nextContent
@@ -294,7 +294,7 @@ function isVcmHookMatcher(value) {
294
294
  if (!isPlainObject(hook)) {
295
295
  return false;
296
296
  }
297
- return typeof hook.command === "string" && hook.command.includes("/api/hooks/claude-code/stop");
297
+ return typeof hook.command === "string" && hook.command.includes("/api/hooks/claude-code");
298
298
  });
299
299
  }
300
300
  function isPlainObject(value) {
@@ -11,6 +11,7 @@ const ROLE_TO_PM_TYPES = new Set(["result", "question", "blocked", "finding"]);
11
11
  const OPEN_MESSAGE_STATUSES = new Set([
12
12
  "pending_approval",
13
13
  "queued",
14
+ "delivering",
14
15
  "submitted",
15
16
  "failed",
16
17
  "delivery_failed",
@@ -55,6 +56,7 @@ export function createMessageService(deps) {
55
56
  const acknowledged = acknowledgeActiveMessages(messages, input.stoppedRole, timestamp);
56
57
  for (const message of acknowledged) {
57
58
  await appendMessageSnapshot(deps.fs, input, message);
59
+ await clearRouteFileIfStillMatchesMessage(deps.fs, input, message);
58
60
  }
59
61
  updates.push(...acknowledged);
60
62
  messages = applyMessageSnapshots(messages, acknowledged);
@@ -140,20 +142,18 @@ export function createMessageService(deps) {
140
142
  clearedRouteFile: false
141
143
  };
142
144
  }
143
- const submitted = {
145
+ const delivering = {
144
146
  ...message,
145
- status: "submitted",
146
- deliveredAt: timestamp,
147
- submittedAt: timestamp
147
+ status: "delivering",
148
+ deliveredAt: timestamp
148
149
  };
149
- await submitTerminalInput(deps.runtime, session.id, renderMessageEnvelope(submitted));
150
+ await submitTerminalInput(deps.runtime, session.id, renderMessageEnvelope(delivering));
150
151
  await deps.sessionService.markRoleActivityRunning(input.repoRoot, input.taskSlug, routeFile.toRole);
151
- await deps.fs.writeText(resolveRepoPath(input.taskRepoRoot ?? input.repoRoot, routeFile.path), "");
152
152
  return {
153
- message: submitted,
153
+ message: delivering,
154
154
  delivered: true,
155
155
  requiresUserApproval: false,
156
- clearedRouteFile: true
156
+ clearedRouteFile: false
157
157
  };
158
158
  }
159
159
  return {
@@ -166,6 +166,30 @@ export function createMessageService(deps) {
166
166
  async scanAndDispatchPendingRouteFiles(input) {
167
167
  return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), () => scanLocked(input));
168
168
  },
169
+ async confirmPromptSubmitted(input) {
170
+ return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
171
+ const timestamp = now();
172
+ const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
173
+ const messageId = extractVcmMessageId(input.prompt);
174
+ if (!messageId) {
175
+ return undefined;
176
+ }
177
+ const message = findDeliveringMessageForPrompt(messages, input.role, messageId);
178
+ if (!message) {
179
+ return undefined;
180
+ }
181
+ const submitted = {
182
+ ...message,
183
+ status: "submitted",
184
+ submittedAt: timestamp,
185
+ queuedBehindMessageId: undefined,
186
+ failureReason: undefined
187
+ };
188
+ await appendMessageSnapshot(deps.fs, input, submitted);
189
+ await clearRouteFileIfStillMatchesMessage(deps.fs, input, submitted);
190
+ return submitted;
191
+ });
192
+ },
169
193
  async markAllDone(input) {
170
194
  return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
171
195
  const timestamp = now();
@@ -384,18 +408,53 @@ function findOpenMessageForRoute(messages, routePath) {
384
408
  }
385
409
  function findActiveMessageForRole(messages, role) {
386
410
  return messages.find((message) => message.toRole === role &&
387
- message.status === "submitted");
411
+ (message.status === "delivering" || message.status === "submitted"));
388
412
  }
389
413
  function acknowledgeActiveMessages(messages, role, timestamp) {
390
414
  return messages
391
- .filter((message) => message.toRole === role && message.status === "submitted")
415
+ .filter((message) => message.toRole === role && (message.status === "delivering" || message.status === "submitted"))
392
416
  .map((message) => ({
393
417
  ...message,
394
418
  status: "acknowledged",
419
+ submittedAt: message.submittedAt ?? message.deliveredAt ?? timestamp,
395
420
  acknowledgedAt: timestamp,
396
421
  failureReason: undefined
397
422
  }));
398
423
  }
424
+ function findDeliveringMessageForPrompt(messages, role, messageId) {
425
+ const delivering = messages.filter((message) => message.toRole === role &&
426
+ message.status === "delivering" &&
427
+ message.id === messageId);
428
+ return delivering.sort((left, right) => getMessageDeliveryTime(left).localeCompare(getMessageDeliveryTime(right))).at(-1);
429
+ }
430
+ function extractVcmMessageId(prompt) {
431
+ return prompt?.match(/^\s*id:\s*(\S+)\s*$/m)?.[1];
432
+ }
433
+ function getMessageDeliveryTime(message) {
434
+ return message.deliveredAt ?? message.createdAt;
435
+ }
436
+ async function clearRouteFileIfStillMatchesMessage(fs, input, message) {
437
+ if (!message.routePath || !isRoleName(String(message.fromRole))) {
438
+ return;
439
+ }
440
+ const absolutePath = resolveRepoPath(input.taskRepoRoot ?? input.repoRoot, message.routePath);
441
+ if (!(await fs.pathExists(absolutePath))) {
442
+ return;
443
+ }
444
+ const routeContent = await fs.readText(absolutePath);
445
+ const parsed = parseRouteFileContent(routeContent, message.fromRole, message.toRole);
446
+ if (parsed.body.trim() === message.body.trim() &&
447
+ parsed.type === message.type &&
448
+ arraysEqual(parsed.artifactRefs, message.artifactRefs)) {
449
+ await fs.writeText(absolutePath, "");
450
+ }
451
+ }
452
+ function arraysEqual(left, right) {
453
+ if (left.length !== right.length) {
454
+ return false;
455
+ }
456
+ return left.every((value, index) => value === right[index]);
457
+ }
399
458
  function applyMessageSnapshots(messages, snapshots) {
400
459
  if (snapshots.length === 0) {
401
460
  return messages;
@@ -96,7 +96,7 @@ function toRoleTurnState(role, sessions) {
96
96
  }
97
97
  function getLatestDeliveredMessage(messages) {
98
98
  return messages
99
- .filter((message) => message.status === "submitted")
99
+ .filter((message) => message.status === "delivering" || message.status === "submitted")
100
100
  .sort((left, right) => getMessageDeliveredAt(left).localeCompare(getMessageDeliveredAt(right)))
101
101
  .at(-1);
102
102
  }
@@ -163,11 +163,13 @@ export function createSessionService(deps) {
163
163
  return undefined;
164
164
  }
165
165
  const timestamp = now();
166
+ const isStop = input.eventName === "Stop";
166
167
  const updated = {
167
168
  ...current,
168
- activityStatus: "idle",
169
+ activityStatus: isStop ? "idle" : "running",
169
170
  lastHookEventAt: timestamp,
170
- lastStopAt: timestamp,
171
+ lastStopAt: isStop ? timestamp : current.lastStopAt,
172
+ lastPromptSubmittedAt: isStop ? current.lastPromptSubmittedAt : timestamp,
171
173
  updatedAt: timestamp
172
174
  };
173
175
  deps.registry.upsert(updated);