@khoavu882/doflow 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/core/.claude-plugin/plugin.json +1 -1
  2. package/core/.codex-plugin/plugin.json +1 -1
  3. package/core/.plugin/plugin.json +1 -1
  4. package/core/harnesses/claude/hooks/session-end.sh +21 -1
  5. package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
  6. package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
  7. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +25 -1
  8. package/core/registry/contribution-points.json +1 -1
  9. package/core/registry/intake.json +3 -3
  10. package/core/registry/lifecycle.json +256 -5
  11. package/core/registry/packages/doflow/doflow-package.json +2 -2
  12. package/core/registry/settings.json +12 -12
  13. package/core/registry/storage.json +1 -2
  14. package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
  15. package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
  16. package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
  17. package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
  18. package/core/shared/scripts/doflow/bin/doflow-run +18 -0
  19. package/core/shared/skills/do-design/SKILL.md +6 -2
  20. package/core/shared/skills/do-design/references/data_model_example.md +167 -0
  21. package/core/shared/templates/doflow/data-model-template.md +136 -102
  22. package/package.json +1 -1
  23. package/src/adapters/antigravity/index.js +54 -12
  24. package/src/adapters/codex/agents.js +15 -1
  25. package/src/adapters/codex/config.js +5 -2
  26. package/src/adapters/copilot/index.js +11 -4
  27. package/src/adapters/copy-tree.js +7 -0
  28. package/src/adapters/created-file.js +41 -0
  29. package/src/adapters/mcp-entries.js +11 -1
  30. package/src/adapters/opencode/index.js +32 -5
  31. package/src/adapters/pi/index.js +15 -4
  32. package/src/adapters/pi/mcp.js +44 -18
  33. package/src/cli/commands/remove.js +3 -1
  34. package/src/cli/commands/update.js +7 -3
  35. package/src/cli/index.js +24 -12
  36. package/src/cli/runtime-commands.js +1 -1
  37. package/src/cli/shared.js +5 -3
  38. package/src/helper/marker-merge.js +11 -7
  39. package/src/install/backup-set.js +7 -3
  40. package/src/install/backup.js +13 -7
  41. package/src/install/context.js +2 -1
  42. package/src/install/manifest.js +35 -11
  43. package/src/install/paths.js +38 -0
  44. package/src/install/targets.js +11 -2
  45. package/src/lifecycle/index.js +80 -3
  46. package/src/lifecycle/retired-harnesses.js +16 -1
  47. package/src/lifecycle/view.js +3 -2
  48. package/src/registry/index.js +21 -4
  49. package/src/release/docs-blocks.js +5 -1
  50. package/src/runtime/advice/doctor.js +2 -2
  51. package/src/runtime/brief-tools.js +2 -1
  52. package/src/runtime/capability-router.js +26 -3
  53. package/src/runtime/context/pointer.js +17 -5
  54. package/src/runtime/context-pack.js +48 -8
  55. package/src/runtime/dispatch/integrate.js +27 -8
  56. package/src/runtime/dispatch/loop.js +7 -0
  57. package/src/runtime/dispatch/verify-run.js +9 -3
  58. package/src/runtime/feature-pointer.js +10 -1
  59. package/src/runtime/health.js +9 -4
  60. package/src/runtime/implementation-gate.js +10 -2
  61. package/src/runtime/intake/engine.js +11 -9
  62. package/src/runtime/knowledge/code-retrieval.js +11 -5
  63. package/src/runtime/knowledge/semble-provider.js +12 -2
  64. package/src/runtime/lifecycle/cli.js +7 -5
  65. package/src/runtime/lifecycle/overview.js +32 -2
  66. package/src/runtime/lifecycle/release.js +16 -3
  67. package/src/runtime/lifecycle/retention.js +12 -15
  68. package/src/runtime/lifecycle/store-upkeep.js +13 -3
  69. package/src/runtime/mask.js +3 -3
  70. package/src/runtime/mcp-consultations.js +11 -5
  71. package/src/runtime/research-request.js +3 -1
  72. package/src/runtime/review/command.js +3 -7
  73. package/src/runtime/review/start.js +24 -10
  74. package/src/runtime/roadmap/loop.js +14 -3
  75. package/src/runtime/roadmap/rows.js +2 -1
  76. package/src/runtime/settings/files.js +3 -1
  77. package/src/runtime/settings/prepared-run.js +12 -2
  78. package/src/runtime/settings/resolve.js +13 -1
  79. package/src/runtime/settings/write.js +73 -2
  80. package/src/runtime/storage/registry.js +2 -2
  81. package/src/runtime/storage/roots.js +4 -18
  82. package/src/runtime/storage/rules.js +22 -46
  83. package/src/runtime/storage/work-status.js +14 -4
  84. package/src/runtime/task-scope.js +17 -2
  85. package/src/runtime/task-state.js +11 -4
  86. package/src/runtime/trace/analyze.js +4 -2
  87. package/src/runtime/trace/indicators.js +25 -20
  88. package/src/runtime/trace/spans.js +4 -5
  89. package/src/runtime/verb-view.js +6 -5
  90. package/src/runtime/worker/command.js +4 -2
  91. package/src/runtime/worker/readiness.js +8 -4
  92. package/src/runtime/worker/supervisor.js +18 -6
  93. package/src/runtime/workflow-orchestrator.js +6 -2
@@ -39,6 +39,7 @@ const { isSafeSlug, invalidSlugRefusal } = require('../task-scope.js');
39
39
  const { appendEvents, readFold } = require('./event-store.js');
