engineering-memory 1.8.0 → 1.9.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.
@@ -21,6 +21,8 @@ After compaction, a new chat, interruption, or handoff, call \`session.resume\`
21
21
 
22
22
  Do not edit until the skill lifecycle has completed discovery, its checkpoint, and \`context.prepare_change\`. Do not claim completion until \`task.verify\` succeeds.
23
23
 
24
+ Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. Existing answers remain valid through retries and handoffs.
25
+
24
26
  When the repository is unbound, use the native questionnaire required by the skill. Do not silently create or bind a project. Do not open a survey web page.`;
25
27
 
26
28
  export const dispatcherSections = Object.freeze({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -3,6 +3,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import { assertManagedPath, canonicalPath } from '../utilities/files.js';
4
4
  import { sha256, stableStringify } from '../utilities/hash.js';
5
5
  import { NativeCommandRunner } from '../utilities/process.js';
6
+ import { TaskBranchStore } from '../runtime/task-branch-store.js';
6
7
  export const temporaryScaffoldingMarker = 'ENGINEERING-MEMORY-TEMPORARY';
7
8
  export const temporaryScaffoldingPattern = `${temporaryScaffoldingMarker}:`;
8
9
  export class GitInspector {
@@ -33,6 +34,96 @@ export class GitInspector {
33
34
  }
34
35
  return sha256(identity);
35
36
  }
37
+ branchStore(repoRoot) {
38
+ return new TaskBranchStore(repoRoot, this.runner);
39
+ }
40
+ async validateBranch(repoRoot, name) {
41
+ const result = await this.runner.run('git', ['check-ref-format', '--branch', name], {
42
+ cwd: repoRoot,
43
+ });
44
+ if (name.length > 255 || result.exitCode !== 0 || result.stdout.trim() !== name)
45
+ throw new Error('Choose a valid literal Git branch name of at most 255 characters');
46
+ }
47
+ async createWorktree(repoRoot, name, target) {
48
+ await this.validateBranch(repoRoot, name);
49
+ const path = resolve(repoRoot, target);
50
+ const present = await lstat(path).then(() => true, (error) => {
51
+ if (error.code === 'ENOENT')
52
+ return false;
53
+ throw error;
54
+ });
55
+ if (present) {
56
+ const root = await this.findRoot(path);
57
+ const common = await this.gitValue(root, [
58
+ 'rev-parse',
59
+ '--path-format=absolute',
60
+ '--git-common-dir',
61
+ ]);
62
+ const sourceCommon = await this.gitValue(repoRoot, [
63
+ 'rev-parse',
64
+ '--path-format=absolute',
65
+ '--git-common-dir',
66
+ ]);
67
+ if (root === (await canonicalPath(path)) &&
68
+ common === sourceCommon &&
69
+ (await this.currentBranch(root)) === name)
70
+ return root;
71
+ throw new Error('The worktree directory already belongs to another repository or branch. Choose a different directory');
72
+ }
73
+ const exists = await this.branchExists(repoRoot, name);
74
+ const args = exists
75
+ ? ['worktree', 'add', '--', path, name]
76
+ : ['worktree', 'add', '-b', name, '--', path];
77
+ const result = await this.runner.run('git', args, { cwd: repoRoot });
78
+ if (result.exitCode !== 0)
79
+ throw new Error(`Git could not create the worktree: ${result.stderr.trim()}`);
80
+ return await this.findRoot(path);
81
+ }
82
+ async currentBranch(repoRoot) {
83
+ return await this.gitValue(repoRoot, ['symbolic-ref', '--short', '-q', 'HEAD']);
84
+ }
85
+ async defaultBranch(repoRoot) {
86
+ const ref = await this.gitValue(repoRoot, [
87
+ 'symbolic-ref',
88
+ '--short',
89
+ '-q',
90
+ 'refs/remotes/origin/HEAD',
91
+ ]);
92
+ return ref ? ref.replace(/^origin\//, '') : null;
93
+ }
94
+ async protectedBranchNames(repoRoot) {
95
+ const names = new Set(['main', 'master', 'develop', 'prod']);
96
+ const fallback = await this.defaultBranch(repoRoot);
97
+ if (fallback)
98
+ names.add(fallback);
99
+ return names;
100
+ }
101
+ async createBranch(repoRoot, name) {
102
+ await this.validateBranch(repoRoot, name);
103
+ const result = await this.runner.run('git', ['switch', '-c', name], { cwd: repoRoot });
104
+ if (result.exitCode !== 0) {
105
+ throw new Error(`Git could not create the branch ${name}: ${result.stderr.trim() || 'unknown error'}`);
106
+ }
107
+ const branch = await this.currentBranch(repoRoot);
108
+ if (branch !== name) {
109
+ throw new Error(`Git created ${name} but HEAD is on ${branch ?? 'no branch'}`);
110
+ }
111
+ return branch;
112
+ }
113
+ async branchExists(repoRoot, name) {
114
+ const result = await this.runner.run('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], {
115
+ cwd: repoRoot,
116
+ });
117
+ return result.exitCode === 0;
118
+ }
119
+ async switchBranch(repoRoot, name) {
120
+ await this.validateBranch(repoRoot, name);
121
+ const result = await this.runner.run('git', ['switch', '--', name], { cwd: repoRoot });
122
+ if (result.exitCode !== 0) {
123
+ throw new Error(`Git could not switch to the branch ${name}: ${result.stderr.trim() || 'unknown error'}`);
124
+ }
125
+ return name;
126
+ }
36
127
  async mainWorktree(repoRoot) {
37
128
  const commonDirectory = await this.gitValue(repoRoot, [
38
129
  'rev-parse',
@@ -35,8 +35,24 @@ async function run() {
35
35
  })).data);
36
36
  const result = await gate.verify(repoRoot);
37
37
  process.stderr.write(`${JSON.stringify(result)}\n`);
38
+ const advice = describe(result);
39
+ if (advice)
40
+ process.stderr.write(`${advice}\n`);
38
41
  process.exitCode = result.allowed ? 0 : 1;
39
42
  }
43
+ function describe(result) {
44
+ const [expected, actual] = result.details ?? [];
45
+ switch (result.reason) {
46
+ case 'branch_mismatch':
47
+ return `The task selected ${expected} but HEAD is on ${actual}. Run: git switch ${expected}`;
48
+ case 'branch_detached':
49
+ return `The task selected ${expected} but HEAD is detached. Run: git switch ${expected}`;
50
+ case 'online_commit_gate_rejected':
51
+ return `The backend refused the commit: ${expected ?? 'no reason given'}`;
52
+ default:
53
+ return null;
54
+ }
55
+ }
40
56
  void run().catch(() => {
41
57
  process.stderr.write('Engineering Memory Git gate failed\n');
42
58
  process.exitCode = 1;
@@ -32,6 +32,7 @@ export class VerificationGate {
32
32
  taskClosed: input.taskClosed ?? false,
33
33
  taskVersion: input.taskVersion ?? null,
34
34
  taskChanges,
35
+ branch: input.branch ?? null,
35
36
  }, this.root);
36
37
  }
37
38
  async verify(repoRoot) {
@@ -79,6 +80,16 @@ export class VerificationGate {
79
80
  taskId: receipt.taskId,
80
81
  };
81
82
  }
83
+ const branch = await this.git.currentBranch(repoRoot);
84
+ if (receipt.branch && branch !== receipt.branch) {
85
+ return {
86
+ allowed: false,
87
+ reason: branch ? 'branch_mismatch' : 'branch_detached',
88
+ diffHash: manifest.diffHash,
89
+ taskId: receipt.taskId,
90
+ details: [receipt.branch, branch ?? 'HEAD'],
91
+ };
92
+ }
82
93
  if (receipt.diffHash !== manifest.diffHash) {
83
94
  return {
84
95
  allowed: false,
@@ -118,6 +129,7 @@ export class VerificationGate {
118
129
  repoFingerprint: receipt.repoFingerprint,
119
130
  diffHash: manifest.diffHash,
120
131
  expectedTaskVersion: receipt.taskVersion,
132
+ branch: branch ?? undefined,
121
133
  });
122
134
  const result = asObject(attestation);
123
135
  if (result?.allowed !== true ||
@@ -132,12 +144,13 @@ export class VerificationGate {
132
144
  };
133
145
  }
134
146
  }
135
- catch {
147
+ catch (error) {
136
148
  return {
137
149
  allowed: false,
138
150
  reason: 'online_commit_gate_rejected',
139
151
  diffHash: manifest.diffHash,
140
152
  taskId: receipt.taskId,
153
+ details: [error instanceof Error ? error.message : String(error)],
141
154
  };
142
155
  }
143
156
  return {
@@ -66,6 +66,7 @@ export const engineeringMemoryToolNames = [
66
66
  'task.verify',
67
67
  'task.close',
68
68
  'task.abandon',
69
+ 'task.branch',
69
70
  'architecture.plan',
70
71
  'architecture.module',
71
72
  'architecture.record_application',
@@ -428,6 +429,16 @@ export function registerEngineeringMemoryTools(server, service) {
428
429
  reason: z.string().min(1).max(2000),
429
430
  }),
430
431
  }, async (input) => toolResult(await service.taskAbandon(input)));
432
+ server.registerTool('task.branch', {
433
+ description: 'Record the native questionnaire answer for the exact externalTaskId before any new write/scaffold task or read-only-to-write transition. Choose name or keepCurrent. For another task in a busy checkout supply worktreePath and name; use the returned repoRoot for all subsequent commands. Existing branches may be selected without resetting them. Decisions persist across retries and restarts.',
434
+ inputSchema: z.object({
435
+ repoRoot: optionalRepoRoot,
436
+ externalTaskId: z.string().min(2).max(160),
437
+ worktreePath: z.string().min(1).optional(),
438
+ name: z.string().min(1).max(255).optional(),
439
+ keepCurrent: z.boolean().optional(),
440
+ }),
441
+ }, async (input) => toolResult(await service.taskBranch(input)));
431
442
  server.registerTool('organization.create', {
432
443
  description: 'Create an organization the user named, with the short identifier they typed. Offer this underneath the organizations they already belong to; never invent the identifier from the name.',
433
444
  inputSchema: z.object({
@@ -32,6 +32,7 @@ export const backendRecoveryOperationNames = [
32
32
  'task.verify',
33
33
  'task.close',
34
34
  'task.abandon',
35
+ 'task.branch',
35
36
  'task.resolve_pending_delivery',
36
37
  ];
37
38
  const backendRecoveryOperations = new Set(backendRecoveryOperationNames);
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { minimatch } from 'minimatch';
5
5
  import { endpoints } from '../config.js';
6
+ import { RepositoryResolver } from '../project/repository.js';
6
7
  import { sha256, stableStringify } from '../utilities/hash.js';
7
8
  import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
8
9
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
@@ -90,6 +91,11 @@ export class BridgeService {
90
91
  if (!projectId) {
91
92
  throw refuse('This repository has a marker that names no project, so there is nothing to bootstrap against.', 'project.resolve');
92
93
  }
94
+ const bootstrapMode = persistedBootstrap.mode ?? 'write';
95
+ const decision = bootstrapMode === 'read_only'
96
+ ? null
97
+ : await this.requireBranchDecision(repository, input.externalTaskId);
98
+ const branch = decision?.branch;
93
99
  const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
94
100
  const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
95
101
  method: 'POST',
@@ -106,6 +112,7 @@ export class BridgeService {
106
112
  ...(input.mode === 'read_only'
107
113
  ? { baselineDiffHash: repository.git.worktreeHash }
108
114
  : {}),
115
+ ...(decision ? { branch } : {}),
109
116
  checkpointIdempotencyKey: checkpointId,
110
117
  knownRevisions: persistedBootstrap.knownRevisions,
111
118
  }),
@@ -127,11 +134,20 @@ export class BridgeService {
127
134
  !isIsoTimestamp(checkpointPayload.createdAt)) {
128
135
  throw refuse('The backend opened the session without the checkpoint the task is anchored to.', 'session.bootstrap');
129
136
  }
137
+ if (decision)
138
+ await this.dependencies.repositories.git
139
+ .branchStore(repository.repoRoot)
140
+ .bind(projectId, input.externalTaskId, task.id);
130
141
  const pointer = {
131
142
  repoFingerprint: repository.repoFingerprint,
132
143
  projectId,
133
144
  taskId: task.id,
134
145
  taskSlug: input.externalTaskId,
146
+ worktreeId: sha256(repository.repoRoot),
147
+ ...(decision
148
+ ? { branch: typeof task.branch === 'string' ? task.branch : decision.branch }
149
+ : {}),
150
+ mode: normalizeTaskMode(task.mode),
135
151
  sessionId: session.id,
136
152
  lastSequence: numericSequence(task.lastSequence),
137
153
  taskVersion: numericTaskVersion(task.lockVersion),
@@ -256,6 +272,12 @@ export class BridgeService {
256
272
  backendSession.id !== sessionId) {
257
273
  throw refuse('The backend resumed the session without the task it belongs to.', 'session.resume');
258
274
  }
275
+ await this.assertTaskWorktree(repository, backendTask, pointer);
276
+ if (backendTask.status === 'closed' || backendTask.status === 'abandoned') {
277
+ await this.dependencies.repositories.git
278
+ .branchStore(repository.repoRoot)
279
+ .release(backendTask.id);
280
+ }
259
281
  let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
260
282
  let pendingOutbox = await this.dependencies.outbox.list();
261
283
  const responseSource = this.dependencies.client.getResponseSource(response);
@@ -365,6 +387,13 @@ export class BridgeService {
365
387
  projectId,
366
388
  taskId: backendTask.id,
367
389
  taskSlug,
390
+ worktreeId: pointer?.worktreeId ?? sha256(repository.repoRoot),
391
+ ...(typeof backendTask.branch === 'string'
392
+ ? { branch: backendTask.branch }
393
+ : pointer?.branch !== undefined
394
+ ? { branch: pointer.branch }
395
+ : {}),
396
+ mode: normalizeTaskMode(backendTask.mode),
368
397
  sessionId,
369
398
  lastSequence: resumedLastSequence,
370
399
  taskVersion: resumedTaskVersion,
@@ -443,6 +472,10 @@ export class BridgeService {
443
472
  ...input.changedPaths,
444
473
  ]);
445
474
  const baselineDiffHash = activeBaseline?.diffHash ?? repository.git.diffHash;
475
+ const decision = pointer &&
476
+ (input.transitionToWrite || (pointer.branch !== undefined && pointer.mode !== 'read_only'))
477
+ ? await this.requireBranchDecision(repository, pointer.taskSlug)
478
+ : null;
446
479
  let transitionTaskVersion;
447
480
  if (input.transitionToWrite) {
448
481
  if (!pointer || pointer.sessionId !== input.sessionId) {
@@ -455,6 +488,7 @@ export class BridgeService {
455
488
  sessionId: input.sessionId,
456
489
  changedPaths,
457
490
  baselineDiffHash,
491
+ ...(decision ? { branch: decision.branch } : {}),
458
492
  ...(input.transitionToWrite
459
493
  ? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
460
494
  : {}),
@@ -501,6 +535,17 @@ export class BridgeService {
501
535
  : undefined);
502
536
  const preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
503
537
  if (preparedPointer) {
538
+ if (decision) {
539
+ await this.dependencies.repositories.git
540
+ .branchStore(repository.repoRoot)
541
+ .bind(preparedPointer.projectId, preparedPointer.taskSlug, preparedPointer.taskId);
542
+ await this.dependencies.activeContexts.save({
543
+ ...preparedPointer,
544
+ worktreeId: sha256(repository.repoRoot),
545
+ branch: typeof preparedTask?.branch === 'string' ? preparedTask.branch : decision.branch,
546
+ mode: 'write',
547
+ });
548
+ }
504
549
  const preparedData = objectValue(response.data);
505
550
  await this.seedResumeSnapshot(preparedPointer, {
506
551
  ...objectOrEmpty(response.data),
@@ -1010,6 +1055,7 @@ export class BridgeService {
1010
1055
  });
1011
1056
  }
1012
1057
  const taskMode = normalizeTaskMode(snapshotTask.mode);
1058
+ await this.assertTaskWorktree(repository, snapshotTask, pointer);
1013
1059
  const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
1014
1060
  const validations = normalizePersistentInput(input.validations, repository.repoRoot);
1015
1061
  if (taskMode === 'read_only') {
@@ -1072,6 +1118,11 @@ export class BridgeService {
1072
1118
  changedPaths: taskMode === 'read_only' ? [] : taskChangedPaths,
1073
1119
  pathChanges: pathChangeManifest,
1074
1120
  diffHash: repository.git.diffHash,
1121
+ ...(taskMode !== 'read_only'
1122
+ ? {
1123
+ branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
1124
+ }
1125
+ : {}),
1075
1126
  ...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
1076
1127
  validations,
1077
1128
  pendingOutboxCount,
@@ -1105,6 +1156,9 @@ export class BridgeService {
1105
1156
  repoFingerprint: repository.repoFingerprint,
1106
1157
  repoRoot: repository.repoRoot,
1107
1158
  diffHash: repository.git.diffHash,
1159
+ branch: typeof snapshotTask.branch === 'string'
1160
+ ? snapshotTask.branch
1161
+ : (pointer.branch ?? null),
1108
1162
  taskVersion,
1109
1163
  taskChanges,
1110
1164
  });
@@ -1148,11 +1202,17 @@ export class BridgeService {
1148
1202
  const taskChanges = pointer.changeBaseline
1149
1203
  ? manifestDelta(pointer.changeBaseline.changedPaths, repository.git.changedPaths)
1150
1204
  : [];
1205
+ await this.assertTaskWorktree(repository, task, pointer);
1151
1206
  const closeBody = {
1152
1207
  taskId: input.taskId,
1153
1208
  sessionId: pointer.sessionId,
1154
1209
  expectedTaskVersion: numericTaskVersion(task.lockVersion),
1155
1210
  diffHash: repository.git.diffHash,
1211
+ ...(normalizeTaskMode(task.mode) !== 'read_only'
1212
+ ? {
1213
+ branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
1214
+ }
1215
+ : {}),
1156
1216
  };
1157
1217
  await this.dependencies.activeContexts.setCloseIntent(repository.repoFingerprint, {
1158
1218
  body: closeBody,
@@ -1162,6 +1222,111 @@ export class BridgeService {
1162
1222
  });
1163
1223
  });
1164
1224
  }
1225
+ async taskBranch(input) {
1226
+ return await this.execute(async () => {
1227
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1228
+ const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
1229
+ if (authentication)
1230
+ return asJsonValue({ authentication });
1231
+ if (!repository.projectId)
1232
+ throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
1233
+ if (!input.externalTaskId ||
1234
+ input.externalTaskId.length > 160 ||
1235
+ /[\r\n]/.test(input.externalTaskId))
1236
+ throw refuse('Pass the exact externalTaskId of the task whose branch question was answered.', 'task.branch');
1237
+ if (Boolean(input.name) === (input.keepCurrent === true) ||
1238
+ (input.worktreePath && !input.name))
1239
+ throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
1240
+ assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
1241
+ const git = this.dependencies.repositories.git;
1242
+ let target = repository;
1243
+ try {
1244
+ if (input.name)
1245
+ await git.validateBranch(repository.repoRoot, input.name);
1246
+ if (input.worktreePath && input.name) {
1247
+ const targetRoot = await git.createWorktree(repository.repoRoot, input.name, input.worktreePath);
1248
+ target = await this.dependencies.repositories.resolve(targetRoot);
1249
+ if (target.projectId && target.projectId !== repository.projectId)
1250
+ throw refuse('The target worktree belongs to a different Engineering Memory project. Choose another worktreePath.', 'task.branch');
1251
+ if (!target.markerPath) {
1252
+ await new RepositoryResolver(git, repository.schemaVersion ?? 2).writeMarker(targetRoot, repository.projectId);
1253
+ target = await this.dependencies.repositories.resolve(targetRoot);
1254
+ }
1255
+ }
1256
+ const branch = input.name ?? (await git.currentBranch(target.repoRoot));
1257
+ const store = git.branchStore(target.repoRoot);
1258
+ await store.reserve({
1259
+ projectId: repository.projectId,
1260
+ externalTaskId: input.externalTaskId,
1261
+ branch,
1262
+ });
1263
+ if (input.name && (await git.currentBranch(target.repoRoot)) !== input.name) {
1264
+ try {
1265
+ if (await git.branchExists(target.repoRoot, input.name))
1266
+ await git.switchBranch(target.repoRoot, input.name);
1267
+ else
1268
+ await git.createBranch(target.repoRoot, input.name);
1269
+ }
1270
+ catch (error) {
1271
+ if ((await git.currentBranch(target.repoRoot)) !== input.name) {
1272
+ await store.cancelUnopened(repository.projectId, input.externalTaskId);
1273
+ throw error;
1274
+ }
1275
+ }
1276
+ }
1277
+ return asJsonValue({
1278
+ branch,
1279
+ kept: input.keepCurrent === true,
1280
+ repoRoot: target.repoRoot,
1281
+ externalTaskId: input.externalTaskId,
1282
+ nextAction: 'Use this repoRoot for session.bootstrap and every subsequent command for this task. The decision survives retries and restarts.',
1283
+ });
1284
+ }
1285
+ catch (error) {
1286
+ if (error instanceof BridgeRecoveryError)
1287
+ throw error;
1288
+ throw refuse(`${error instanceof Error ? error.message : String(error)} Retry task.branch for the selected task; use worktreePath to leave a busy or dirty checkout intact.`, 'task.branch');
1289
+ }
1290
+ });
1291
+ }
1292
+ async requireBranchDecision(repository, externalTaskId) {
1293
+ const git = this.dependencies.repositories.git;
1294
+ const store = git.branchStore(repository.repoRoot);
1295
+ let record = await store.read();
1296
+ if (!record) {
1297
+ const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
1298
+ if (pointer?.worktreeId === sha256(repository.repoRoot) &&
1299
+ pointer.mode !== 'read_only' &&
1300
+ pointer.branch !== undefined) {
1301
+ await store.reserve({
1302
+ projectId: pointer.projectId,
1303
+ externalTaskId,
1304
+ branch: pointer.branch,
1305
+ taskId: pointer.taskId,
1306
+ });
1307
+ record = await store.read();
1308
+ }
1309
+ }
1310
+ if (!record)
1311
+ throw refuse(`Ask the branch question through the native questionnaire for task ${externalTaskId}, then call task.branch with that externalTaskId and a name or keepCurrent. For parallel work choose a separate worktreePath.`, 'task.branch');
1312
+ if (record.decision.projectId !== repository.projectId ||
1313
+ record.decision.externalTaskId !== externalTaskId)
1314
+ throw refuse(`This worktree belongs to task ${record.decision.externalTaskId}. Use task.branch with worktreePath for ${externalTaskId}, then bootstrap in the returned directory.`, 'task.branch');
1315
+ const branch = await git.currentBranch(repository.repoRoot);
1316
+ if (branch !== record.decision.branch)
1317
+ throw refuse(`Task ${externalTaskId} selected ${record.decision.branch ?? 'detached HEAD'}, but this worktree is on ${branch ?? 'detached HEAD'}. Return to the selected branch with task.branch.`, 'task.branch');
1318
+ return record.decision;
1319
+ }
1320
+ async assertTaskWorktree(repository, task, pointer) {
1321
+ if (normalizeTaskMode(task.mode) === 'read_only')
1322
+ return;
1323
+ if (pointer?.worktreeId && pointer.worktreeId !== sha256(repository.repoRoot))
1324
+ throw refuse('This task belongs to a different worktree. Resume and run its commands in the worktree selected for this task.', 'session.resume');
1325
+ const recorded = typeof task.branch === 'string' ? task.branch : pointer?.branch;
1326
+ const current = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
1327
+ if (recorded && current !== recorded)
1328
+ throw refuse(`This task selected ${recorded}. Call task.branch with externalTaskId ${pointer?.taskSlug ?? String(task.externalTaskId)} and name ${recorded} before continuing.`, 'task.branch');
1329
+ }
1165
1330
  async taskAbandon(input) {
1166
1331
  return await this.execute(async () => {
1167
1332
  return await this.taskExclusive(input.taskId, async () => {
@@ -1174,6 +1339,9 @@ export class BridgeService {
1174
1339
  if (!abandoned || abandoned.abandoned !== true) {
1175
1340
  throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
1176
1341
  }
1342
+ await this.dependencies.repositories.git
1343
+ .branchStore(repository.repoRoot)
1344
+ .release(input.taskId);
1177
1345
  const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
1178
1346
  await this.dependencies.gate.invalidateTask(input.taskId);
1179
1347
  return asJsonValue({
@@ -2072,6 +2240,7 @@ export class BridgeService {
2072
2240
  repoFingerprint: pointer.repoFingerprint,
2073
2241
  repoRoot: repository.repoRoot,
2074
2242
  diffHash: repository.git.diffHash,
2243
+ branch: typeof backendTask?.branch === 'string' ? backendTask.branch : (pointer.branch ?? null),
2075
2244
  taskVersion,
2076
2245
  taskChanges: intent.taskChanges,
2077
2246
  });
@@ -2100,6 +2269,9 @@ export class BridgeService {
2100
2269
  }
2101
2270
  async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
2102
2271
  const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
2272
+ const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
2273
+ if (typeof body.branch === 'string' && body.branch !== currentBranch)
2274
+ throw refuse(`Return to the branch ${body.branch} recorded in this close request before retrying.`, 'task.branch');
2103
2275
  let response;
2104
2276
  try {
2105
2277
  response = await this.dependencies.client.request(endpoints.taskClose, {
@@ -2125,11 +2297,19 @@ export class BridgeService {
2125
2297
  repoFingerprint: repository.repoFingerprint,
2126
2298
  repoRoot: repository.repoRoot,
2127
2299
  diffHash: String(body.diffHash),
2300
+ branch: typeof closedTask.branch === 'string'
2301
+ ? closedTask.branch
2302
+ : typeof body.branch === 'string'
2303
+ ? body.branch
2304
+ : null,
2128
2305
  taskClosed: true,
2129
2306
  taskVersion: closedTaskVersion,
2130
2307
  taskChanges,
2131
2308
  });
2132
2309
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
2310
+ await this.dependencies.repositories.git
2311
+ .branchStore(repository.repoRoot)
2312
+ .release(String(body.taskId));
2133
2313
  return asJsonValue({
2134
2314
  ...closedTask,
2135
2315
  repository: publicRepository(repository),
@@ -0,0 +1,111 @@
1
+ import { NativeCommandRunner } from '../utilities/process.js';
2
+ import { assertSafeToPersist } from './offline-outbox.js';
3
+ import { BridgeRecoveryError } from './recovery-error.js';
4
+ const decisionRef = 'refs/worktree/engineering-memory-task';
5
+ export class TaskBranchStore {
6
+ repoRoot;
7
+ runner;
8
+ constructor(repoRoot, runner = new NativeCommandRunner()) {
9
+ this.repoRoot = repoRoot;
10
+ this.runner = runner;
11
+ }
12
+ async read() {
13
+ const reference = await this.runner.run('git', ['rev-parse', '--verify', '--quiet', decisionRef], { cwd: this.repoRoot });
14
+ if (reference.exitCode === 1)
15
+ return null;
16
+ if (reference.exitCode !== 0)
17
+ throw new BridgeRecoveryError('Git could not read the worktree task reservation. Retry the branch operation.', 'task.branch');
18
+ const oid = reference.stdout.trim();
19
+ const blob = await this.runner.run('git', ['cat-file', 'blob', oid], { cwd: this.repoRoot });
20
+ if (blob.exitCode !== 0)
21
+ throw new BridgeRecoveryError('Git could not read the recorded branch decision. Retry the branch operation.', 'task.branch');
22
+ let decision;
23
+ try {
24
+ decision = JSON.parse(blob.stdout);
25
+ }
26
+ catch {
27
+ throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
28
+ }
29
+ if (!decision)
30
+ throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
31
+ if (typeof decision.projectId !== 'string' ||
32
+ typeof decision.externalTaskId !== 'string' ||
33
+ !(decision.branch === null || typeof decision.branch === 'string')) {
34
+ throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
35
+ }
36
+ return { oid, decision };
37
+ }
38
+ async reserve(decision) {
39
+ assertSafeToPersist({ ...decision });
40
+ const existing = await this.read();
41
+ if (existing) {
42
+ this.assertSame(existing.decision, decision);
43
+ return;
44
+ }
45
+ const oid = await this.hash(decision);
46
+ const written = await this.runner.run('git', ['update-ref', '--no-deref', decisionRef, oid, '0'.repeat(oid.length)], { cwd: this.repoRoot });
47
+ if (written.exitCode !== 0) {
48
+ const winner = await this.read();
49
+ if (winner) {
50
+ this.assertSame(winner.decision, decision);
51
+ return;
52
+ }
53
+ throw new BridgeRecoveryError('Another branch operation is in progress. Retry the same task.branch operation.', 'task.branch');
54
+ }
55
+ }
56
+ async bind(projectId, externalTaskId, taskId) {
57
+ const current = await this.read();
58
+ if (!current ||
59
+ current.decision.projectId !== projectId ||
60
+ current.decision.externalTaskId !== externalTaskId)
61
+ throw new BridgeRecoveryError('The worktree reservation changed while opening the task. Resume it before editing.', 'session.resume');
62
+ if (current.decision.taskId === taskId)
63
+ return;
64
+ const oid = await this.hash({ ...current.decision, taskId });
65
+ const result = await this.runner.run('git', ['update-ref', '--no-deref', decisionRef, oid, current.oid], { cwd: this.repoRoot });
66
+ if (result.exitCode !== 0) {
67
+ const latest = await this.read();
68
+ if (latest?.decision.taskId !== taskId)
69
+ throw new BridgeRecoveryError('The worktree reservation changed while opening the task. Resume it before editing.', 'session.resume');
70
+ }
71
+ }
72
+ async cancelUnopened(projectId, externalTaskId) {
73
+ const current = await this.read();
74
+ if (!current ||
75
+ current.decision.taskId ||
76
+ current.decision.projectId !== projectId ||
77
+ current.decision.externalTaskId !== externalTaskId)
78
+ return;
79
+ const result = await this.runner.run('git', ['update-ref', '--no-deref', '-d', decisionRef, current.oid], { cwd: this.repoRoot });
80
+ if (result.exitCode !== 0 && (await this.read())?.oid === current.oid)
81
+ throw new BridgeRecoveryError('The failed branch operation could not release its reservation. Retry task.branch with the same task identifier.', 'task.branch');
82
+ }
83
+ async release(taskId) {
84
+ const current = await this.read();
85
+ if (!current || current.decision.taskId !== taskId)
86
+ return;
87
+ const result = await this.runner.run('git', ['update-ref', '--no-deref', '-d', decisionRef, current.oid], { cwd: this.repoRoot });
88
+ if (result.exitCode !== 0 && (await this.read())?.decision.taskId === taskId) {
89
+ throw new BridgeRecoveryError('The task has finished but its worktree reservation could not be released. Resume the task to retry cleanup.', 'session.resume');
90
+ }
91
+ }
92
+ assertSame(current, requested) {
93
+ if (current.projectId !== requested.projectId ||
94
+ current.externalTaskId !== requested.externalTaskId) {
95
+ throw new BridgeRecoveryError(`This worktree belongs to task ${current.externalTaskId}. Resume that task, or call task.branch with worktreePath and a branch name for the new task.`, 'task.branch');
96
+ }
97
+ if (current.branch !== requested.branch) {
98
+ throw new BridgeRecoveryError(`This task selected ${current.branch ?? 'detached HEAD'}. Return to that branch before continuing.`, 'task.branch');
99
+ }
100
+ }
101
+ async hash(decision) {
102
+ const result = await this.runner.run('git', ['hash-object', '-w', '--stdin'], {
103
+ cwd: this.repoRoot,
104
+ input: JSON.stringify(decision),
105
+ });
106
+ if (result.exitCode !== 0)
107
+ throw new BridgeRecoveryError('Git could not save the branch decision. Retry the same branch operation.', 'task.branch');
108
+ return result.stdout.trim();
109
+ }
110
+ }
111
+ //# sourceMappingURL=task-branch-store.js.map
package/skill/SKILL.md CHANGED
@@ -11,6 +11,8 @@ Read [lifecycle.md](references/lifecycle.md) before acting in a bound repository
11
11
 
12
12
  Mandatory behavior:
13
13
 
14
+ Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. Existing answers remain valid through retries and handoffs.
15
+
14
16
  1. Locate `.engineering-memory/project.json` from the working directory toward the repository root. When it exists, the project's knowledge is in the backend and not in the working tree, so answer nothing about the project before bootstrapping — a file search that finds no design link, no screen record and no rule is reporting what the repository lacks, not what the project knows.
15
17
  2. Call `session.entry` before answering anything in a repository, and act on what it reports before the message itself: sign in when it says so, ask for organization and project when nothing has been decided, and stay completely silent about Engineering Memory in a repository where the user switched it off. Record every one of those answers with `session.set_decision`, and only ever from something the user actually said.
16
18
  3. Before planning or editing, call `session.bootstrap`. After compaction, a new chat, interruption, or handoff, call `session.resume` first.
@@ -10,9 +10,11 @@ An archived project is recoverable state, not a missing or conflicting binding.
10
10
 
11
11
  For a bound repository, call `session.bootstrap` before producing a plan or changing files. Supply the current repository root, project ID, task ID or stable local task slug, objective, task kind, task mode, and current Git diff hash. Use `read_only` for review, diagnosis, planning, or reporting without write authority; use `scaffold` when the task applies organization architecture templates to a new project; use `write` when the request authorizes repository changes. The bridge records the read-only Git diff hash as the immutable task baseline, including a pre-existing dirty worktree. Use the returned task ID, task version, context session ID, pinned revisions, project profile, engineering rules, prior task documents, quality gates, and current deviations.
12
12
 
13
- Before the first edit of a write task, settle the branch. Ask the user through the native questionnaire whether to open a branch for this task and which name to use, offering the convention the returned rules carry. Do this once, at the start, not at commit time — the commit gate runs long after the work is written, and by then the wrong branch has already cost something. A read-only task never creates a branch.
13
+ Every new write or scaffold task settles its branch through the native questionnaire before it opens, including when HEAD is already on a feature branch. Offer a task branch or an explicit choice to stay on the current branch. Call `task.branch` with the exact `externalTaskId` and either `name` or `keepCurrent`, then call `session.bootstrap` in its returned `repoRoot`. An existing branch name checks it out. Creating a branch remains optional; recording the user's choice does not. A read-only task needs no branch decision, but `context.prepare_change` with `transitionToWrite` requires one before editing.
14
14
 
15
- Two tasks in one repository need two branches, and one working tree can only have one checked out. A second checkout is what `git worktree` is for: `git worktree add ../<repo>-<task> -b <branch>` gives the task its own directory and its own branch against the same repository, and the chat for that task runs there. Engineering Memory treats them as one project — the fingerprint comes from the repository, not the directory — while each task measures only what changed in its own tree. Offer this when a task starts in a repository that already has a live task, and never ask somebody to switch branches in a tree another task is using.
15
+ The choice belongs to that task and worktree, survives retry and client restart, and cannot be consumed by a different task. Reuse an existing answer rather than asking again. A named branch stays fixed through prepare, verify, close and commit; closing never replaces it. Return with `task.branch` using the same task slug and recorded branch when a gate reports a mismatch. Legacy tasks without a recorded branch and tasks explicitly kept on detached HEAD remain compatible; they are not restricted to a named branch. Older clients remain supported during the minimum-version compatibility window.
16
+
17
+ Parallel development needs separate working directories. If another task owns the current worktree, offer a separate worktree through the questionnaire. Call `task.branch` with `externalTaskId`, `name`, and `worktreePath`; run bootstrap and all subsequent file, test and Git commands in its returned `repoRoot`. This creates or reuses an appropriate Git worktree and preserves the repository's project marker schema. Never switch another task's working directory. Reservations are shared by clients using the same worktree; close or abandon releases that task's reservation. An interrupted close can release it through resume.
16
18
 
17
19
  A repository holds as many tasks as the people working in it. Never treat somebody else's unfinished task as a reason this one cannot proceed: no task waits on another task's review, reconciliation, verification or close, and nothing that is already verified or closed is undone by what happens elsewhere. When `session.resume` reports more than one live task for this repository, it lists them and the right move is to ask the user which one this is, never to guess and never to adopt the one that happens to be most recent.
18
20
 
@@ -1,6 +1,6 @@
1
1
  # Native Questionnaires
2
2
 
3
- Use Codex or Claude native question controls. Do not open a custom survey web page. Web pages are limited to sign in, sign up, initial password change, and email verification.
3
+ Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. Existing answers remain valid through retries and handoffs. Web pages are limited to sign in, sign up, initial password change, and email verification.
4
4
 
5
5
  ## Authentication
6
6
 
@@ -62,7 +62,7 @@ Ask for the registered email and intended role. Show owner, maintainer, member,
62
62
 
63
63
  ## Branch
64
64
 
65
- At the start of a write task, before the first edit, ask whether to open a branch for it and confirm the name. Offer the convention the returned engineering rules state, the current branch as the alternative, and let the user name something else. Never create a branch during read-only analysis, and never create one without asking. When the repository already has another live task, add a third option — a separate worktree for this one — and say what it means: its own directory, its own branch, the same project, and the other task's tree left alone.
65
+ Ask once for every new write or scaffold task, on any branch, and before a read-only task transitions to writing. Offer a new or existing task branch, or explicitly continuing on the current branch; use the naming convention from the returned rules. When another task owns the worktree, offer a separate directory and branch for parallel work. Pass the exact `externalTaskId` and either `name` or `keepCurrent` to `task.branch`; for a separate worktree also supply `worktreePath`. Continue in the returned `repoRoot`. Retries and handoffs reuse the stored choice. Read-only analysis does not create a branch or consume another task's answer.
66
66
 
67
67
  ## Flow Entry and Exit
68
68
 
@@ -1,29 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { readFile } from 'node:fs/promises';
3
- import test from 'node:test';
4
-
5
- const entryPoint = new URL('./engineering-memory.mjs', import.meta.url);
6
- const manifest = new URL('../package.json', import.meta.url);
7
-
8
- test('the published entry point tells the installer which version it is', async () => {
9
- const source = await readFile(entryPoint, 'utf8');
10
-
11
- assert.match(
12
- source,
13
- /'--client-version',\s*await publishedVersion\(\)/,
14
- 'The installer is never told the version, so every install reports itself as unknown and no update is ever offered',
15
- );
16
- });
17
-
18
- test('the published entry point tells the installer which backend it was built for', async () => {
19
- const source = await readFile(entryPoint, 'utf8');
20
-
21
- assert.match(source, /'--api-url',\s*await publishedApiUrl\(\)/);
22
- });
23
-
24
- test('the manifest carries a version the entry point can stamp', async () => {
25
- const declared = JSON.parse(await readFile(manifest, 'utf8'));
26
-
27
- assert.match(String(declared.version), /^\d+\.\d+\.\d+$/);
28
- assert.equal(declared.name, 'engineering-memory');
29
- });