@skyf0xx/hedgehog 6.2.2 → 6.2.4

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
@@ -12,6 +12,7 @@ English | [简体中文](README.zh-CN.md)
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
13
13
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/skyf0xx/hedgehog/codeql.yml?branch=master&label=CodeQL)](https://github.com/skyf0xx/hedgehog/actions/workflows/codeql.yml)
14
14
  [![Check](https://img.shields.io/github/actions/workflow/status/skyf0xx/hedgehog/check.yml?branch=master&label=Check)](https://github.com/skyf0xx/hedgehog/actions/workflows/check.yml)
15
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14399/badge)](https://www.bestpractices.dev/projects/14399)
15
16
 
16
17
  AI can write code in seconds.
17
18
 
package/README.zh-CN.md CHANGED
@@ -12,6 +12,7 @@
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
13
13
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/skyf0xx/hedgehog/codeql.yml?branch=master&label=CodeQL)](https://github.com/skyf0xx/hedgehog/actions/workflows/codeql.yml)
14
14
  [![Check](https://img.shields.io/github/actions/workflow/status/skyf0xx/hedgehog/check.yml?branch=master&label=Check)](https://github.com/skyf0xx/hedgehog/actions/workflows/check.yml)
15
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14399/badge)](https://www.bestpractices.dev/projects/14399)
15
16
 
16
17
  AI 能在几秒钟内写出代码。
17
18
 
package/bin/cli.mjs CHANGED
@@ -78,17 +78,13 @@ import {
78
78
  createWorktree,
79
79
  hedgehogWorktrees,
80
80
  branchName,
81
- worktreePath,
82
81
  intentReadyToMerge,
83
- intentTaskStatuses,
84
82
  mergeBranch,
85
83
  removeWorktree,
86
84
  intentFileCommitted,
87
85
  onHedgehogBranch,
88
- loadAbandoned,
89
86
  writeAbandonedFile,
90
87
  applyAbandonment,
91
- replayAbandonments,
92
88
  ABANDONED_DIR,
93
89
  } from '../src/db/worktree.mjs';
94
90
  import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
@@ -126,7 +122,6 @@ const BLOCKED_REASON_LABELS = {
126
122
  const __dirname = dirname(fileURLToPath(import.meta.url));
127
123
  const PKG_ROOT = resolve(__dirname, '..');
128
124
  const DEST_ROOT = process.cwd();
129
- const DEFAULT_CORE = 'full-stack-app';
130
125
 
131
126
  // The version of the payload this CLI carries — what `init` and
132
127
  // `update` stamp into the project they write to.
@@ -3342,7 +3337,7 @@ async function reconcileCommand(args) {
3342
3337
  if (sub === 'list') {
3343
3338
  const reconciliations = await loadReconciliations();
3344
3339
  const db = openDb({ readOnly: true });
3345
- let orphaned = [];
3340
+ let orphaned;
3346
3341
  try {
3347
3342
  orphaned = orphanedReconciliations(db, reconciliations);
3348
3343
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.2.2",
3
+ "version": "6.2.4",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -14,7 +14,8 @@
14
14
  "hedgehog": "bin/cli.mjs"
15
15
  },
16
16
  "scripts": {
17
- "check": "node scripts/check.mjs",
17
+ "lint": "eslint .",
18
+ "check": "npm run lint && node scripts/check.mjs",
18
19
  "repro": "node --experimental-sqlite repro/run-all.mjs",
19
20
  "release": "node scripts/bump-package-version.mjs",
20
21
  "release:plugin": "node scripts/bump-plugin-version.mjs"
@@ -43,5 +44,9 @@
43
44
  "scaffold",
44
45
  "hedgehog"
45
46
  ],
46
- "license": "MIT"
47
+ "license": "MIT",
48
+ "devDependencies": {
49
+ "@eslint/js": "^10.0.1",
50
+ "eslint": "^10.9.1"
51
+ }
47
52
  }
package/src/db/drift.mjs CHANGED
@@ -32,7 +32,6 @@ import {
32
32
  CORE_MODULE,
33
33
  loadIntentDependencies,
34
34
  } from './plan.mjs';
35
- import { loadOverrides } from './overrides.mjs';
36
35
 
37
36
  // Statuses a field rewrite is safe on: nothing has been built, leased,
38
37
  // committed, or failed against these rows yet, so moving their ALLOWED
package/src/db/gate.mjs CHANGED
@@ -97,7 +97,7 @@ export async function commitGateStatus(projectRoot) {
97
97
  };
98
98
  }
99
99
 
100
- let script = '';
100
+ let script;
101
101
  try {
102
102
  script = await readFile(hook, 'utf8');
103
103
  } catch {
package/src/db/notes.mjs CHANGED
@@ -79,7 +79,7 @@ export async function loadNotes(notesDir = NOTES_DIR) {
79
79
  try {
80
80
  parsed = JSON.parse(await readFile(path, 'utf8'));
81
81
  } catch (err) {
82
- throw new Error(`could not read notes file ${path}: ${err.message}`);
82
+ throw new Error(`could not read notes file ${path}: ${err.message}`, { cause: err });
83
83
  }
84
84
  const { task, notes } = validateNotesFile(parsed, path);
85
85
  byTask.set(task, notes);
@@ -122,7 +122,7 @@ export async function loadOverrides(overridesDir = OVERRIDES_DIR) {
122
122
  try {
123
123
  parsed = JSON.parse(await readFile(path, 'utf8'));
124
124
  } catch (err) {
125
- throw new Error(`could not read override file ${path}: ${err.message}`);
125
+ throw new Error(`could not read override file ${path}: ${err.message}`, { cause: err });
126
126
  }
127
127
  const record = validateOverride(parsed, path);
128
128
  if (!byTask.has(record.task)) byTask.set(record.task, []);
package/src/db/plan.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  // graph holds. Without it such a layer compiles once per intent and
20
20
  // every copy but the first is a replay of the same command.
21
21
 
22
- import { composeScope, loadOverrides } from './overrides.mjs';
22
+ import { composeScope } from './overrides.mjs';
23
23
 
24
24
  export function fillModule(template, module) {
25
25
  return template.replaceAll('{module}', module);
@@ -535,7 +535,7 @@ export function planTasks(db, core, overrides = new Map(), { excludeIntentIds =
535
535
  const compiledOnceTaskIds = [];
536
536
  // Once-tasks that a newly compiled intent hangs a prerequisite on.
537
537
  const onceTasksGainingPrereqs = [];
538
- let reopenedOnceTaskIds = [];
538
+ let reopenedOnceTaskIds;
539
539
 
540
540
  db.exec('BEGIN IMMEDIATE');
541
541
  try {
@@ -198,12 +198,12 @@ async function replayIntents(db, intentsDir) {
198
198
  try {
199
199
  parsed = JSON.parse(await readFile(path, 'utf8'));
200
200
  } catch (err) {
201
- throw new Error(`could not read intent file ${path}: ${err.message}`);
201
+ throw new Error(`could not read intent file ${path}: ${err.message}`, { cause: err });
202
202
  }
203
203
  try {
204
204
  records.push({ file, intent: normalizeIntent(parsed) });
205
205
  } catch (err) {
206
- throw new Error(`invalid intent file ${path}: ${err.message}`);
206
+ throw new Error(`invalid intent file ${path}: ${err.message}`, { cause: err });
207
207
  }
208
208
  }
209
209
 
@@ -218,26 +218,32 @@ async function replayIntents(db, intentsDir) {
218
218
  return count;
219
219
  }
220
220
 
221
- // Every commit subject in history mapped to its position, newest first
222
- // one `git log` call rather than one per task. Membership alone answers
223
- // "did this task ever run"; position also answers "did it run *after*
224
- // the thing it depends on", which is what a `once: true` task needs,
225
- // since its commit subject is a constant that one historical occurrence
226
- // would otherwise satisfy forever. `--topo-order` so the position is a
221
+ // Every commit subject in history mapped to EVERY position it occurs at,
222
+ // newest first — one `git log` call rather than one per task. Membership
223
+ // alone answers "did this task ever run"; position also answers "did it
224
+ // run *after* the thing it depends on", which is what a `once: true`
225
+ // task needs, since its commit subject is a constant that one historical
226
+ // occurrence would otherwise satisfy forever. Keeping every occurrence
227
+ // (not just the newest) is what lets markCompletedTasks tell two tasks
228
+ // that share a constant commit_message apart on a linear-chain core: one
229
+ // real commit per intent that actually ran the layer, each position
230
+ // claimable by at most one task. `--topo-order` so position is a
227
231
  // property of the history's shape rather than of commit timestamps.
228
232
  function loadCommitSubjects() {
229
233
  const output = execSync('git log --topo-order --format=%H%x00%s', { encoding: 'utf8' });
230
- const newestPosition = new Map();
234
+ const positions = new Map();
231
235
  let position = 0;
232
236
  for (const line of output.split('\n')) {
233
237
  if (!line) continue;
234
238
  const [, subject] = line.split('\0');
235
239
  if (subject === undefined) continue;
236
- // Newest first, so the first occurrence seen is the most recent one.
237
- if (!newestPosition.has(subject)) newestPosition.set(subject, position);
240
+ // Newest first, so pushing in read order keeps each subject's array
241
+ // newest-to-oldest.
242
+ if (!positions.has(subject)) positions.set(subject, []);
243
+ positions.get(subject).push(position);
238
244
  position++;
239
245
  }
240
- return newestPosition;
246
+ return positions;
241
247
  }
242
248
 
243
249
  // A task is complete iff some commit's subject exactly matches its
@@ -246,25 +252,65 @@ function loadCommitSubjects() {
246
252
  // there's no verify_command to re-run and no working tree diff to check,
247
253
  // only the historical fact that the commit already happened.
248
254
  //
249
- // A `once: true` task carries two extra conditions: every prerequisite
250
- // must be complete, and its own commit must be *newer* than all of them.
251
- // Its prerequisite set is the only one that grows after the task has
252
- // already run — `planner`'s Re-entry pass adds a new intent whose work a
253
- // tail once-layer then depends on (plan.mjs reopens it for exactly that
254
- // reason). Its commit subject carries no {module}, so it is a constant:
255
- // the single `chore(infra): deploy` from the first run would otherwise
256
- // make the layer look done forever, re-closing it here on the next fresh
257
- // clone and quietly undoing the reopen. Requiring it to sit above its
258
- // prerequisites in history is what encodes "the deploy ran *after* that
259
- // module landed". Walked to a fixpoint, since a once-layer may sit
260
- // behind another one.
255
+ // A task's `commit_message` is unique to it exactly when the layer's
256
+ // `commit` template interpolates `{module}` the module-axis case,
257
+ // where each intent's copy of the layer produces a distinct string. A
258
+ // `once: true` layer has no module to substitute (core.mjs's
259
+ // validateCore rejects one that names {module}), so its single task's
260
+ // commit subject is a constant by construction and, since a core
261
+ // compiles at most one once-task per once-layer, that task is the only
262
+ // one carrying its subject. A linear-chain core (authored, adopted) has
263
+ // no module axis at all: every layer's `commit` is a fixed string in
264
+ // core.yaml with no `{module}` token, so every intent that walks the
265
+ // chain compiles a per-layer task carrying that same constant one task
266
+ // per intent, all sharing one subject.
261
267
  //
262
- // This condition is deliberately not applied to per-module tasks. A task
263
- // that completed without touching any file leaves no commit at all
264
- // (verifyTask writes none), and cascading that gap through the whole
265
- // chain would reset already-built modules. Scoping it to once-layers
266
- // keeps the change to cores that use the feature, and errs toward
267
- // re-running an idempotent infrastructure step rather than skipping it.
268
+ // A commit_message shared by more than one task (grouped below into
269
+ // `ambiguousTasks`) means membership in commitSubjects can't tell those
270
+ // tasks apart: every commit ever made with that subject is a candidate
271
+ // match for every one of them. Two conditions resolve it:
272
+ //
273
+ // 1. Ordering a task's own matching commit must sit *above* (be
274
+ // newer than) every one of its own prerequisites' matching
275
+ // commits, the same way a once-task has always required its commit
276
+ // to postdate the module it deploys. Without this, the first
277
+ // intent's commit for a layer would satisfy every later intent's
278
+ // task of that same layer forever, re-closing the layer on a fresh
279
+ // clone regardless of what that later intent's own chain has
280
+ // actually done.
281
+ // 2. Consumption — a group of N tasks sharing a subject can credit at
282
+ // most as many of them complete as there are actual commits with
283
+ // that subject in history, each commit backing at most one task.
284
+ // Ordering alone doesn't catch a head-of-chain task: with no
285
+ // prerequisite of its own, "ran after its prerequisites" is
286
+ // vacuously true regardless of which commit it points at, which is
287
+ // exactly the shape of the bug — a fresh intent's first layer,
288
+ // sharing a constant commit_message with an already-built intent's
289
+ // completed first layer, has nothing to check position against.
290
+ // Consumption is what a head-of-chain task actually needs: once
291
+ // every real commit for that subject is claimed by other tasks,
292
+ // none is left for it to point at.
293
+ //
294
+ // Both are walked together, per group, in a single deterministic pass
295
+ // ordered by task id: earlier-sorted tasks get first claim on the
296
+ // oldest still-unclaimed matching commit that satisfies the ordering
297
+ // condition against whatever their own prerequisites already claimed.
298
+ // A task that finds no claimable commit is left incomplete — the safe
299
+ // direction, matching every other cross-cutting-layer default in this
300
+ // engine (re-running an idempotent step beats silently skipping one).
301
+ // The whole thing is walked to a fixpoint, since one ambiguous task's
302
+ // claim can be the prerequisite another ambiguous task needs before it
303
+ // can claim its own (a once-layer behind another once-layer, or one
304
+ // linear-chain layer behind the one before it in the same intent).
305
+ //
306
+ // A task with a commit_message unique to it (the ordinary module-axis
307
+ // case) skips all of this: it is marked complete directly from
308
+ // commitSubjects membership, with no ordering or consumption check.
309
+ // That's necessary, not just cheaper — a task that completed without
310
+ // touching any file leaves no commit at all (verifyTask writes none),
311
+ // and applying either check there would cascade that gap through the
312
+ // whole chain and reset already-built modules that have no ambiguity to
313
+ // resolve in the first place.
268
314
  function markCompletedTasks(db, commitSubjects) {
269
315
  const tasks = db.prepare('SELECT id, module, commit_message FROM tasks').all();
270
316
  const prerequisites = new Map(tasks.map((t) => [t.id, []]));
@@ -272,39 +318,64 @@ function markCompletedTasks(db, commitSubjects) {
272
318
  prerequisites.get(d.task_id)?.push(d.depends_on_task_id);
273
319
  }
274
320
 
275
- // Position of each task's most recent matching commit; undefined means
276
- // the task never committed.
277
- const positionOf = new Map(
278
- tasks.map((t) => [t.id, commitSubjects.get(t.commit_message)]),
279
- );
321
+ const messageCounts = new Map();
322
+ for (const task of tasks) {
323
+ messageCounts.set(task.commit_message, (messageCounts.get(task.commit_message) ?? 0) + 1);
324
+ }
325
+ const isAmbiguous = (task) => messageCounts.get(task.commit_message) > 1;
280
326
 
327
+ // Position of each unambiguous task's one matching commit; undefined
328
+ // means the task never committed. An ambiguous task's position is
329
+ // resolved separately below, since "the" matching commit for its
330
+ // subject isn't decided until a claim succeeds.
331
+ const positionOf = new Map();
281
332
  const complete = new Set();
282
- const onceTasks = [];
333
+ const ambiguousTasks = [];
283
334
  for (const task of tasks) {
284
- if (task.module === CORE_MODULE) {
285
- onceTasks.push(task);
335
+ if (task.module === CORE_MODULE || isAmbiguous(task)) {
336
+ ambiguousTasks.push(task);
286
337
  continue;
287
338
  }
288
- if (positionOf.get(task.id) !== undefined) complete.add(task.id);
339
+ const position = (commitSubjects.get(task.commit_message) ?? [])[0];
340
+ positionOf.set(task.id, position);
341
+ if (position !== undefined) complete.add(task.id);
289
342
  }
343
+ // Deterministic claim order within a shared subject: sorted by task id.
344
+ ambiguousTasks.sort((a, b) => a.id.localeCompare(b.id));
345
+
346
+ // Every commit position still unclaimed, per subject — shrinks as
347
+ // ambiguous tasks below claim one each.
348
+ const available = new Map(
349
+ [...commitSubjects].map(([subject, positions]) => [subject, [...positions]]),
350
+ );
290
351
 
291
- // Lower position is newer, so a once-task ran after a prerequisite when
292
- // its own position is strictly smaller.
293
- const ranAfterPrerequisites = (task) => {
294
- const own = positionOf.get(task.id);
295
- return prerequisites.get(task.id).every((id) => {
352
+ // A task ran after a prerequisite when its own claimed position is
353
+ // strictly smaller (newer) than the prerequisite's.
354
+ const ranAfterPrerequisites = (task, position) =>
355
+ prerequisites.get(task.id).every((id) => {
296
356
  const prereq = positionOf.get(id);
297
- return prereq === undefined || own < prereq;
357
+ return prereq === undefined || position < prereq;
298
358
  });
299
- };
300
359
 
301
360
  let changed = true;
302
361
  while (changed) {
303
362
  changed = false;
304
- for (const task of onceTasks) {
305
- if (complete.has(task.id) || positionOf.get(task.id) === undefined) continue;
363
+ for (const task of ambiguousTasks) {
364
+ if (complete.has(task.id) || positionOf.has(task.id)) continue;
306
365
  if (!prerequisites.get(task.id).every((id) => complete.has(id))) continue;
307
- if (!ranAfterPrerequisites(task)) continue;
366
+
367
+ const slots = available.get(task.commit_message) ?? [];
368
+ // Oldest-first, so a task claims the least-recent commit that
369
+ // still satisfies its ordering condition — leaving newer slots
370
+ // free for whichever task in the group depends on this one.
371
+ const slotIndex = [...slots]
372
+ .map((position, i) => [position, i])
373
+ .sort((a, b) => b[0] - a[0])
374
+ .find(([position]) => ranAfterPrerequisites(task, position))?.[1];
375
+ if (slotIndex === undefined) continue;
376
+
377
+ const [position] = slots.splice(slotIndex, 1);
378
+ positionOf.set(task.id, position);
308
379
  complete.add(task.id);
309
380
  changed = true;
310
381
  }
@@ -313,13 +384,14 @@ function markCompletedTasks(db, commitSubjects) {
313
384
  const setComplete = db.prepare("UPDATE tasks SET status = 'complete' WHERE id = ?");
314
385
  for (const id of complete) setComplete.run(id);
315
386
 
316
- // A once-task can be marked complete by the loop above and then fail
317
- // one of the extra conditions on a later pass of the fixpoint walk.
318
- // Reconcile it back rather than leaving the stale status untouched.
387
+ // A task on the ambiguous path can be marked complete by the loop
388
+ // above and then fail to claim a slot on a later pass of the fixpoint
389
+ // walk. Reconcile it back rather than leaving the stale status
390
+ // untouched.
319
391
  const reopen = db.prepare(
320
392
  "UPDATE tasks SET status = 'planned' WHERE id = ? AND status = 'complete'",
321
393
  );
322
- for (const task of onceTasks) {
394
+ for (const task of ambiguousTasks) {
323
395
  if (!complete.has(task.id)) reopen.run(task.id);
324
396
  }
325
397
 
@@ -148,7 +148,7 @@ export async function loadReconciliations(reconciledDir = RECONCILED_DIR) {
148
148
  try {
149
149
  parsed = JSON.parse(await readFile(path, 'utf8'));
150
150
  } catch (err) {
151
- throw new Error(`could not read reconciliation file ${path}: ${err.message}`);
151
+ throw new Error(`could not read reconciliation file ${path}: ${err.message}`, { cause: err });
152
152
  }
153
153
  const record = validateReconciled(parsed, path);
154
154
  byTask.set(record.task, record);
package/src/db/verify.mjs CHANGED
@@ -562,6 +562,7 @@ export function verifyTask(db, taskId, owner) {
562
562
  `the cause (a rejecting git hook, commit signing, or git identity), then ` +
563
563
  `re-run \`hedgehog verify ${task.id} --owner ${owner}\`, or hand it back ` +
564
564
  `with \`hedgehog release ${task.id} --owner ${owner}\`.\n\n${err.message}`,
565
+ { cause: err },
565
566
  );
566
567
  }
567
568
 
@@ -376,7 +376,7 @@ export async function loadAbandoned(abandonedDir = ABANDONED_DIR) {
376
376
  try {
377
377
  parsed = JSON.parse(await readFile(path, 'utf8'));
378
378
  } catch (err) {
379
- throw new Error(`could not read abandonment file ${path}: ${err.message}`);
379
+ throw new Error(`could not read abandonment file ${path}: ${err.message}`, { cause: err });
380
380
  }
381
381
  const record = validateAbandoned(parsed, path);
382
382
  byIntent.set(record.intent, record);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.2.2",
3
+ "version": "6.2.4",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -122,14 +122,14 @@ async function packAndExtract(entry) {
122
122
  tarball = join(tmp, JSON.parse(stdout)[0].filename);
123
123
  } catch (err) {
124
124
  await rm(tmp, { recursive: true, force: true });
125
- throw new Error(unresolvableMessage(entry, spec, err));
125
+ throw new Error(unresolvableMessage(entry, spec, err), { cause: err });
126
126
  }
127
127
 
128
128
  try {
129
129
  await execFileAsync('tar', ['-xzf', tarball, '-C', tmp]);
130
130
  } catch (err) {
131
131
  await rm(tmp, { recursive: true, force: true });
132
- throw new Error(`Could not extract ${entry.package} from ${tarball}: ${err.message}`);
132
+ throw new Error(`Could not extract ${entry.package} from ${tarball}: ${err.message}`, { cause: err });
133
133
  }
134
134
 
135
135
  const root = join(tmp, TARBALL_ROOT);
@@ -29,22 +29,31 @@ with a real cost, taken on stated conditions — not the safe default.
29
29
  ## The three exits
30
30
 
31
31
  Decide by the conditions, in order. The first one that holds is the exit.
32
+ Re-plan is checked first, and its check is a read to make, not a
33
+ suspicion to already hold — do not reach Change-work or Tweak without
34
+ having made it.
32
35
 
33
36
  ### Re-plan
34
37
 
35
- The locked planning artifact that governs this project no longer
36
- describes what is being asked for. The core's own loop skill names which
37
- artifact governs the brief and layer sequence for a shipped core,
38
- `.hedgehog/core-design.md` for an authored one, `.hedgehog/adoption.md`
39
- for an adopted one.
38
+ The core's own loop skill names which artifact governs this project
39
+ the brief and layer sequence for a shipped core, `.hedgehog/core-design.md`
40
+ for an authored one, `.hedgehog/adoption.md` for an adopted one. Read
41
+ that artifact, then check:
40
42
 
41
- Route to `planner`'s re-entry pass, which adds intents for new work
42
- without re-running planning from scratch and without disturbing anything
43
- already built.
43
+ - Does anything the request needs contradict a statement locked there —
44
+ an interface, a stack choice, a scope boundary?
44
45
 
45
- Where the artifact's failure means the request is a different project
46
- rather than an extension of this one, say so plainly instead of routing.
47
- That artifact is never rewritten to accommodate new scope.
46
+ If it holds, this is Re-plan regardless of how many layers the request
47
+ reaches or whether every file it touches already exists. Route to
48
+ `planner`'s re-entry pass, which adds intents for new work without
49
+ re-running planning from scratch and without disturbing anything already
50
+ built.
51
+
52
+ Where the contradiction means the request is a different project rather
53
+ than an extension of this one, say so plainly instead of routing. That
54
+ artifact is never rewritten to accommodate new scope.
55
+
56
+ If it does not hold, move on to Change-work.
48
57
 
49
58
  ### Change-work
50
59