40
40
  const { deriveStatuses, readGitFacts, behindNote, subjectNames, readMerges, readBranchTips, readHistory } = require('./status.js');
41
41
  const { FollowupUsageError, oneLine, channelBy } = require('./followup.js');
42
+ const { parseSemVer, compareSemVer } = require('../../helper/semver.js');
42
43
 
43
44
  /** The release-tag pattern of IC-020, the one `git-state` filters by. */
44
45
  const RELEASE_TAG = /^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/;
@@ -125,13 +126,25 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
125
126
  return refusal('release', 'untracked-feature', `${untracked.join(', ')} ${untracked.length === 1 ? 'is' : 'are'} not tracked, so ${untracked.length === 1 ? 'it' : 'they'} cannot be added to or excluded from a release; run doflow-run lifecycle --action init --slug <slug> first. Nothing was written.`);
126
127
  }
127
128
 
128
- // DEC-030: containment in the tag being recorded never finishes a feature before its record exists.
129
- const otherTags = { ...facts, release_tags: facts.release_tags.filter((name ) => name !== tag) };
129
+ // DEC-030: containment in the tag being recorded never finishes a feature before its record exists. Of the
130
+ // other tags only an unrecorded one that is stable and earlier than `tag` (lower version, or a strict ancestor of it)
131
+ // may take a feature out of this release: a pre-release (v1.0.0-rc.1 before v1.0.0) or a later tag
132
+ // (v2.0.0 when recording v1.0.0) does not. A tag with a record is left in: status reads it by its record.
133
+ const tagCommit = commitOf(root, `refs/tags/${tag}`);
134
+ const recordedTags = new Set(fold.releases.map((r ) => r.tag));
135
+ const shadows = (name ) => {
136
+ const parsed = parseSemVer(name.slice(1));
137
+ if (!parsed || parsed.prerelease.length > 0) return false;
138
+ if (compareSemVer(name.slice(1), tag.slice(1)) < 0) return true;
139
+ // A strict ancestor only: a higher-versioned tag on the very same commit is not earlier.
140
+ const commit = tagCommit && commitOf(root, `refs/tags/${name}`);
141
+ return Boolean(commit) && commit !== tagCommit && isAncestor(root, commit, tagCommit);
142
+ };
143
+ const otherTags = { ...facts, release_tags: facts.release_tags.filter((name ) => name !== tag && (recordedTags.has(name) || shadows(name))) };
130
144
  const derived = deriveStatuses({ root, fold, facts: otherTags });
131
145
  if (derived.releaseMode === 'unknown' || !derived.integrationSha) return refusal('release', 'no-integration-ref', `${derived.reason || `${facts.integration_ref} does not resolve to a commit`}. Nothing was written.`);
132
146
 
133
147
  // X: the tag's commit once it exists, else the commit the status view pinned the integration ref to (DEC-044).
134
- const tagCommit = commitOf(root, `refs/tags/${tag}`);
135
148
  const bound = tagCommit || derived.integrationSha;
136
149
  const previousTag = previousTagOf(root, { tag, facts, bound });
137
150
  // A hotfix merged only into the tag's own history has no evidence on the integration chain: derive against the tag as well.
@@ -18,7 +18,7 @@
18
18
 
19
19
  const nodeFs = require('node:fs');
20
20
  const { isDeepStrictEqual } = require('node:util');
21
- const { loadSettings } = require('../settings/resolve.js');
21
+ const { loadSettings, registryOn } = require('../settings/resolve.js');
22
22
  const { foldInto, finalize, EVENT_TYPES } = require('./fold.js');
23
23
 
24
24
  const HOUR_MS = 3600000;
