engineering-memory 0.2.2 → 0.2.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.
@@ -107,8 +107,29 @@ export async function installEngineeringMemory(options = {}) {
107
107
  dispatcherSection: dispatcherSections.claude,
108
108
  },
109
109
  };
110
+ const mcpPlans = await planMcpRegistrations({
111
+ selectedClients,
112
+ bridgeEntry,
113
+ nodePath,
114
+ apiUrl,
115
+ clientVersion,
116
+ state: existingState,
117
+ commandRunner,
118
+ clientsWereRequested: options.selectedClients !== undefined,
119
+ });
120
+ const absentClients = mcpPlans
121
+ .filter((plan) => plan.action === 'absent')
122
+ .map((plan) => plan.clientName);
123
+ const installedClients = selectedClients.filter(
124
+ (clientName) => !absentClients.includes(clientName),
125
+ );
126
+ if (installedClients.length === 0) {
127
+ throw new Error(
128
+ `None of the supported agent CLIs are on PATH: ${selectedClients.join(', ')}. Install one, then rerun the installer.`,
129
+ );
130
+ }
110
131
  const clientPlans = await Promise.all(
111
- selectedClients.map(async (clientName) => {
132
+ installedClients.map(async (clientName) => {
112
133
  const target = clientTargets[clientName];
113
134
  return {
114
135
  clientName,
@@ -120,15 +141,6 @@ export async function installEngineeringMemory(options = {}) {
120
141
  };
121
142
  }),
122
143
  );
123
- const mcpPlans = await planMcpRegistrations({
124
- selectedClients,
125
- bridgeEntry,
126
- nodePath,
127
- apiUrl,
128
- clientVersion,
129
- state: existingState,
130
- commandRunner,
131
- });
132
144
  const hookPlan = await planGitHook({
133
145
  repoRoot: options.repoRoot ? resolve(options.repoRoot) : null,
134
146
  mode: options.hookMode ?? 'cancel',
@@ -189,7 +201,8 @@ export async function installEngineeringMemory(options = {}) {
189
201
  statePath,
190
202
  skillPaths: clientPlans.map((plan) => plan.skillPath),
191
203
  dispatcherPaths: clientPlans.map((plan) => plan.dispatcherPath),
192
- selectedClients,
204
+ selectedClients: installedClients,
205
+ absentClients,
193
206
  apiUrl,
194
207
  clientVersion,
195
208
  runtimePath,
@@ -48,6 +48,7 @@ export async function planMcpRegistrations({
48
48
  clientVersion,
49
49
  state,
50
50
  commandRunner,
51
+ clientsWereRequested = true,
51
52
  }) {
52
53
  const plans = [];
53
54
  for (const clientName of selectedClients) {
@@ -55,6 +56,10 @@ export async function planMcpRegistrations({
55
56
  if (!client) throw new Error(`Unsupported client: ${clientName}`);
56
57
  const version = await commandRunner(client.executable, ['--version']);
57
58
  if (version.code !== 0) {
59
+ if (!clientsWereRequested && !state?.mcp?.[clientName]) {
60
+ plans.push({ clientName, client, action: 'absent' });
61
+ continue;
62
+ }
58
63
  throw new Error(
59
64
  `${clientName} CLI is unavailable. Install its native CLI, ensure it is on PATH, then rerun the installer. ${commandFailure(client.executable, version)}`,
60
65
  );
@@ -121,6 +126,7 @@ export async function planMcpRegistrations({
121
126
  export async function applyMcpRegistrations(plans, commandRunner, transaction) {
122
127
  const result = {};
123
128
  for (const plan of plans) {
129
+ if (plan.action === 'absent') continue;
124
130
  if (plan.action === 'add') {
125
131
  await addRegistration(plan, plan.registration, commandRunner);
126
132
  transaction.add(() => removeRegistration(plan, commandRunner));
@@ -211,6 +217,19 @@ function registrationFingerprint(clientName, registration) {
211
217
  .digest('hex');
212
218
  }
213
219
 
220
+ function fingerprintBeforeClientVersion(clientName, registration) {
221
+ return createHash('sha256')
222
+ .update(
223
+ JSON.stringify({
224
+ clientName,
225
+ nodePath: registration.nodePath,
226
+ bridgeEntry: registration.bridgeEntry,
227
+ apiUrl: registration.apiUrl ?? null,
228
+ }),
229
+ )
230
+ .digest('hex');
231
+ }
232
+
214
233
  function sameRegistration(left, right) {
215
234
  return (
216
235
  left?.nodePath === right.nodePath &&
@@ -462,9 +481,13 @@ function isOwnedRegistration(clientName, managed) {
462
481
  typeof managed.bridgeEntry === 'string'
463
482
  );
464
483
  }
484
+ if (typeof managed.apiUrl !== 'string') return false;
485
+ if (managed.fingerprint === registrationFingerprint(clientName, managed)) {
486
+ return true;
487
+ }
465
488
  return (
466
- typeof managed.apiUrl === 'string' &&
467
- managed.fingerprint === registrationFingerprint(clientName, managed)
489
+ managed.clientVersion === undefined &&
490
+ managed.fingerprint === fingerprintBeforeClientVersion(clientName, managed)
468
491
  );
469
492
  }
470
493
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
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
+ export const temporaryScaffoldingMarker = 'ENGINEERING-MEMORY-TEMPORARY';
6
7
  export class GitInspector {
7
8
  runner;
8
9
  constructor(runner = new NativeCommandRunner()) {
@@ -273,6 +274,42 @@ export class GitInspector {
273
274
  .map((line) => line.trim())
274
275
  .filter(Boolean);
275
276
  }
277
+ async temporaryScaffolding(repoRoot) {
278
+ const root = await this.findRoot(repoRoot);
279
+ const [worktree, staged] = await Promise.all([
280
+ this.markerMatches(root, ['--untracked'], 'worktree'),
281
+ this.markerMatches(root, ['--cached'], 'staged'),
282
+ ]);
283
+ const found = new Map();
284
+ for (const match of [...worktree, ...staged]) {
285
+ const identity = `${match.path}:${match.line}`;
286
+ const existing = found.get(identity);
287
+ found.set(identity, existing ? { ...existing, staged: existing.staged || match.staged } : match);
288
+ }
289
+ return [...found.values()].sort((left, right) => left.path === right.path ? left.line - right.line : left.path.localeCompare(right.path));
290
+ }
291
+ async markerMatches(root, scope, origin) {
292
+ const result = await this.runner.run('git', ['grep', '--no-color', '-n', '-I', '-F', ...scope, '-e', temporaryScaffoldingMarker], { cwd: root });
293
+ if (result.exitCode !== 0)
294
+ return [];
295
+ return result.stdout
296
+ .split(/\r?\n/)
297
+ .map((line) => line.trim())
298
+ .filter(Boolean)
299
+ .flatMap((line) => {
300
+ const match = /^(.+?):(\d+):(.*)$/.exec(line);
301
+ if (!match)
302
+ return [];
303
+ return [
304
+ {
305
+ path: normalizeGitPath(match[1]),
306
+ line: Number(match[2]),
307
+ text: match[3].trim().slice(0, 200),
308
+ staged: origin === 'staged',
309
+ },
310
+ ];
311
+ });
312
+ }
276
313
  async gitValue(repoRoot, args) {
277
314
  const result = await this.runner.run('git', args, { cwd: repoRoot });
278
315
  const value = result.stdout.trim().split(/\r?\n/)[0];
@@ -35,15 +35,25 @@ export class VerificationGate {
35
35
  }, this.root);
36
36
  }
37
37
  async verify(repoRoot) {
38
- const [fingerprint, manifest, stagedChanges, pending] = await Promise.all([
38
+ const [fingerprint, manifest, stagedChanges, pending, scaffolding] = await Promise.all([
39
39
  this.git.fingerprint(repoRoot),
40
40
  this.git.manifest(repoRoot),
41
41
  this.git.stagedManifest(repoRoot),
42
42
  this.outbox.list(),
43
+ this.git.temporaryScaffolding(repoRoot),
43
44
  ]);
44
45
  if (pending.length > 0) {
45
46
  return { allowed: false, reason: 'offline_outbox_pending', diffHash: manifest.diffHash };
46
47
  }
48
+ const staged = scaffolding.filter((match) => match.staged);
49
+ if (staged.length > 0) {
50
+ return {
51
+ allowed: false,
52
+ reason: 'temporary_scaffolding_staged',
53
+ diffHash: manifest.diffHash,
54
+ details: staged.map((match) => `${match.path}:${match.line}`),
55
+ };
56
+ }
47
57
  const receipt = await readJson(this.pathFor(fingerprint), this.root);
48
58
  if (!receipt) {
49
59
  return { allowed: false, reason: 'task_verify_required', diffHash: manifest.diffHash };
@@ -14,6 +14,7 @@ import { BridgeService } from './runtime/bridge-service.js';
14
14
  import { OfflineOutbox } from './runtime/offline-outbox.js';
15
15
  import { ActiveContextStore } from './runtime/active-context-store.js';
16
16
  import { RepositoryDecisionStore } from './runtime/repository-decision-store.js';
17
+ import { ShadowNoticeStore } from './runtime/shadow-notice-store.js';
17
18
  import { UpdateChoiceStore } from './runtime/update-choice-store.js';
18
19
  import { PrincipalStateGuard } from './runtime/principal-state.js';
19
20
  export function createBridgeService() {
@@ -43,6 +44,7 @@ export function createBridgeService() {
43
44
  const activeContexts = new ActiveContextStore(stateRoot);
44
45
  const repositoryDecisions = new RepositoryDecisionStore(stateRoot);
45
46
  const updateChoices = new UpdateChoiceStore(stateRoot);
47
+ const shadowNotices = new ShadowNoticeStore(stateRoot);
46
48
  const gate = new VerificationGate(stateRoot, git, outbox);
47
49
  const principalState = new PrincipalStateGuard(stateRoot, credentials, cache, outbox, activeContexts, gate);
48
50
  return new BridgeService({
@@ -56,6 +58,7 @@ export function createBridgeService() {
56
58
  activeContexts,
57
59
  repositoryDecisions,
58
60
  updateChoices,
61
+ shadowNotices,
59
62
  clientVersion: config.clientVersion,
60
63
  principalState,
61
64
  });
@@ -47,6 +47,7 @@ export const engineeringMemoryToolNames = [
47
47
  'session.entry',
48
48
  'session.set_decision',
49
49
  'session.decline_update',
50
+ 'session.answer_shadow_notice',
50
51
  'session.bootstrap',
51
52
  'session.resume',
52
53
  'context.prepare_change',
@@ -76,6 +77,13 @@ export const engineeringMemoryToolNames = [
76
77
  'auth.signin_browser',
77
78
  'auth.logout',
78
79
  ];
80
+ const reconciliationEntry = z.object({
81
+ resourceId: z.string().min(1),
82
+ type: z.enum(['approved_revision', 'no_semantic_memory_change']),
83
+ proposalId: z.string().optional(),
84
+ revisionId: z.string().optional(),
85
+ reason: z.string().optional(),
86
+ });
79
87
  export function registerEngineeringMemoryTools(server, service) {
80
88
  server.registerTool('session.entry', {
81
89
  description: 'The first call of every session in a repository, before answering anything about the project. Reports whether the user is signed in, whether this repository is bound, what this user decided about it last time, and the one thing to do now. A repository the user switched Engineering Memory off in reports that, and is left alone.',
@@ -92,6 +100,16 @@ export function registerEngineeringMemoryTools(server, service) {
92
100
  description: 'Record that the user turned down the available client update. Call it only when they say no. The same version is never offered again; a newer one is a new question. A required update is not declinable and must not be recorded here.',
93
101
  inputSchema: z.object({}),
94
102
  }, async () => toolResult(await service.sessionDeclineUpdate()));
103
+ server.registerTool('session.answer_shadow_notice', {
104
+ description: 'Record that the user has answered a shadowed rule notice: the record in force overrides a wider rule that has since changed, and they chose whether to read the new text or keep the override. Call it only after they answer. The same revision of that wider rule is never raised again; a later revision is a new question.',
105
+ inputSchema: z.object({
106
+ resourceId: z.string().describe('The wider rule that is being hidden.'),
107
+ revisionNumber: z
108
+ .number()
109
+ .int()
110
+ .describe('The revision of that rule the user was told about.'),
111
+ }),
112
+ }, async (input) => toolResult(await service.sessionAnswerShadowNotice(input)));
95
113
  server.registerTool('session.bootstrap', {
96
114
  description: 'Authenticate, resolve the repository project, open or resume a write or read-only task, and load mandatory engineering context before planning.',
97
115
  inputSchema: z.object({
@@ -247,30 +265,52 @@ export function registerEngineeringMemoryTools(server, service) {
247
265
  }),
248
266
  }, async (input) => toolResult(await service.taskSelfReview(input)));
249
267
  server.registerTool('task.reconcile', {
250
- description: 'Reconcile each changed screen or component with an approved revision or an explicit no-semantic-memory-change reason.',
268
+ description: 'Reconcile changed screens and components with an approved revision or an explicit no-semantic-memory-change reason. Pass every record the task touched as `entries` in one call rather than calling once per record; the whole set is applied together and rejected together.',
251
269
  inputSchema: z
252
270
  .object({
253
271
  repoRoot: optionalRepoRoot,
254
272
  taskId: z.string().min(1),
255
- resourceId: z.string().min(1),
256
- type: z.enum(['approved_revision', 'no_semantic_memory_change']),
273
+ entries: z.array(reconciliationEntry).min(1).max(50).optional(),
274
+ resourceId: z.string().min(1).optional(),
275
+ type: z.enum(['approved_revision', 'no_semantic_memory_change']).optional(),
257
276
  proposalId: z.string().optional(),
258
277
  revisionId: z.string().optional(),
259
278
  reason: z.string().optional(),
260
279
  })
261
280
  .superRefine((value, context) => {
262
- if (value.type === 'approved_revision' && (!value.proposalId || !value.revisionId)) {
281
+ const entries = value.entries ??
282
+ (value.resourceId && value.type
283
+ ? [
284
+ {
285
+ resourceId: value.resourceId,
286
+ type: value.type,
287
+ proposalId: value.proposalId,
288
+ revisionId: value.revisionId,
289
+ reason: value.reason,
290
+ },
291
+ ]
292
+ : null);
293
+ if (!entries) {
263
294
  context.addIssue({
264
295
  code: 'custom',
265
- message: 'Approved reconciliation requires proposalId and revisionId',
296
+ message: 'Reconciliation requires entries, or a single resourceId and type',
266
297
  });
298
+ return;
267
299
  }
268
- if (value.type === 'no_semantic_memory_change' &&
269
- (!value.reason || value.reason.length < 8)) {
270
- context.addIssue({
271
- code: 'custom',
272
- message: 'No-semantic-change reconciliation requires a reason of at least 8 characters',
273
- });
300
+ for (const entry of entries) {
301
+ if (entry.type === 'approved_revision' && (!entry.proposalId || !entry.revisionId)) {
302
+ context.addIssue({
303
+ code: 'custom',
304
+ message: 'Approved reconciliation requires proposalId and revisionId',
305
+ });
306
+ }
307
+ if (entry.type === 'no_semantic_memory_change' &&
308
+ (!entry.reason || entry.reason.length < 8)) {
309
+ context.addIssue({
310
+ code: 'custom',
311
+ message: 'No-semantic-change reconciliation requires a reason of at least 8 characters',
312
+ });
313
+ }
274
314
  }
275
315
  }),
276
316
  }, async (input) => toolResult(await service.taskReconcile(input)));
@@ -439,6 +439,7 @@ export class BridgeService {
439
439
  ...(preparedData?.lease ? { activeLease: preparedData.lease } : {}),
440
440
  });
441
441
  }
442
+ const shadowed = await this.unansweredShadowNotices(response.data);
442
443
  return asJsonValue({
443
444
  ...objectOrEmpty(response.data),
444
445
  editLeaseGranted: true,
@@ -446,9 +447,42 @@ export class BridgeService {
446
447
  strictVerifyBlocked: false,
447
448
  backendSource: responseSource,
448
449
  repository: publicRepository(repository),
450
+ shadowedRuleNotices: shadowed,
451
+ ...(shadowed.length > 0
452
+ ? {
453
+ nextAction: 'A record this change relies on overrides a wider rule that has changed since the override was written. Say so in this reply, name the wider rule, and ask whether to read its new text or keep the override. Record the answer with session.answer_shadow_notice so it is not raised again until that rule moves.',
454
+ }
455
+ : {}),
449
456
  });
450
457
  });
451
458
  }
459
+ async sessionAnswerShadowNotice(input) {
460
+ return await this.execute(async () => {
461
+ await this.dependencies.shadowNotices.answer(input.resourceId, input.revisionNumber);
462
+ return asJsonValue({ resourceId: input.resourceId, answered: true });
463
+ });
464
+ }
465
+ async unansweredShadowNotices(data) {
466
+ const shadowed = objectValue(objectValue(data)?.shadowed);
467
+ if (!shadowed)
468
+ return [];
469
+ const notices = Object.values(shadowed)
470
+ .flatMap((entry) => (Array.isArray(entry) ? entry : []))
471
+ .flatMap((entry) => {
472
+ const hidden = objectValue(entry);
473
+ const resourceId = hidden?.resourceId;
474
+ const resourceKey = hidden?.resourceKey;
475
+ const revisionNumber = hidden?.currentRevisionNumber;
476
+ return typeof resourceId === 'string' &&
477
+ typeof resourceKey === 'string' &&
478
+ typeof revisionNumber === 'number'
479
+ ? [{ resourceId, resourceKey, revisionNumber }]
480
+ : [];
481
+ });
482
+ const unanswered = await this.dependencies.shadowNotices.unanswered(notices);
483
+ const wanted = new Set(unanswered.map((notice) => notice.resourceId));
484
+ return notices.filter((notice) => wanted.has(notice.resourceId));
485
+ }
452
486
  async contextRefresh(input) {
453
487
  return await this.execute(async () => {
454
488
  const response = await this.dependencies.client.request(endpoints.contextRefresh, {
@@ -627,29 +661,31 @@ export class BridgeService {
627
661
  }
628
662
  async taskReconcile(input) {
629
663
  return await this.execute(async () => {
630
- const reason = normalizePersistentInput(input.reason, input.repoRoot ?? process.cwd());
631
- assertSafeToPersist(cleanJson({
632
- resourceId: input.resourceId,
633
- type: input.type,
634
- proposalId: input.proposalId,
635
- revisionId: input.revisionId,
636
- reason,
664
+ const repoRoot = input.repoRoot ?? process.cwd();
665
+ const entries = (input.entries ?? [
666
+ {
667
+ resourceId: input.resourceId,
668
+ type: input.type,
669
+ proposalId: input.proposalId,
670
+ revisionId: input.revisionId,
671
+ reason: input.reason,
672
+ },
673
+ ]).map((entry) => ({
674
+ ...entry,
675
+ reason: normalizePersistentInput(entry.reason, repoRoot),
637
676
  }));
677
+ assertSafeToPersist(cleanJson({ entries }));
638
678
  const snapshot = await this.activeTaskSnapshot(input.taskId, undefined, input.repoRoot);
639
679
  const response = await this.dependencies.client.request(endpoints.taskReconcile, {
640
680
  method: 'POST',
641
681
  body: cleanJson({
642
682
  taskId: input.taskId,
643
- resourceId: input.resourceId,
644
- type: input.type,
645
- proposalId: input.proposalId,
646
- revisionId: input.revisionId,
647
- reason,
683
+ entries,
648
684
  expectedTaskVersion: snapshot.taskVersion,
649
685
  }),
650
686
  });
651
687
  await this.dependencies.gate.invalidateTask(input.taskId);
652
- return asJsonValue({ reconciliation: response.data });
688
+ return asJsonValue({ reconciliations: response.data });
653
689
  });
654
690
  }
655
691
  async taskResolvePendingDelivery(input) {
@@ -784,6 +820,20 @@ export class BridgeService {
784
820
  diffHash: repository.git.diffHash,
785
821
  });
786
822
  }
823
+ const scaffolding = await this.dependencies.repositories.git.temporaryScaffolding(repository.repoRoot);
824
+ if (scaffolding.length > 0) {
825
+ return asJsonValue({
826
+ verified: false,
827
+ reason: 'temporary_scaffolding_present',
828
+ temporaryScaffolding: scaffolding.map((match) => ({
829
+ location: `${match.path}:${match.line}`,
830
+ text: match.text,
831
+ staged: match.staged,
832
+ })),
833
+ nextAction: 'Temporary code written to reach or force a path is still in the working tree. Remove every listed line and restore what it replaced, then verify again. A manipulation that ships is a defect, so this is checked in the tree rather than taken on trust.',
834
+ diffHash: repository.git.diffHash,
835
+ });
836
+ }
787
837
  let taskChangedPaths = [];
788
838
  let taskChanges = [];
789
839
  const snapshotResponse = await this.dependencies.client.request(endpoints.sessionResume, {
@@ -0,0 +1,26 @@
1
+ import { join } from 'node:path';
2
+ import { readJson, writeJson } from '../utilities/files.js';
3
+ export class ShadowNoticeStore {
4
+ path;
5
+ root;
6
+ constructor(stateRoot) {
7
+ this.root = join(stateRoot, 'client');
8
+ this.path = join(this.root, 'shadow-notices.json');
9
+ }
10
+ async unanswered(pending) {
11
+ const record = await readJson(this.path, this.root);
12
+ const answered = record?.answered ?? {};
13
+ return pending.filter((notice) => (answered[notice.resourceId] ?? -1) < notice.revisionNumber);
14
+ }
15
+ async answer(resourceId, revisionNumber) {
16
+ const existing = await readJson(this.path, this.root);
17
+ const record = {
18
+ schemaVersion: 1,
19
+ answered: { ...(existing?.answered ?? {}), [resourceId]: revisionNumber },
20
+ updatedAt: new Date().toISOString(),
21
+ };
22
+ await writeJson(this.path, record, this.root);
23
+ return record;
24
+ }
25
+ }
26
+ //# sourceMappingURL=shadow-notice-store.js.map
@@ -33,8 +33,22 @@ A request can be as short as "add the KYC flow from Figma". That is enough, and
33
33
 
34
34
  Then propose the `flow_logic` record and stop. The plan is not a message in the chat; it is the proposal, and the user approving it is the approval. Do not write the second screen before that approval exists — a flow's shape replicated across six screens costs six times as much to undo, and verification refuses a task that adds several screens without an approved flow record reconciled to it.
35
35
 
36
+ ### A task that reports a defect
37
+
38
+ Settle first whether the report is about what the user sees or where they go. If it is, the
39
+ design carries the intended behaviour and the code carries the actual one, so read both
40
+ before naming a cause: open the frame the report starts from, follow its prototype link to
41
+ the destination the design intends, and state the divergence. Never repoint navigation at a
42
+ target inferred only from surrounding code. Carry both node ids into the discovery
43
+ checkpoint as evidence. A defect in a calculation, a response shape or a service has no
44
+ answer in the design; do not spend the read there.
45
+
36
46
  Do not pull history for everything. Pull it for the records the task actually touches, and for anything the header shows a surprising number of corrections on. Record `task.checkpoint` with type `discovery` and update STATE, DECISIONS, DISCOVERY, and HANDOFF projections through the bridge.
37
47
 
48
+ Send calls that do not feed each other in one batch rather than one at a time: reads of any kind, and proposals for different records. Reconcile every record the task touched in a single `task.reconcile` call with `entries`, not one call per record. Keep in order only what moves the task version, and what genuinely waits on another answer.
49
+
50
+ Temporary code written to reach or force a path — a pinned state, a fixed service response, a jump straight to the screen — is allowed and expected, carries the marker `ENGINEERING-MEMORY-TEMPORARY` with its reason, and is removed before verification. `task.verify` refuses while any marker is in the tree and names every line, and the commit gate refuses while one is staged.
51
+
38
52
  Do not write task Markdown files directly. The bridge owns event IDs, expected task versions, atomic projections, outbox state, and synchronization.
39
53
 
40
54
  ## Change Preparation
@@ -70,9 +70,28 @@ When the user names a different Figma file or link, treat it as a correction to
70
70
 
71
71
  ## Running the Application
72
72
 
73
- When the change is something a person sees or interacts with and this environment can run the application, ask before launching it rather than starting on your own. A run occupies the user's machine, can take minutes, and they may be using it. Say what you would exercise and what you are looking for, so the answer is about the work rather than about the tooling.
73
+ When the change is something a person sees or interacts with and this environment can run the application, ask before launching it rather than starting on your own. A run occupies the user's machine and their time, and the answer should be theirs.
74
74
 
75
- Ask again nothing when they decline. Verify what you can without it and state in the handoff that the flow was not exercised in a running application.
75
+ Ask with the price attached. Say what would be exercised, what is being looked for, and roughly how long it takes, and carry a recommendation rather than leaving the choice bare. For a change confined to one screen with no service on the path, recommend against running and say what will be verified instead — a five-minute change should not cost an hour of driving the application. For a flow that spans several screens, or anything touching a service, recommend running, because that is exactly what reading cannot answer.
76
+
77
+ When they decline, do not raise it again for this task. Verify what you can without it and state in the handoff that the flow was not exercised in a running application.
78
+
79
+ ## Design Reading
80
+
81
+ Before proposing a flow record or naming a cause from the design, put the reading in front of
82
+ the user: the frames found, their node ids, and the order the prototype links imply. Ask
83
+ whether that is the flow and where it is entered from. A prototype link is evidence, not a
84
+ decision, and the design cannot say which entry point the product intends. Never skip
85
+ straight from what the design showed to a proposal.
86
+
87
+ ## Shadowed Product Rule
88
+
89
+ When `context.prepare_change` returns `shadowedRuleNotices`, a record this change relies on
90
+ overrides a wider rule that has changed since the override was written. Say so in the same
91
+ reply, name the wider rule, and ask whether to read its new text or keep the override. The
92
+ override still wins if that is the answer — it just stops being silent. Record the answer
93
+ with `session.answer_shadow_notice`, which keeps the same revision from being raised again;
94
+ a later revision of that rule is a new question.
76
95
 
77
96
  ## Correction Scope
78
97