@@ -53,14 +53,7 @@ function retentionWindow({ env = process.env, projectRoot, fsImpl = nodeFs }
53
53
  const parsed = parseWindow(env);
54
54
  if (parsed.state === 'invalid') return parsed;
55
55
  try {
56
- let settingsRegistry;
57
- if (fsImpl !== nodeFs) {
58
- const { loadSettingsRegistry } = require('../settings/registry.js');
59
- const loaded = loadSettingsRegistry({ fsImpl });
60
- if (!loaded.ok) throw new Error(loaded.detail);
61
- settingsRegistry = loaded.registry;
62
- }
63
- const view = loadSettings({ projectRoot, env, fsImpl, ...(settingsRegistry ? { registry: settingsRegistry } : {}) });
56
+ const view = loadSettings({ projectRoot, env, fsImpl, ...registryOn(fsImpl) });
64
57
  const retention = view.get('storage.retentionHours', 'project');
65
58
  if (retention.state === 'invalid') return { state: 'invalid', raw: invalidRaw(retention, env) };
66
59
  if (retention.explicit) {
@@ -136,10 +129,12 @@ function unitsOf(events , shouldStop ) {
136
129
  }
137
130
 
138
131
  /**
139
- * Every string value and every object key in the data of the events whose type this version's fold
140
- * does not know (a newer DoFlow wrote them). The fold ignores them, so retention cannot tell what they change; a unit that
141
- * one of them names by id is kept whole rather than deleted from under it. Exact matches only:
142
- * keeping too little is a lost record, keeping too much only a file that stays.
132
+ * Every string value in the envelope, and every object key inside it, of the events whose type this
133
+ * version's fold does not know (a newer DoFlow wrote them). The fold ignores them, so retention
134
+ * cannot tell what they change; a unit that one of them names, by item id, event id or event file
135
+ * name, is kept whole rather than deleted from under it. The envelope's own field names are its
136
+ * shape, not references. Exact matches only: keeping too little is a lost record, keeping too much
137
+ * only a file that stays.
143
138
  * @returns {Set<string>|null} null when `shouldStop` fired
144
139
  */
145
140
  function unknownTypeStrings(events , shouldStop ) {
@@ -148,7 +143,7 @@ function unknownTypeStrings(events , shouldStop )
148
143
  for (const event of events) {
149
144
  if (shouldStop?.()) return null;
150
145
  if (known.has(event.type)) continue;
151
- const pending = [event.data];
146
+ const pending = Object.values(event);
152
147
  while (pending.length > 0) {
153
148
  if (shouldStop?.()) return null;
154
149
  const value = pending.pop();
@@ -260,7 +255,9 @@ function selectExpired(events , { now, windowMs, shouldStop } = {})
260
255
  };
261
256
  const baseSelected = safeSubset(candidates);
262
257
  if (!baseSelected) return incomplete();
263
- const unprotected = baseSelected.filter((unit ) => ![...unit.items].some((key ) => mentioned.has(key.slice(key.indexOf(':') + 1))));
258
+ const namedByUnknown = (unit ) => [...unit.items].some((key ) => mentioned.has(key.slice(key.indexOf(':') + 1)))
259
+ || unit.events.some((event ) => mentioned.has(event.id) || mentioned.has(`${event.id}.json`));
260
+ const unprotected = baseSelected.filter((unit ) => !namedByUnknown(unit));
264
261
  // Dropping units can leave a set the refold no longer accepts; check it again, inside the subset.
265
262
  const selected = unprotected.length === baseSelected.length ? baseSelected : safeSubset(unprotected);
266
263
  if (!selected) return incomplete();
@@ -342,9 +342,9 @@ function retainLocked(root , options = {}) {
342
342
  if (!release) return outcome(0, [], true);
343
343
  try {
344
344
  const swept = sweepStaleTmp(fsImpl, root, now, staleTmpMs, { maxFiles, shouldStop });
345
- if (swept.partial) return outcome(0, [], true);
345
+ if (swept.partial) return outcome(swept.removed, [], true);
346
346
  const result = retain(fsImpl, root, window, now, { maxFiles: Math.max(0, maxFiles - swept.removed), shouldStop });
347
- return outcome(result.removedCount, result.warning, result.partial, result.selected, result.code);
347
+ return outcome(swept.removed + result.removedCount, result.warning, result.partial, result.selected, result.code);
348
348
  } finally {
349
349
  release();
350
350
  }
@@ -358,6 +358,16 @@ function needsPrune(fsImpl , root , window , now ) {
358
358
  return selectExpired(readEvents(root, { fsImpl }).events, { now, windowMs: window.ms }).files.length > 0;
359
359
  }
360
360
 
361
+ /**
362
+ * Whether the old `agent-docs/lifecycle` copy still waits to be copied. Read-only (no lock, no
363
+ * write, `DOFLOW_UPKEEP` not consulted), for `doctor`; an unreadable folder counts as not pending.
364
+ */
365
+ function migrationPending(root , { fsImpl = nodeFs } = {}) {
366
+ try {
367
+ return !hasMarker(fsImpl, root) && !storeHasEvents(fsImpl, root) && legacyEventFiles(fsImpl, root).length > 0;
368
+ } catch { return false; }
369
+ }
370
+
361
371
  /**
362
372
  * Prepares the project store for one verb invocation.
363
373
  * @param {string} root projectRoot() result
@@ -439,4 +449,4 @@ function prepareStore(root , { env = process.env, now = new Date(), fsImp
439
449
  }
440
450
  }
441
451
 
442
- module.exports = { prepareStore, retainLocked, removedLine, STALE_TMP_MS, LEGACY_LIFECYCLE_REL, LEGACY_EVENTS_REL, MARKER_REL };
452
+ module.exports = { prepareStore, migrationPending, retainLocked, removedLine, STALE_TMP_MS, LEGACY_LIFECYCLE_REL, LEGACY_EVENTS_REL, MARKER_REL };
@@ -12,8 +12,8 @@
12
12
  * Rules run in the table's order. Normalisation (rules 10 to 13) is a separate step applied to
13
13
  * failure messages only, after masking.
14
14
  *
15
- * The module has no I/O and no state; the home directory is read from `HOME` unless the caller
16
- * passes one.
15
+ * The module has no I/O and no state; the home directory is read from `HOME` (`USERPROFILE` when
16
+ * `HOME` is absent or empty, as on Windows) unless the caller passes one.
17
17
  */
18
18
 
19
19
  const MASKED = '<masked>';
@@ -152,7 +152,7 @@ function escapeRegExp(text ) { return text.replace(/[.*+?^${}()|[
152
152
 
153
153
  /** The home directory to abbreviate: an absolute path other than the file system root. */
154
154
  function homeOf(options ) {
155
- const raw = options && 'home' in options ? options.home : process.env.HOME;
155
+ const raw = options && 'home' in options ? options.home : (process.env.HOME || process.env.USERPROFILE);
156
156
  if (typeof raw !== 'string' || !(raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw))) return null;
157
157
  const home = raw.replace(/[\\/]+$/, '');
158
158
  return home.length > 1 ? home : null;
@@ -230,14 +230,15 @@ const bySortKey = (a , b ) => (a < b ? -1 : a > b ? 1 : 0);
230
230
  * Injected readers (all optional; a missing one contributes nothing):
231
231
  * - `ledger.queryEvidence({taskId})` -> evidence items (active ones count)
232
232
  * - `requests.list(taskId)` -> research requests
233
- * - `briefRecords` -> IC-018 records already parsed, each `{ runTaskId, stage, tasks, expected }`
233
+ * - `briefRecords` -> IC-018 records already parsed, each `{ runTaskId, stage, createdAt, tasks, expected }`
234
234
  * - `readReport(path)` -> report text, or null when the file is missing
235
235
  * - `reach.anyHarnessHas(server)` -> true | false | null (unknown); never decides an outcome
236
236
  * - `reach.harnessWith(server)` -> the display name of a harness that has the server, or null
237
237
  * - `harnessName(id)` -> a harness id's display name (for a brief record's `harness`)
238
- * - `addCommand(server)` -> the text after "add it:" in the unavailable notice
238
+ * - `addCommand(server, harnessId)` -> the text after "add it:" in the unavailable notice; `harnessId` is the harness
239
+ * of the brief that found the server unreachable, or null when no brief names one
239
240
  * - `since` -> ISO time of the previous stage's completion (or the run start); rule 3 counts only
240
- * evidence observed after it
241
+ * evidence observed after it, and only brief records created after it
241
242
  *
242
243
  * @returns {{ record: object, warnings: object[], notices: object[], evidenceToAdd: object[] }}
243
244
  */
@@ -266,8 +267,11 @@ function resolveConsultations({
266
267
  // ingested (design A6).
267
268
  const toolsUsed = [];
268
269
  const fromReports = new Map ();
270
+ // A stage visited twice leaves briefs from the earlier visit: when `since` is known only a brief created
271
+ // after it counts, and a record with no creation time (written before it was stamped) cannot be placed.
269
272
  const records = (Array.isArray(briefRecords) ? briefRecords : [])
270
- .filter((record ) => record && stageId && taskId && record.stage === stageId && record.runTaskId === taskId);
273
+ .filter((record ) => record && stageId && taskId && record.stage === stageId && record.runTaskId === taskId
274
+ && (since === null || (typeof record.createdAt === 'string' && record.createdAt > since)));
271
275
  const reports = [];
272
276
  for (const record of records) {
273
277
  for (const task of record.tasks || []) {
@@ -347,10 +351,12 @@ function resolveConsultations({
347
351
  // brief writes `unreachable` only where every config the harness merges was read (IC-017).
348
352
  const unreachable = new Map ();
349
353
  const reachableOn = new Map ();
354
+ const unreachableOn = new Map ();
350
355
  for (const record of records) {
351
356
  for (const expected of record.expected || []) {
352
357
  if (!expected || typeof expected.server !== 'string') continue;
353
358
  if (expected.reach === 'unreachable' && !unreachable.has(expected.server)) unreachable.set(expected.server, record.tasks?.[0]?.id);
359
+ if (expected.reach === 'unreachable' && !unreachableOn.has(expected.server)) unreachableOn.set(expected.server, record.harness ?? null);
354
360
  if (expected.reach === 'reachable' && !reachableOn.has(expected.server)) reachableOn.set(expected.server, record.harness ?? null);
355
361
  }
356
362
  }
@@ -415,7 +421,7 @@ function resolveConsultations({
415
421
  text: `[WARN] mcp: ${server} reported unavailable but ${has} has it configured`,
416
422
  });
417
423
  }
418
- const add = !has && typeof addCommand === 'function' ? addCommand(server) : null;
424
+ const add = !has && typeof addCommand === 'function' ? addCommand(server, unreachableOn.get(server) ?? null) : null;
419
425
  notices.push({
420
426
  kind: 'unavailable', server,
421
427
  text: `[INFO] mcp: ${server} unavailable to ${skill}${add ? `; add it: ${add}` : ''}`,
@@ -95,7 +95,9 @@ class ResearchRequestStore {
95
95
  }
96
96
 
97
97
  stageFor(taskId , stageId ) {
98
- const run = readTaskState(this.fsImpl, this.runFile(taskId));
98
+ // The run is read the way every run read is: no link followed, no FIFO or device opened (FU-d88kp0).
99
+ const { readRunRecord } = require('./implementation-gate.js');
100
+ const run = readRunRecord(this.fsImpl, this.runFile(taskId), path.join(this.projectRoot, '.doflow', 'state', 'orchestration'));
99
101
  if (!run || run.taskClass !== 'feature' || run.state !== 'RUNNING') {
100
102
  throw new Error(`Task '${taskId}' has no active feature stage`);
101
103
  }
@@ -25,15 +25,10 @@ const TELEMETRY_COUNT_KEYS = Object.freeze([
25
25
  'plausible', 'unverified_blockers', 'bug_classes',
26
26
  ]);
27
27
 
28
- // Inherited location variables would point the check at another repository (C-8).
29
- const GIT_LOCATION_KEYS = Object.freeze([
30
- 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_COMMON_DIR',
31
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
32
- ]);
33
-
34
28
  function defaultGit(args , cwd , env ) {
35
29
  const gitEnv = { ...env };
36
- for (const key of GIT_LOCATION_KEYS) delete gitEnv[key];
30
+ // Inherited location variables would point the check at another repository (C-8); the list is verification's.
31
+ for (const key of require('../dispatch/verify-run.js').REPO_ENV) delete gitEnv[key];
37
32
  return spawnSync('git', ['-C', cwd, ...args], { cwd, env: gitEnv, stdio: 'ignore' });
38
33
  }
39
34
 
@@ -112,6 +107,7 @@ function handleReviewCommand({
112
107
  }
113
108
  return handleReviewStart({ slug: hasValue(slug) ? slug : null, base: hasValue(base) ? base : null, preset: hasValue(preset) ? preset : null, json, cwd, env, resolve, adviceClient, out });
114
109
  }
110
+ if (Array.isArray(recordPath)) return usage('--path may be given only once', json, out);
115
111
  if (hasValue(base)) return usage('--base is valid only with --action start', json, out);
116
112
  if (hasValue(preset)) return usage('--preset is valid only with --action start', json, out);
117
113
  if (hasValue(slug) && hasValue(recordPath)) return usage('use either --slug or --path, not both', json, out);
@@ -109,15 +109,16 @@ function readBounded(file , maxBytes )
109
109
  }
110
110
  }
111
111
 
112
- function parseRecordLines(file ) {
112
+ function parseRecordLines(file ) {
113
113
  const read = readBounded(file, MAX_RECORD_BYTES);
114
114
  if ('error' in read) return { error: read.error };
115
+ const text = 'text' in read ? read.text : '';
115
116
  const records = [];
116
- for (const [index, line] of ('text' in read ? read.text : '').split('\n').entries()) {
117
+ for (const [index, line] of text.split('\n').entries()) {
117
118
  if (!line.trim()) continue;
118
119
  try { records.push(JSON.parse(line)); } catch (error ) { return { error: `line ${index + 1}: ${error.message}` }; }
119
120
  }
120
- return { records };
121
+ return { records, text };
121
122
  }
122
123
 
123
124
 
@@ -243,20 +244,23 @@ function loadGatePolicy(cwd )
243
244
  * is on, so the class seat floor binds; maint-thin), the gate policy (the shipped gates.json narrowed by the
244
245
  * project's `.doflow/gates.override.json`, read as roadmap gate-b reads it, 119 IC-005; null when there is no task class, so
245
246
  * no floor), the protocols registry and the enforcement inputs (`loadEnforcementInputs`). A base, override or registry the loaders reject is a refusal carrying the loader's
246
- * reason (and the override file). Shared with the `protocol` verb.
247
+ * reason (and the override file). Shared with the `protocol` verb. `replaced` names the settings a prepared run
248
+ * (`config validate --prepared-run`) writes to the project layer: an invalid project candidate of such a key is
249
+ * replaced by it, so it does not refuse here.
247
250
  */
248
- function loadProtocolInputs(taskClass , cwd , env = process.env)
251
+ function loadProtocolInputs(taskClass , cwd , env = process.env, replaced = new Set())
249
252
  {
250
253
  const settingsView = {};
251
254
  try {
252
255
  const settings = require('../settings/resolve.js').loadSettings({ projectRoot: path.resolve(cwd), env });
253
256
  for (const key of PROTOCOL_SETTINGS) {
254
257
  const resolution = settings.get(key);
255
- // C-6: an invalid project or local review.* candidate would silently fall back to the default: refuse instead.
256
- // A preset key refuses an invalid user candidate too.
257
- const invalid = key.endsWith('.preset') ? invalidPresetCandidate(resolution)
258
- : key.startsWith('review.') ? invalidProjectCandidate(resolution) : undefined;
259
- if (invalid) return { error: 'settings-unavailable', message: `${key} in ${invalid.source}: ${invalid.reason}` };
258
+ // C-6: an invalid project or local review.* candidate would silently fall back to the default: refuse instead,
259
+ // except a project candidate that a prepared run replaces. A preset key refuses an invalid user candidate too.
260
+ const judged = replaced.has(key) ? { ...resolution, candidates: (resolution.candidates ?? []).filter((c ) => c.layer !== 'project') } : resolution;
261
+ const stands = key.endsWith('.preset') ? invalidPresetCandidate(judged)
262
+ : key.startsWith('review.') ? invalidProjectCandidate(judged) : undefined;
263
+ if (stands) return { error: 'settings-unavailable', message: `${key} in ${stands.source}: ${stands.reason}` };
260
264
  settingsView[key] = { value: resolution.value, layer: resolution.origin.layer };
261
265
  }
262
266
  } catch (error ) {
@@ -429,12 +433,14 @@ async function handleReviewStart({ slug, base, preset, json = false, cwd, env, r
429
433
  result.applicable = result.notApplicable === null;
430
434
 
431
435
  let nextCommand = null;
436
+ let seen = null;
432
437
  if (result.applicable) {
433
438
  const state = readHeadState(cwd, env);
434
439
  result.head = state.head;
435
440
  result.clean = state.clean;
436
441
  const parsed = parseRecordLines(verificationRunRecordFile(verificationRunsRoot(cwd), verifyId ));
437
442
  if ('error' in parsed) return refuse('records-unreadable', parsed.error);
443
+ seen = { head: state.head, clean: state.clean, text: parsed.text };
438
444
  result.reasons = reviewPrecondition({ records: parsed.records, head: state.head, clean: state.clean, slug: verifyId });
439
445
  if (result.reasons.length) {
440
446
  result.ok = false;
@@ -507,6 +513,14 @@ async function handleReviewStart({ slug, base, preset, json = false, cwd, env, r
507
513
  const decided = resolveProtocol({ ...facts, advicePick: null });
508
514
  if (!decided.ok) return refuseResolution(decided.reason);
509
515
  const advice = await askAdvice({ decision: { depth: decided.protocol.depth, scope }, risk: result.risk, taskClass, slug: resolvedSlug, files: paths ? paths.length : null, cwd, env, adviceClient });
516
+ // The advice await can take a while: what the precondition and the risk were read from must still stand.
517
+ if (seen && advice.status !== 'off') {
518
+ const now = readHeadState(cwd, env);
519
+ const records = parseRecordLines(verificationRunRecordFile(verificationRunsRoot(cwd), verifyId ));
520
+ if (now.head !== seen.head || now.clean !== seen.clean || 'error' in records || records.text !== seen.text) {
521
+ return refuse('state-moved', 'HEAD, the working tree or the verification records changed while the advice was asked; run review start again');
522
+ }
523
+ }
510
524
  const resolved = resolveProtocol({ ...facts, advicePick: advice.pick });
511
525
  if (!resolved.ok) return refuseResolution(resolved.reason);
512
526
  const { protocol } = resolved;
@@ -15,6 +15,7 @@ const path = require('node:path');
15
15
  const crypto = require('node:crypto');
16
16
  const { execFileSync: defaultExecFileSync } = require('node:child_process');
17
17
  const { listCheckouts } = require('../checkouts.js');
18
+ const { readRegularFileText } = require('../task-scope.js');
18
19
  const { withGitMemo } = require('../git-memo.js');
19
20
  const {
20
21
  parseSeed,
@@ -661,11 +662,17 @@ const tripwireFile = (ctx ) => path.join(ctx.dir, 'tripwire', 'event
661
662
  function readTripwire(ctx ) {
662
663
  const file = tripwireFile(ctx);
663
664
  try {
664
- const value = JSON.parse(ctx.fsImpl.readFileSync(file, 'utf8'));
665
+ try { ctx.fsImpl.lstatSync(file); } catch (error ) {
666
+ if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
667
+ throw error;
668
+ }
669
+ // A bounded read: a link, a FIFO or a file over the record cap is never opened as the record.
670
+ const text = readRegularFileText(file, ctx.fsImpl);
671
+ if (text === null) throw new Error('not a regular file within the record size cap');
672
+ const value = JSON.parse(text);
665
673
  if (!value || value.schema !== 1 || !Array.isArray(value.reverts) || !Array.isArray(value.unverified)) throw new Error('not a schema 1 tripwire record');
666
674
  return value;
667
675
  } catch (error ) {
668
- if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
669
676
  throw new Unreadable(['tripwire'], error.message);
670
677
  }
671
678
  }
@@ -808,7 +815,9 @@ function orchestrationFor(ctx , row , worktree )
808
815
  // artifactRootOf finds it (the feature checkout, else the main one).
809
816
  const planExitCheck = () => require('../plan-content.js').planStageExit({ roots: [...new Set([worktree, runRoot, ctx.root])], slug: row.slug });
810
817
  const orch = new WorkflowOrchestrator({ repoRoot: ctx.repoRoot, projectRoot: runRoot, sourceRoot: worktree,
811
- stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck });
818
+ stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck,
819
+ // FR-006, as the orchestrate verb wires it: the ledger is found from any checkout of the row.
820
+ taskAttemptCheck: () => require('../task-attempts.js').checkCompletion({ slug: row.slug, cwd: worktree, fsImpl: ctx.fsImpl }) });
812
821
  let run ;
813
822
  try { run = orch.readRun(row.slug); } catch (error ) { throw new Unreadable([source], error.message); }
814
823
  if (run && (!Array.isArray(run.program) || !Number.isInteger(run.cursor) || typeof run.state !== 'string' || !Array.isArray(run.history))) {
@@ -1716,4 +1725,6 @@ module.exports = {
1716
1725
  // The lease primitive, exported for its interleaving test (C8).
1717
1726
  acquireLease,
1718
1727
  releaseLease, resolveMainRoot, noPsAlive,
1728
+ // The orchestrator the loop builds for a row, exported for its wiring test (FU-e4yr45).
1729
+ orchestrationFor,
1719
1730
  };
@@ -864,7 +864,8 @@ function render(
864
864
  for (const row of rows) {
865
865
  const info = getInfo(row.id);
866
866
  const needsStr = row.needs && row.needs.length > 0 ? row.needs.join(', ') : '-';
867
- const statusStr = info.status;
867
+ // A not-started row of a class the loop does not run waits for a person, not for time: `manual`.
868
+ const statusStr = info.status === 'not-started' && !LOOP_CLASSES.includes(row.class) ? 'manual' : info.status;
868
869
 
869
870
  let evidenceStr = '-';
870
871
  if (info.evidence) {
@@ -35,6 +35,8 @@ const BOM = '\uFEFF';
35
35
 
36
36
 
37
37
 
38
+
39
+
38
40
 
39
41
 
40
42
  function isObject(value ) {
@@ -265,7 +267,7 @@ function readLayerFile(file , { registry, fsImpl = nodeFs, includeDocumen
265
267
  values.set(key, { raw, value: error ? null : raw, error });
266
268
  }
267
269
  return { status: 'ok', code: null, offset: null, reason: null, values, notes,
268
- ...(includeDocument ? { document: parsed } : {}) };
270
+ ...(includeDocument ? { document: parsed , source: text } : {}) };
269
271
  }
270
272
 
271
273
  module.exports = { MAX_BYTES, jsonFailure, validateValue, readLayerFile };
@@ -188,6 +188,7 @@ function validatePrepared(doc , ctx ) {
188
188
  if (Object.hasOwn(doc, 'presets') && !isObject(doc.presets)) errors.push(entry('presets', null, 'type', 'presets must be an object with review and debate preset ids'));
189
189
  let loaded = null; // the start verb's inputs, loaded once and only when a preset is checked
190
190
  const inputs = () => (loaded ??= ctx.inputs(taskClass));
191
+ let settingsFault = null; // a live settings fault the prepared values do not replace: one entry per preset
191
192
  for (const kind of ['review', 'debate']) {
192
193
  if (!Object.hasOwn(presets, kind)) continue;
193
194
  const order = presetIds(protocols, kind); // the effective core order: the only ids that have a rank
@@ -201,6 +202,10 @@ function validatePrepared(doc , ctx ) {
201
202
  // The effective preset setting (C-3): the user/project/local layers `review --action start` reads, with this
202
203
  // file's own entry as the project layer, applied by the resolver's rule (settingsPick) to the loosest preset.
203
204
  const settingKey = `${kind}.preset`;
205
+ if (settingsFault === null) {
206
+ try { inputs(); } catch (error ) { if (!error.settings) throw error; settingsFault = error.message; }
207
+ }
208
+ if (settingsFault !== null) { errors.push(entry(key, null, 'settings-unavailable', settingsFault)); continue; }
204
209
  const view = { ...inputs().settingsView };
205
210
  const current = view[settingKey];
206
211
  if (typeof settings[settingKey] === 'string' && (!current || ['default', 'user', 'project'].includes(current.layer))) {
@@ -239,6 +244,11 @@ function report(file , errors , json , notes =
239
244
  return finishRuntime(errors.length === 0 ? 0 : 1);
240
245
  }
241
246
 
247
+ /** The settings keys the prepared run writes to the project layer: applied before the live settings are judged. */
248
+ function replaced(doc ) {
249
+ return new Set(isObject(doc.settings) ? Object.keys(doc.settings).filter((key) => doc.settings[key] !== null) : []);
250
+ }
251
+
242
252
  function handlePreparedRun({ file, json, registry, repoRoot, projectRoot, env, fsImpl = nodeFs } ) {
243
253
  let text ;
244
254
  try { text = fsImpl.readFileSync(path.resolve(file), 'utf8'); } catch { return usageError('config', `cannot read the prepared run ${file}`, json); }
@@ -255,8 +265,8 @@ function handlePreparedRun({ file, json, registry, repoRoot, projectRoot, env, f
255
265
  registry, protocols: protocols.registry, classes: taskClasses(repoRoot, fsImpl), env, projectRoot: root, repoRoot, fsImpl, notes,
256
266
  // what `review --action start` loads: the protocol settings view and gates.json narrowed by the project's override
257
267
  inputs: (taskClass ) => {
258
- const inputs = require('../review/start.js').loadProtocolInputs(taskClass, root, env);
259
- if ('error' in inputs) throw Object.assign(new Error(inputs.message), { usage: true });
268
+ const inputs = require('../review/start.js').loadProtocolInputs(taskClass, root, env, replaced(doc));
269
+ if ('error' in inputs) throw Object.assign(new Error(inputs.message), inputs.error === 'settings-unavailable' ? { settings: true } : { usage: true });
260
270
  return inputs;
261
271
  },
262
272
  };
@@ -214,6 +214,18 @@ function loadSettings({ projectRoot = null, global = false, env = process.env, r
214
214
  return view;
215
215
  }
216
216
 
217
+ /**
218
+ * The registry to pass `loadSettings` with `fsImpl`: none for the real file system (the shipped one),
219
+ * else the registry read through that file system. Throws when it is unusable (callers fail closed).
220
+ */
221
+ function registryOn(fsImpl ) {
222
+ if (fsImpl === nodeFs) return {};
223
+ const { loadSettingsRegistry } = require('./registry.js');
224
+ const loaded = loadSettingsRegistry({ fsImpl });
225
+ if (!loaded.ok) throw new Error(loaded.detail);
226
+ return { registry: loaded.registry };
227
+ }
228
+
217
229
  function buildView(registry , home , root , global , env , fsImpl , protocols , only ) {
218
230
  const { REGISTRY_FILE } = require('./registry.js');
219
231
  const { REF_META } = require('./refs.js');
@@ -710,4 +722,4 @@ function buildView(registry , home , root , global
710
722
  return view;
711
723
  }
712
724
 
713
- module.exports = { loadSettings, clearSettingsCache };
725
+ module.exports = { loadSettings, registryOn, clearSettingsCache };
@@ -189,10 +189,74 @@ function writeLayer({ file, set = {}, unset = [], scrubKeys = [], force = false,
189
189
  }
190
190
  }
191
191
 
192
+ /**
193
+ * Where a write to `file` lands. A symlinked settings file (a dotfile manager's link) is written
194
+ * through to its target so the link survives; a project or local file whose link leads outside the
195
+ * project, or leads nowhere, is refused rather than read through or copied.
196
+ */
197
+ function writeTarget(file , fsImpl ) {
198
+ let stat ;
199
+ try { stat = fsImpl.lstatSync(file); } catch { return { ok: true, target: file }; }
200
+ if (!stat.isSymbolicLink()) return { ok: true, target: file };
201
+ let real ;
202
+ try { real = fsImpl.realpathSync(file); } catch {
203
+ return { ok: false, result: invalid(file, 'is a symbolic link that does not resolve to a file; nothing was written', 'scope') };
204
+ }
205
+ const directory = path.dirname(file);
206
+ if (path.basename(directory) === '.doflow') {
207
+ try {
208
+ const rel = path.relative(fsImpl.realpathSync(path.dirname(directory)), real);
209
+ if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) throw new Error('outside');
210
+ } catch {
211
+ return { ok: false, result: invalid(file, 'is a symbolic link to a path outside the project; nothing was written', 'scope') };
212
+ }
213
+ }
214
+ return { ok: true, target: real };
215
+ }
216
+
217
+ /** The most significant digits a JSON number keeps exactly when it is read as a double and written back. */
218
+ const EXACT_DIGITS = 15;
219
+ const NUMBER_AT = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
220
+
221
+ /**
222
+ * The first top-level key of `text` (a JSON document) holding a number that `JSON.parse` followed by
223
+ * `JSON.stringify` would change in value: more than 15 digits and not a safe integer.
224
+ */
225
+ function inexactNumberKey(text ) {
226
+ let depth = 0;
227
+ let current = null;
228
+ for (let i = 0; i < text.length;) {
229
+ const ch = text[i];
230
+ if (ch === '"') {
231
+ let end = i + 1;
232
+ while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1;
233
+ if (depth === 1) {
234
+ let after = end + 1;
235
+ while (after < text.length && /\s/.test(text[after])) after++;
236
+ if (text[after] === ':') { try { current = JSON.parse(text.slice(i, end + 1)); } catch { current = null; } }
237
+ }
238
+ i = end + 1;
239
+ } else if (ch === '{' || ch === '[') { depth++; i++; }
240
+ else if (ch === '}' || ch === ']') { depth--; i++; }
241
+ else if (ch === '-' || (ch >= '0' && ch <= '9')) {
242
+ NUMBER_AT.lastIndex = i;
243
+ const match = NUMBER_AT.exec(text);
244
+ if (!match) { i++; continue; }
245
+ const token = match[0];
246
+ const digits = token.replace(/[eE].*$/, '').replace(/[-.]/g, '').replace(/^0+/, '').replace(/0+$/, '');
247
+ if (digits.length > EXACT_DIGITS && !(/^-?\d+$/.test(token) && Number.isSafeInteger(Number(token))) && current !== null) return current;
248
+ i += token.length;
249
+ } else i++;
250
+ }
251
+ return null;
252
+ }
253
+
192
254
  const INVALID = Symbol('invalid layer');
193
255
 
194
256
  /** The read, merge and rename of `writeLayer`, run while it holds the layer's lock. */
195
257
  function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect, backup, fsImpl } ) {
258
+ const landing = writeTarget(file, fsImpl);
259
+ if (!landing.ok) return landing.result;
196
260
  let before = null;
197
261
  let existing = {};
198
262
  let layer ;
@@ -231,6 +295,13 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
231
295
  for (const [key, value] of Object.entries(set)) values[key] = value;
232
296
  for (const key of unsetKeys) delete values[key];
233
297
 
298
+ if (layer?.status === 'ok' && typeof layer.source === 'string') {
299
+ const kept = inexactNumberKey(layer.source);
300
+ if (kept !== null && Object.hasOwn(values, kept) && !Object.hasOwn(set, kept)) {
301
+ return invalid(file, `key '${kept}' holds a number with more digits than JSON can rewrite exactly; nothing was written; edit the file by hand`, 'number-precision');
302
+ }
303
+ }
304
+
234
305
  const after = layerDocument(registry, values);
235
306
  const bytes = serializeLayer(after);
236
307
  const tooLarge = oversize(file, bytes);
@@ -244,7 +315,7 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
244
315
  const directory = path.dirname(file);
245
316
  let temporary = null;
246
317
  try {
247
- temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
318
+ temporary = path.join(path.dirname(landing.target), `.${path.basename(landing.target)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
248
319
  fsImpl.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
249
320
  fsImpl.chmodSync(temporary, 0o600);
250
321
  if (path.basename(directory) === '.doflow') {
@@ -253,7 +324,7 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
253
324
  return invalid(file, '.doflow symlink points outside the project', 'scope');
254
325
  }
255
326
  }
256
- fsImpl.renameSync(temporary, file);
327
+ fsImpl.renameSync(temporary, landing.target);
257
328
  temporary = null;
258
329
  clearSettingsCache();
259
330
  return { ok: true, file, before, after };
@@ -13,7 +13,7 @@ const RULES = new Map([
13
13
  ['date-partition', ['maxAgeDays']],
14
14
  ['recovery', ['keepNewest']],
15
15
  ['lifecycle-settled', ['defaultHours']],
16
- ['report-orphan', ['orphanGraceHours', 'foreignMaxAgeDays']],
16
+ ['report-orphan', ['orphanGraceHours']],
17
17
  ['age', ['maxAgeDays']],
18
18
  ['existing', []],
19
19
  ['control', []],
@@ -132,7 +132,7 @@ function formatBound(store ) {
132
132
  case 'date-partition': return `prune partitions older than ${bound.maxAgeDays} days`;
133
133
  case 'recovery': return `keep newest ${bound.keepNewest}, plus the snapshot named by the ledger`;
134
134
  case 'lifecycle-settled': return `retain settled events for ${bound.defaultHours} hours`;
135
- case 'report-orphan': return `prune orphan bodies after ${bound.orphanGraceHours} hours; foreign folders after ${bound.foreignMaxAgeDays} days`;
135
+ case 'report-orphan': return `prune orphan bodies after ${bound.orphanGraceHours} hours; foreign folders are kept`;
136
136
  case 'age': return `prune inactive directories after ${bound.maxAgeDays} days`;
137
137
  case 'existing':
138
138
  case 'control': return bound.text;