@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
@@ -10,7 +10,7 @@ const { ClaimsManager } = require('./claims.js');
10
10
  const { ResearchRequestStore } = require('./research-request.js');
11
11
  const { finishRuntime, usageError, EXIT } = require('./cli-result.js');
12
12
  const nativeConsoleLog = console.log;
13
- const { resolveFeature } = require('./task-scope.js');
13
+ const { resolveFeature, isSafeSlug } = require('./task-scope.js');
14
14
  const { readRegister } = require('./decision-register.js');
15
15
  const compose = require('./context/compose.js');
16
16
  const {
@@ -122,6 +122,13 @@ function classifyStoreFailure(error ) {
122
122
  return 'store-unwritable';
123
123
  }
124
124
 
125
+ /** A store-locked failure's additive diagnostics for `delivery`: the lock folder (relative to the
126
+ * state root) and the stray entries blocking it. Empty for any other failure. */
127
+ function lockDiagnostics(error , root ) {
128
+ if (classifyStoreFailure(error) !== 'store-locked' || typeof error?.lockPath !== 'string') return {};
129
+ return { lockPath: path.relative(root, error.lockPath), strayEntries: Array.isArray(error.strayEntries) ? error.strayEntries : [] };
130
+ }
131
+
125
132
  function profileRetention(harnessId , profiles ) {
126
133
  if (!harnessId) return null;
127
134
  const harnesses = profiles || getRegisteredHarnesses();
@@ -949,7 +956,9 @@ function handleContextPackCommand({
949
956
 
950
957
  const budget = resolveBudget({ taskClass: effectiveTaskClass, maxBytes: parsedMaxBytes, harnessFlag: harness, profiles });
951
958
 
952
- const feature = resolveFeature({ projectRoot: source, slug: slug || null });
959
+ const resolved = resolvePackFeature({ source, stateRoot: root, taskId, slug, typed: explicitSlug });
960
+ slug = resolved.slug;
961
+ const feature = resolved.feature;
953
962
 
954
963
  let deltaExpansions = [];
955
964
  if (parsedMaxBytes !== undefined) {
@@ -1008,6 +1017,7 @@ function handleContextPackCommand({
1008
1017
  recordReason = null;
1009
1018
  } catch (error ) {
1010
1019
  recordReason = error?.code === 'entry-evicted' ? 'entry-evicted' : classifyStoreFailure(error);
1020
+ Object.assign(pack.delivery, lockDiagnostics(error, root));
1011
1021
  }
1012
1022
  }
1013
1023
  pack.delivery.recorded = expansionRecorded;
@@ -1115,6 +1125,7 @@ function deliverPack({
1115
1125
  const form = json ? 'json' : 'markdown';
1116
1126
  let deliveryRecorded = !noRecord;
1117
1127
  let deliveryReason = noRecord ? 'no-record' : null;
1128
+ let deliveryLock = {};
1118
1129
  pack.delivery.recorded = deliveryRecorded;
1119
1130
  pack.delivery.reason = deliveryReason;
1120
1131
  pack.delivery.key = noRecord ? null : `${pack.delivery.packDigest}.${form}.${budget.harness?.id || 'unstated'}`;
@@ -1175,6 +1186,7 @@ function deliverPack({
1175
1186
  pack = trimmed;
1176
1187
  pack.delivery.recorded = deliveryRecorded;
1177
1188
  pack.delivery.reason = deliveryReason;
1189
+ Object.assign(pack.delivery, deliveryLock);
1178
1190
  pack.delivery.key = deliveryRecorded ? `${pack.delivery.packDigest}.${form}.${budget.harness?.id || 'unstated'}` : null;
1179
1191
  pack.accounting.tokens = makeTokenRecord(json ? jsonBytes : mdBytes);
1180
1192
  pack.omissions = pack.mode === 'bare' ? pack.omissions : compose.groupOmissions(pack.deliveryOmissions, !noRecord);
@@ -1224,6 +1236,8 @@ function deliverPack({
1224
1236
  } catch (error ) {
1225
1237
  deliveryRecorded = false;
1226
1238
  deliveryReason = classifyStoreFailure(error);
1239
+ deliveryLock = lockDiagnostics(error, root);
1240
+ Object.assign(pack.delivery, deliveryLock);
1227
1241
  pack.delivery.key = null;
1228
1242
  pack.delivery.recorded = deliveryRecorded;
1229
1243
  pack.delivery.reason = deliveryReason;
@@ -1274,10 +1288,13 @@ function resolveDeltaBasis({ root, source = root, taskId, slug, since, budget, p
1274
1288
  function gatherMandatory({ root, taskId, feature } ) {
1275
1289
  let reqText = null;
1276
1290
  let reqPath = 'requirement.md';
1291
+ // The folder lives in `featureRoot`, the checkout that holds it: the main checkout when the
1292
+ // checkout the verb runs in has none of its own.
1293
+ const folder = feature.featureFolder || feature.featureDir;
1277
1294
  if (!feature.error && feature.featureDir) {
1278
1295
  reqPath = feature.paths?.requirement
1279
- ? path.resolve(feature.repoRoot || root, feature.paths.requirement)
1280
- : path.join(feature.featureDir, 'intention', 'requirement.md');
1296
+ ? path.resolve(feature.featureRoot || feature.repoRoot || root, feature.paths.requirement)
1297
+ : path.join(folder, 'intention', 'requirement.md');
1281
1298
  try {
1282
1299
  if (fs.existsSync(reqPath)) {
1283
1300
  reqText = fs.readFileSync(reqPath, 'utf8');
@@ -1291,10 +1308,10 @@ function gatherMandatory({ root, taskId, feature } ) {
1291
1308
  if (!feature.error && feature.featureDir && taskId) {
1292
1309
  let planPath = null;
1293
1310
  if (feature.paths?.plan) {
1294
- planPath = path.resolve(feature.repoRoot || root, feature.paths.plan);
1311
+ planPath = path.resolve(feature.featureRoot || feature.repoRoot || root, feature.paths.plan);
1295
1312
  } else {
1296
- const nestedPlan = path.join(feature.featureDir, 'plan', 'plan.md');
1297
- const flatPlan = path.join(feature.featureDir, 'plan.md');
1313
+ const nestedPlan = path.join(folder, 'plan', 'plan.md');
1314
+ const flatPlan = path.join(folder, 'plan.md');
1298
1315
  if (fs.existsSync(nestedPlan)) planPath = nestedPlan;
1299
1316
  else if (fs.existsSync(flatPlan)) planPath = flatPlan;
1300
1317
  }
@@ -1323,6 +1340,27 @@ function gatherMandatory({ root, taskId, feature } ) {
1323
1340
  });
1324
1341
  }
1325
1342
 
1343
+ /**
1344
+ * The feature a pack is compiled for, as `{slug, feature}`. A `--slug` the caller typed wins; without
1345
+ * one, a task id that names an existing feature folder (in this checkout, the state root's, or the
1346
+ * checkout the branch's feature was found in) is that feature's own task, ahead of the branch's feature, so the
1347
+ * objective, artifacts and records all select the same feature (a pack for feature B's task id run
1348
+ * on feature A's branch). The common case, a task id that names no feature, resolves nothing extra.
1349
+ * @param {{source: string, taskId: string, slug?: string|null, typed?: string|null}} options
1350
+ * `typed`: the `--slug` the caller typed, when it is known; absent means `slug` was given on purpose.
1351
+ */
1352
+ function resolvePackFeature({ source, stateRoot = source, taskId, slug, typed } ) {
1353
+ const feature = resolveFeature({ projectRoot: source, slug: slug || null });
1354
+ if (typed || (typed === undefined && slug) || !isSafeSlug(taskId) || (!feature.error && feature.paths?.feature_slug === taskId)) return { slug, feature };
1355
+ const roots = new Set ([source, stateRoot, feature.repoRoot, feature.paths?.repo_root].filter((root) => typeof root === 'string' && root !== ''));
1356
+ if (![...roots].some((root) => fs.existsSync(path.join(root, 'agent-docs', 'doflow', taskId)))) return { slug, feature };
1357
+ try {
1358
+ const own = resolveFeature({ projectRoot: source, slug: taskId });
1359
+ if (!own.error && fs.existsSync(own.featureFolder || own.featureDir || '')) return { slug: taskId, feature: own };
1360
+ } catch { /* a failed lookup keeps the branch's feature */ }
1361
+ return { slug, feature };
1362
+ }
1363
+
1326
1364
  /**
1327
1365
  * 078 IC-001: the context pack `emitContext()` delivers on `launch` and `delta`, compiled by
1328
1366
  * compileContextPack() through the verb's own steps, with `maxBytes` as the budget and `since` the
@@ -1331,7 +1369,9 @@ function gatherMandatory({ root, taskId, feature } ) {
1331
1369
  */
1332
1370
  function packForEmission({ root, stateRoot = root, taskId = 'default', taskClass = 'feature', slug = null, harness = null, maxBytes, since = null, noRecord = false, env = process.env } ) {
1333
1371
  const budget = resolveBudget({ taskClass, maxBytes, harnessFlag: harness, env });
1334
- const feature = resolveFeature({ projectRoot: root, slug: slug || null });
1372
+ const resolved = resolvePackFeature({ source: root, stateRoot, taskId, slug, typed: slug || null });
1373
+ slug = resolved.slug;
1374
+ const feature = resolved.feature;
1335
1375
  // 115 C-2: the delivery record goes where `context-pack` puts it, under the state root; the feature
1336
1376
  // folder, the plan and the hashed source files are read in `root`, the checkout emitting.
1337
1377
  const basis = resolveDeltaBasis({ root: stateRoot, source: root, taskId, slug, since, budget, profiles: null });
@@ -17,8 +17,10 @@
17
17
  // Integration (IC-018). One dispatch per call, under the store's lease: `cherry-pick --no-commit`
18
18
  // of `C` on the integration HEAD, `write-tree`, `commit-tree` on the pre-integration head, then
19
19
  // `update-ref` naming that head as the expected old value, so a concurrent move of the branch fails
20
- // the update instead of being overwritten (DEC-050). Verification follows on the integrated HEAD
21
- // (IC-019), then B.3's end rule finishes or releases the 080 attempt inside the dispatch lock.
20
+ // the update instead of being overwritten (DEC-050). The new commit is recorded as the lease's
21
+ // `pickedHead` before `update-ref` runs (RT-10), so a stop between the two is recoverable (IC-032). Verification follows on the integrated HEAD
22
+ // (IC-019), then B.3's end rule finishes or releases the 080 attempt inside the dispatch lock, once
23
+ // the integration worktree is found on its branch, at this integration's head and clean again.
22
24
  // Undo is `reset --hard` to the pre-integration head, run only while the integration branch is
23
25
  // checked out there and points at a head this integration made (IC-020).
24
26
  //
@@ -310,7 +312,7 @@ function endStale(store , dispatch , reason , ctx , now
310
312
  * `ctx` is B.3's 080 context (`ledgerFile`, `roots`, `slug`, `env`, `cwd`, `fsImpl`, `now`); `env`
311
313
  * is the environment git and the verification inherit, less the repository variables. `holder` is
312
314
  * the lease holder `{ pid, start }` (this process by default). `beforeUpdateRef` is a test seam
313
- * called between `commit-tree` and `update-ref`, and `afterLease` one called between steps 1 and 2;
315
+ * called after the lease records the commit and before `update-ref`, and `afterLease` one called between steps 1 and 2;
314
316
  * the loop passes neither.
315
317
  *
316
318
  * Resolves `{ outcome, dispatchId, task, token, ... }`, `outcome` one of `none`, `not-running`,
@@ -446,15 +448,19 @@ async function integrateOnce({
446
448
  const made = runGit(integration.worktree, ['commit-tree', tree.stdout.trim(), '-p', preHead, '-m', `${task}: dispatch ${dispatchId} token ${token}`], { env: identityEnv, timeout: WRITE_TIMEOUT_MS });
447
449
  const commit = made.stdout.trim();
448
450
  if (!made.ok || !SHA.test(commit)) return releaseWithError('commit-tree-failed', made.status, null);
451
+ // RT-10: the commit is recorded in this dispatch's lease BEFORE the ref moves, so a stop between
452
+ // the two leaves a head the lease rule (IC-032) knows as this integration's. Not recorded (the lease
453
+ // is no longer this dispatch's), no ref move.
454
+ const recorded = mutateDispatch(file, (store ) => {
455
+ if (!store.lease || store.lease.dispatchId !== dispatchId) return { write: false, result: false };
456
+ store.lease.pickedHead = commit;
457
+ return { write: true, result: true };
458
+ });
459
+ if (!recorded) return releaseWithError('picked-head-unrecorded', null, null);
449
460
  if (beforeUpdateRef) await beforeUpdateRef({ worktree: integration.worktree, branch: integration.branch, preHead, commit });
450
461
  const moved = runGit(integration.worktree, ['update-ref', `refs/heads/${integration.branch}`, commit, preHead], { env: identityEnv, timeout: WRITE_TIMEOUT_MS });
451
462
  if (!moved.ok) return releaseWithError('update-ref-failed', moved.status, null);
452
463
  pickedHead = commit;
453
- mutateDispatch(file, (store ) => {
454
- if (!store.lease || store.lease.dispatchId !== dispatchId) return { write: false, result: null };
455
- store.lease.pickedHead = commit;
456
- return { write: true, result: null };
457
- });
458
464
  }
459
465
  }
460
466
 
@@ -487,6 +493,19 @@ async function integrateOnce({
487
493
  }, record);
488
494
  }
489
495
 
496
+ // A passing verification must also have left the integration worktree as it found it: on its branch,
497
+ // at this integration's head, listing nothing. Anything else is not this integration's to reset, so
498
+ // nothing is undone and the work stays for recovery; the run blocks instead of completing.
499
+ const post = integrationClean({ ...where, preHead: pickedHead ?? preHead });
500
+ if (!post.clean) {
501
+ return settle((store, d) => {
502
+ record(store);
503
+ retryLater(store, d);
504
+ blockRun(store, { task, kind: 'integration-dirty', reason: `after-verification:${post.reason}`, paths: post.paths });
505
+ return { ...base, outcome: 'integration-dirty', reason: `after-verification:${post.reason}`, paths: post.paths, verification };
506
+ }, record);
507
+ }
508
+
490
509
  const after = readLedger(ctx);
491
510
  const finished = mutateDispatch(file, (store ) => {
492
511
  const d = store.dispatches[dispatchId];
@@ -951,6 +951,13 @@ async function launchChildren(p ) {
951
951
  const started = Date.now();
952
952
  const granter = current.dispatches[req.granterDispatchId];
953
953
  const who = { dispatchId: req.granterDispatchId, task: granter ? granter.task : null, token: granter ? granter.token : null };
954
+ // RR-7: a launch whose record write was refused keeps its worktree and child run on the request
955
+ // (RR-5); launching it again would reuse the tree and overwrite that child run id.
956
+ if (req.childRunId != null) {
957
+ refuse(req.requestId, 'child-run-recorded');
958
+ p.act('launch-child', who, 'refused', `${req.requestId} child-run-recorded`, Date.now() - started);
959
+ continue;
960
+ }
954
961
  if (!granter || granter.phase !== 'launched' || !granter.workerRunId) {
955
962
  refuse(req.requestId, 'granter-not-running');
956
963
  p.act('launch-child', who, 'refused', `${req.requestId} granter-not-running`, Date.now() - started);
@@ -24,9 +24,15 @@ const processTree = require('../worker/process-tree.js');
24
24
  const { EvidenceLedger } = require('../evidence-ledger.js');
25
25
  const { emitVerifyTelemetry } = require('../verification/telemetry-emit.js');
26
26
 
27
- /** The same names as 081's `REPO_ENV` (`src/runtime/worker/worktree-check.js:30`), which that
28
- * module does not export: inherited variables that would point git at another repository. */
29
- const REPO_ENV = Object.freeze(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY']);
27
+ /** The repository-local variables of `git rev-parse --local-env-vars` (git 2.50): inherited, they would point git at
28
+ * another repository. Hardcoded so no git process is spawned to compute it. Fetch (`packages/fetch.ts`) strips these
29
+ * and also `GIT_ALLOW_PROTOCOL`; the integration step and the review checks inherit this list. `GIT_CONFIG*` is left
30
+ * out on purpose: CI injects authentication through it. */
31
+ const REPO_ENV = Object.freeze([
32
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_OBJECT_DIRECTORY', 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_IMPLICIT_WORK_TREE',
33
+ 'GIT_GRAFT_FILE', 'GIT_INDEX_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_PREFIX',
34
+ 'GIT_SHALLOW_FILE', 'GIT_COMMON_DIR',
35
+ ]);
30
36
  /** How long the streams may stay open after the leader's exit (a grandchild may hold them). */
31
37
  const DRAIN_MS = 500;
32
38
  /** Bound on the `git rev-parse HEAD` read of the verified head. */
@@ -24,6 +24,8 @@ const { resolveActiveFeature } = require('./feature-resolve.js');
24
24
  const SCHEMA = 1;
25
25
  const POINTER_DIR = path.join('.doflow', 'state', 'feature');
26
26
  const POINTER_NAME = /^[0-9a-f]{16}\.json$/;
27
+ /** The single-file pointer written before row 135 (one per store, not per checkout). It is never migrated or deleted. */
28
+ const LEGACY_FILE = path.join('.doflow', 'state', 'feature.json');
27
29
 
28
30
 
29
31
 
@@ -145,7 +147,14 @@ function describeActive({ storeRoot, cwd } )
145
147
  const file = pointerFile(storeRoot, here.key);
146
148
  const read = readEntry(file);
147
149
  if (read.status === 'unreadable') return { ...base, state: 'unreadable', reason: read.reason, file: path.relative(storeRoot, file) };
148
- if (read.status === 'none') return { ...base, state: 'none' };
150
+ if (read.status === 'none') {
151
+ // A pre-135 feature.json is not read as an answer, but it is not silence either: say it is there and how to retire it.
152
+ const legacy = path.join(storeRoot, LEGACY_FILE);
153
+ if (fs.existsSync(legacy)) {
154
+ return { ...base, state: 'unreadable', file: LEGACY_FILE, reason: 'a pre-row-135 single-file pointer that is not migrated to the per-checkout pointer' };
155
+ }
156
+ return { ...base, state: 'none' };
157
+ }
149
158
  const entry = read.data ;
150
159
  const mismatch = entry.branch !== current.branch || (derived !== null && derived !== entry.slug);
151
160
  if (mismatch) return { ...base, state: 'mismatch', pointer: entry };
@@ -31,6 +31,9 @@ const { readGrants, detectedProjectRoot } = require('./packages/grants.js');
31
31
  const { readState, grantRevoked } = require('./packages/state.js');
32
32
  const { transcriptsDoctorView } = require('./transcripts/registry.js');
33
33
  const { readLedger, stateRoot } = require('../state/index.js');
34
+ const { readRegularFileText } = require('./task-scope.js');
35
+ const { migrationPending } = require('./lifecycle/store-upkeep.js');
36
+ const { isRunNamespaceDir, isSupersededFlatRun } = require('./storage/work-status.js');
34
37
 
35
38
  /** Answered a probe. */
36
39
  const HEALTHY = 'HEALTHY';
@@ -612,22 +615,21 @@ function mcpConsultationsView({ projectRoot, fsImpl } ) {
612
615
  const listed = [];
613
616
  const readRunFile = (rel , name , namespace ) => {
614
617
  let run;
615
- try { run = JSON.parse(fsImpl.readFileSync(path.join(dir, rel), 'utf8')); } catch { run = null; }
618
+ try { run = JSON.parse(readRegularFileText(path.join(dir, rel), fsImpl) ?? 'null'); } catch { run = null; }
616
619
  listed.push({ rel, name, namespace, run });
617
620
  };
618
621
  let entries = [];
619
622
  try { entries = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { entries = []; }
620
623
  for (const entry of entries) if (entry.name.endsWith('.json') && !entry.isDirectory()) readRunFile(entry.name, entry.name, '');
621
624
  for (const entry of entries) {
622
- if (!entry.isDirectory() || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.name) || entry.name.endsWith('.lock')) continue;
625
+ if (!entry.isDirectory() || !isRunNamespaceDir(entry.name)) continue;
623
626
  let inner = [];
624
627
  try { inner = fsImpl.readdirSync(path.join(dir, entry.name), { withFileTypes: true }); } catch { inner = []; }
625
628
  for (const file of inner) if (file.name.endsWith('.json') && !file.isDirectory()) readRunFile(`${entry.name}/${file.name}`, file.name, entry.name);
626
629
  }
627
630
  listed.sort((a, b) => byText(a.rel, b.rel));
628
631
  // A flat run whose namespaced copy exists is that copy's seed, not a second run.
629
- const files = listed.filter((one) => one.namespace !== '' || !listed.some((other) => other.namespace !== '' && other.name === one.name
630
- && (typeof one.run?.featureSlug !== 'string' || one.run.featureSlug === other.namespace)));
632
+ const files = listed.filter((one) => one.namespace !== '' || !isSupersededFlatRun(one.name, one.run, listed.filter((other) => other.namespace !== '')));
631
633
  let unreadableRuns = 0;
632
634
  const bySkill = new Map();
633
635
  for (const { name, run } of files) {
@@ -935,6 +937,9 @@ function buildHealthReport({ repoRoot, projectRoot = process.cwd(), router, exec
935
937
  const warnings = [
936
938
  ...(nodeWarning ? [nodeWarning] : []),
937
939
  ...claudePermissionWarnings({ repoRoot: root, projectRoot, homeDir: home, fsImpl }),
940
+ ...(migrationPending(path.resolve(projectRoot), { fsImpl })
941
+ ? [{ kind: 'lifecycle-migration-pending', subject: 'lifecycle store', detail: 'the lifecycle store has not been copied yet from agent-docs/lifecycle; run any followup, lifecycle or goal command once without DOFLOW_UPKEEP=off. doctor wrote nothing.' }]
942
+ : []),
938
943
  ...freshness
939
944
  .filter((item) => item.state === 'STALE')
940
945
  .map((item) => ({ kind: 'index-stale', subject: item.providerId, detail: `${item.label} is behind the working tree; refresh with: ${item.refresh}` })),
@@ -401,15 +401,22 @@ function findNamespacedRun({ stateRoot, taskId, exec } ) {
401
401
  const name = `${taskId}.json`;
402
402
  const missing = { status: 'missing', file: null, root: null, origin: null, candidates: [] };
403
403
  const inRoot = (root ) => runFilesAt(root).filter((entry) => entry.namespace !== null && path.basename(entry.file) === name);
404
+ // A flat run in another checkout that names no feature is any feature's, so `list` shows it and
405
+ // `status` opens it; read through the same safe read as every other run (a link, FIFO or device
406
+ // is never opened, and is not a match).
407
+ const flatInRoot = (root ) => runFilesAt(root).filter((entry) => {
408
+ if (entry.namespace !== null || path.basename(entry.file) !== name) return false;
409
+ try { return namesNoFeature(JSON.parse(readRunText(fs, entry.file, runStoreDir(root)) )); } catch { return false; }
410
+ });
404
411
  const settle = (entries , root , origin ) => (
405
412
  entries.length === 1
406
- ? { status: 'found', file: entries[0].file, root, origin, candidates: [], slug: entries[0].namespace }
413
+ ? { status: 'found', file: entries[0].file, root, origin, candidates: [], ...(entries[0].namespace !== null ? { slug: entries[0].namespace } : {}) }
407
414
  : { ...missing, status: 'ambiguous', byFeature: true, candidates: entries.map((entry) => entry.file) });
408
415
  const here = inRoot(stateRoot);
409
416
  if (here.length > 0) return settle(here, stateRoot, 'current');
410
417
  const checkouts = listCheckouts({ cwd: stateRoot, ...(exec ? { exec } : {}) });
411
418
  if (!checkouts.ok || checkouts.sandbox) return missing;
412
- const matches = checkouts.others.flatMap((root ) => inRoot(root).map((entry) => ({ ...entry, root })));
419
+ const matches = checkouts.others.flatMap((root ) => [...inRoot(root), ...flatInRoot(root)].map((entry) => ({ ...entry, root })));
413
420
  if (matches.length === 0) return missing;
414
421
  return settle(matches, matches[0].root, 'other');
415
422
  }
@@ -568,6 +575,7 @@ module.exports = {
568
575
  featureSlugFor,
569
576
  runSlugOf,
570
577
  orchestrationRuns,
578
+ runEntriesAt,
571
579
  selectRunEntries,
572
580
  findRunFile,
573
581
  RUN_STORE_REL,
@@ -210,10 +210,12 @@ function directClaim(categories , name ) {
210
210
 
211
211
  /**
212
212
  * Plans a sort of the loose files (IC-002). Pure with respect to the folder: it reads, hashes and decides, and
213
- * writes nothing. A companion rule sends a file beside a same-stem file that is itself moving in this plan, or
213
+ * writes nothing. The manifest is read, and so validated, once before anything is planned (a caller that already
214
+ * holds a snapshot passes it as `manifest`), so a malformed manifest or a linked `.intake` fails the same way
215
+ * whether or not a loose render has a partner. A companion rule sends a file beside a same-stem file that is itself moving in this plan, or
214
216
  * that already sits in the category folder or is recorded `done` in the manifest.
215
217
  */
216
- function planSort({ contextDir, taxonomy, now, fsImpl = nodeFs, withHashes = true } ) {
218
+ function planSort({ contextDir, taxonomy, now, fsImpl = nodeFs, withHashes = true, manifest = readManifest(contextDir, fsImpl) } ) {
217
219
  const { files, dirs, unsupported } = rootEntries(contextDir, fsImpl);
218
220
  const windowMs = taxonomy.recentWindowSeconds * 1000;
219
221
  const recent = new Set ();
@@ -236,7 +238,7 @@ function planSort({ contextDir, taxonomy, now, fsImpl = nodeFs, withHashes = tru
236
238
 
237
239
  // A partner that no longer is loose: already in the category folder, or recorded `done` by an earlier run. This
238
240
  // settles a render stranded by a crash or by a recent-window skip while its source moved.
239
- let doneMoves = null;
241
+ const doneMoves = manifest.runs.flatMap((run ) => run.moves.filter((move ) => move.state === 'done'));
240
242
  const settledPartnerPlace = (category , rule , name ) => {
241
243
  const stem = stemOf(name);
242
244
  for (const sourceRule of category.rules) {
@@ -251,8 +253,7 @@ function planSort({ contextDir, taxonomy, now, fsImpl = nodeFs, withHashes = tru
251
253
  if (!isUnreadable(error)) throw error; // a folder this user may not read holds no partner we can see: the render stays loose
252
254
  }
253
255
  if (inFolder) return place;
254
- const done = doneMoves ??= readManifest(contextDir, fsImpl).runs.flatMap((run ) => run.moves.filter((move ) => move.state === 'done'));
255
- if (done.some((move ) => path.posix.dirname(move.to) === place.dir && isPartner(path.posix.basename(move.to)))) return place;
256
+ if (doneMoves.some((move ) => path.posix.dirname(move.to) === place.dir && isPartner(path.posix.basename(move.to)))) return place;
256
257
  }
257
258
  return null;
258
259
  };
@@ -376,7 +377,7 @@ const hasPlanned = (manifest ) => manifest.runs.some((run ) =>
376
377
  const absent = (action , extra = {}) => ({ action, state: 'absent', ...extra });
377
378
  const absentSort = (action ) => absent(action, { runId: null, moves: [], skipped: [], unmatched: [], createdDirs: [] });
378
379
 
379
- /** IC-002 preview: the plan, with `runId: null`; no lock, no manifest, no write. */
380
+ /** IC-002 preview: the plan, with `runId: null`; reads the manifest once, takes no lock and writes nothing. */
380
381
  function previewSort({ contextDir, taxonomy, now = new Date(), fsImpl = nodeFs } ) {
381
382
  if (contextState(contextDir, fsImpl) === 'absent') return absentSort('preview');
382
383
  return { action: 'preview', state: 'sorted', runId: null, ...planSort({ contextDir, taxonomy, now, fsImpl }) };
@@ -387,13 +388,14 @@ function sortFolder({ contextDir, taxonomy, now = new Date(), fsImpl = nodeFs }
387
388
  if (contextState(contextDir, fsImpl) === 'absent') return absentSort('sort');
388
389
  const empty = (plan ) => ({ action: 'sort', state: 'sorted', runId: null, moves: [], skipped: plan.skipped, unmatched: plan.unmatched, createdDirs: [] });
389
390
  // Nothing to move and nothing to settle: report without a lock or a single write.
390
- const first = planSort({ contextDir, taxonomy, now, fsImpl, withHashes: false });
391
- if (first.moves.length === 0 && !hasPlanned(readManifest(contextDir, fsImpl))) return empty(first);
391
+ const snapshot = readManifest(contextDir, fsImpl);
392
+ const first = planSort({ contextDir, taxonomy, now, fsImpl, withHashes: false, manifest: snapshot });
393
+ if (first.moves.length === 0 && !hasPlanned(snapshot)) return empty(first);
392
394
 
393
395
  return withManifestLock(contextDir, (assertHeld ) => {
394
396
  const manifest = readManifest(contextDir, fsImpl);
395
397
  if (reconcile(manifest, contextDir, now, fsImpl, assertHeld)) writeManifest(contextDir, manifest, fsImpl, assertHeld);
396
- const plan = planSort({ contextDir, taxonomy, now, fsImpl });
398
+ const plan = planSort({ contextDir, taxonomy, now, fsImpl, manifest });
397
399
  if (plan.moves.length === 0) return empty(plan);
398
400
 
399
401
  const run = { id: newRunId(now), action: 'sort', startedAt: now.toISOString(), finishedAt: null, source: null, stage: null, createdDirs: [], moves: [] };
@@ -45,12 +45,14 @@ function lexicalOutcome(result ) {
45
45
 
46
46
  function validFreshness(value ) { return INDEX_STATES.has(value) ? value : 'UNKNOWN'; }
47
47
 
48
- function appendOutcome({ binding, projectRoot, taskId, slug, intent, need, router, provider, capability, id, candidates, indexState, failure, bound = null, complete } ) {
48
+ function appendOutcome({ binding, projectRoot, taskId, slug, intent, need, router, provider, capability, id, candidates, indexState, failure, bound = null, complete, answered = complete } ) {
49
+ // `answered`: the provider finished its own search. `complete` additionally needs a complete corpus. An
50
+ // empty answer is classified on `answered`, so an incomplete corpus never turns it into a timeout.
49
51
  let stored = null;
50
52
  if (binding) {
51
53
  let status; let reason;
52
54
  if (failure) { status = 'UNREACHED'; reason = failureReason(failure, indexState); }
53
- else if (!candidates.length && complete) {
55
+ else if (!candidates.length && answered) {
54
56
  if (indexState === 'STALE') { status = 'EMPTY'; reason = 'index-stale'; }
55
57
  else if (indexState === 'UNKNOWN') { status = 'UNVERIFIED'; reason = 'index-unverifiable'; }
56
58
  else { status = 'EMPTY'; reason = 'empty-answer'; }
@@ -233,10 +235,14 @@ async function executeCodeRetrieval(options = {}) {
233
235
  rankedLists.push({ provider: 'native.rg', capability, queryRef, retrievalAttemptRef: lexicalId, candidates: lexCandidates });
234
236
  const lexOutcome = lexicalOutcome(lexResult);
235
237
  attempts.push(appendOutcome({ binding, projectRoot, taskId, slug, intent, need, router, provider: 'native.rg', capability,
236
- id: lexicalId, candidates: lexCandidates, indexState: 'NOT_APPLICABLE', ...lexOutcome, complete: Boolean(lexResult.complete && inventory.complete) }));
238
+ id: lexicalId, candidates: lexCandidates, indexState: 'NOT_APPLICABLE', ...lexOutcome, complete: Boolean(lexResult.complete && inventory.complete), answered: Boolean(lexResult.complete) }));
237
239
  if (lexOutcome.failure || lexOutcome.bound) gaps.push({ provider: 'native.rg', reason: lexOutcome.failure || lexOutcome.bound, source: 'lexical' });
238
240
 
239
- if (profile === 'hybrid' && classification.queryClass === 'conceptual') {
241
+ // Semantic search needs a complete corpus (index build and refresh refuse one too): an incomplete inventory skips
242
+ // it as a coverage gap, never as a provider attempt or failure. The admission gaps above already say why.
243
+ if (profile === 'hybrid' && classification.queryClass === 'conceptual' && !inventory.complete) {
244
+ gaps.push({ source: 'semantic', reason: 'incomplete-corpus-skipped' });
245
+ } else if (profile === 'hybrid' && classification.queryClass === 'conceptual') {
240
246
  const semanticId = makeId();
241
247
  let semResult;
242
248
  const modelPath = options.modelPath || process.env.SEMBLE_MODEL_PATH || null;
@@ -269,7 +275,7 @@ async function executeCodeRetrieval(options = {}) {
269
275
  rankedLists.push({ provider: 'native.rg', capability, queryRef, retrievalAttemptRef: fallbackId, candidates: fallbackCandidates });
270
276
  const fallbackOutcome = lexicalOutcome(fallback);
271
277
  attempts.push(appendOutcome({ binding, projectRoot, taskId, slug, intent, need, router, provider: 'native.rg', capability,
272
- id: fallbackId, candidates: fallbackCandidates, indexState: 'NOT_APPLICABLE', ...fallbackOutcome, complete: Boolean(fallback.complete && inventory.complete) }));
278
+ id: fallbackId, candidates: fallbackCandidates, indexState: 'NOT_APPLICABLE', ...fallbackOutcome, complete: Boolean(fallback.complete && inventory.complete), answered: Boolean(fallback.complete) }));
273
279
  if (fallbackOutcome.failure || fallbackOutcome.bound) gaps.push({ provider: 'native.rg', reason: fallbackOutcome.failure || fallbackOutcome.bound, source: 'fallback' });
274
280
  }
275
281
  }
@@ -234,6 +234,9 @@ function inspectArtifacts(index , inventory , paths , model ,
234
234
  // rule: process.kill(pid, 0)) is reclaimed under a claim file named for its token, created `wx`: the claim winner re-reads
235
235
  // the lock and removes it only while it still carries the judged token, so a live or newer lock is never removed. A lock
236
236
  // from another host, from this process, or without a readable record is never reclaimed.
237
+ // The Python bridge child is what writes the cache, so executeBridge records its pid in the lock as `childPid`; a lock
238
+ // is stale only when the owner and that child are both gone. Ceiling: a Node process killed between spawn and the
239
+ // record leaves a lock without `childPid` (the child is then unprotected for that instant).
237
240
  // Ceiling: a reclaimer killed between creating and removing its claim leaves `operation.lock.claim.<token>`, and that
238
241
  // lock then waits for a manual removal, which the contention cause names.
239
242
  const LOCK_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -243,6 +246,12 @@ function readLockOwner(lock ) {
243
246
  return owner && Number.isInteger(owner.pid) && typeof owner.hostname === 'string' && LOCK_TOKEN.test(owner.token) ? owner : null;
244
247
  } catch { return null; }
245
248
  }
249
+ function recordLockChild(lock , childPid ) {
250
+ const owner = readLockOwner(lock); if (!owner || owner.pid !== process.pid || !Number.isInteger(childPid)) return;
251
+ const next = `${lock}.child.${process.pid}`;
252
+ try { fs.writeFileSync(next, `${JSON.stringify({ ...owner, childPid })}\n`, { flag: 'w', mode: 0o600 }); fs.renameSync(next, lock); }
253
+ catch { try { fs.unlinkSync(next); } catch {} }
254
+ }
246
255
  function reclaimStaleLock(lock , owner , hostname ) {
247
256
  const claim = `${lock}.claim.${owner.token}`;
248
257
  try { fs.writeFileSync(claim, `${JSON.stringify({ pid: process.pid, hostname })}\n`, { flag: 'wx', mode: 0o600 }); }
@@ -262,7 +271,7 @@ async function acquireSemanticLock(paths , { isAlive = defaultIsAlive, hostn
262
271
  return () => { fs.closeSync(fd); if (readLockOwner(paths.lock)?.token === token) fs.unlinkSync(paths.lock); };
263
272
  } catch (error ) { if (error.code !== 'EEXIST') throw error; }
264
273
  const owner = readLockOwner(paths.lock);
265
- if (owner && owner.hostname === hostname && owner.pid !== process.pid && !isAlive(owner.pid) && reclaimStaleLock(paths.lock, owner, hostname)) continue;
274
+ if (owner && owner.hostname === hostname && owner.pid !== process.pid && !isAlive(owner.pid) && !(Number.isInteger(owner.childPid) && isAlive(owner.childPid)) && reclaimStaleLock(paths.lock, owner, hostname)) continue;
266
275
  if (Date.now() >= deadline) {
267
276
  throw fault('lock-contention', 'UNKNOWN', `another semantic index operation holds ${paths.lock}${owner ? ` (pid ${owner.pid} on ${owner.hostname})` : ''}; `
268
277
  + 'run the command again when it finishes. A lock whose process has exited on this host is reclaimed automatically; '
@@ -300,6 +309,7 @@ function executeBridge(request , options , paths , sandbox )
300
309
  TRANSFORMERS_CACHE: library, XDG_CACHE_HOME: library, SEMBLE_GRAMMARS_CACHE_DIR: path.join(library, 'grammars'), TORCH_HOME: library,
301
310
  TMPDIR: library, TEMP: library, TMP: library, TOKENIZERS_PARALLELISM: 'false', SEMBLE_INDEX_WORKERS: '0' };
302
311
  const child = spawn(options.python, [options.bridgePath || path.join(__dirname, 'semble-bridge.py')], { cwd: paths.state, env, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
312
+ if (child.pid) recordLockChild(paths.lock, child.pid);
303
313
  let stdout = ''; let outputBytes = 0; let failure = null; let done = false;
304
314
  const stop = (reason ) => { if (!failure) failure = reason; child.kill('SIGKILL'); };
305
315
  const timer = setTimeout(() => stop('provider-timeout'), deadline);
@@ -362,4 +372,4 @@ async function runSembleQuery(options ) {
362
372
  finally { try { if (temp) fs.rmSync(temp, { recursive: true, force: true }); } catch {} try { if (release) release(); } catch {} }
363
373
  }
364
374
  module.exports = { SEMBLE_VERSION, SEMBLE_LIMITS, runSembleQuery, resolveSembleInterpreter, checkSembleVersion, semanticStatePaths, digest, fault, inside, safeDirectories,
365
- regularBytes, scanTree, inspectModel, inspectBm25, validateInventory, readSource, checkSources, inspectArtifacts, acquireSemanticLock, cleanupTemporary, executeBridge, readCompleted, modelLinkRoots };
375
+ regularBytes, scanTree, inspectModel, inspectBm25, validateInventory, readSource, checkSources, inspectArtifacts, acquireSemanticLock, recordLockChild, cleanupTemporary, executeBridge, readCompleted, modelLinkRoots };
@@ -15,7 +15,7 @@ const followup = require('./followup.js');
15
15
  const { StoreUnsafeError, StoreLockedError } = require('./event-store.js');
16
16
  const { prepareStore } = require('./store-upkeep.js');
17
17
  const { printSafe } = require('../mask.js');
18
- const { buildOverview, initFeature, featureStatus, activeFeature } = require('./overview.js');
18
+ const { buildOverview, capOverviewBrief, initFeature, featureStatus, activeFeature } = require('./overview.js');
19
19
  const { recordFeature, clearSlug } = require('../feature-pointer.js');
20
20
  const { releaseFeatures, recordMerged } = require('./release.js');
21
21
  const goal = require('./goal.js');
@@ -44,7 +44,7 @@ const OVERVIEW_BRIEF = Object.freeze({
44
44
  '$.followups.items[].id', '$.followups.items[].statement', '$.followups.items[].source', '$.followups.items[].pending',
45
45
  '$.intents', '$.goals[].goal', '$.goals[].outcome', '$.goals[].items', '$.goals[].proposeDone', '$.goals[].nudges',
46
46
  '$.goals[].conflicts', '$.conflicts', '$.unreadable', '$.unreadableReasons', '$.failures', '$.reason', '$.note',
47
- '$.integrationBehind', '$.pending', '$.next',
47
+ '$.integrationBehind', '$.pending', '$.omitted', '$.next',
48
48
  ]),
49
49
  });
50
50
  /** A brief list answers one page of this many items unless `--limit` says otherwise. */
@@ -195,7 +195,7 @@ function emitList(result , view , flags , json ) {
195
195
  const paged = result.shown !== undefined;
196
196
  const carried = { state: flags.state, ids: flags.ids, limit: flags.limit };
197
197
  const lines = !paged ? [] : [
198
- ...viewLines('page', { spec: FOLLOWUP_LIST_BRIEF, carried, nextCursor: result.nextCursor, view }),
198
+ ...viewLines('page', { spec: FOLLOWUP_LIST_BRIEF, carried, nextCursor: result.nextCursor, view, json: Boolean(json) }),
199
199
  ...(view === 'brief' ? viewLines('page-full', { spec: FOLLOWUP_LIST_BRIEF, carried, cursor: flags.cursor, shown: result.shown, json: Boolean(json) }) : []),
200
200
  ];
201
201
  if (!json) return emit({ ...result, next: result.shown < result.count ? lines : [] }, false, followupLines);
@@ -301,8 +301,10 @@ function handleLifecycleCommand({ action, cwd, global = false, slug = null, json
301
301
  // keeps its 50: that loop settles every pending item. The next lines name the paged list and carry no full-view line.
302
302
  if (view === 'brief' && json) {
303
303
  const items = flags.maintain ? result.followups.items : result.followups.items.slice(0, BRIEF_PAGE);
304
- const brief = { ...result, followups: { ...result.followups, shown: items.length, items } };
305
- return emit(withEnvelope(projectBrief(brief, OVERVIEW_BRIEF.keep), { view }), true, lifecycleLines);
304
+ const brief = capOverviewBrief({ ...result, followups: { ...result.followups, shown: items.length, items } });
305
+ // Something cut from a list: the next lines also say how to get everything.
306
+ const next = brief.omitted ? [...result.next, ...viewLines('full', { spec: OVERVIEW_BRIEF, carried: { maintain: flags.maintain, since: flags.since }, json: true })] : undefined;
307
+ return emit(withEnvelope(projectBrief(brief, OVERVIEW_BRIEF.keep), { view, ...(next ? { next } : {}) }), true, lifecycleLines);
306
308
  }
307
309
  } else if (act === 'init') {
308
310
  result = initFeature({ root, slug, take: flags.take, intent: flags.intent, goal: flags.goal });
@@ -28,6 +28,8 @@ const DISCOVERY_SHOWN = 15;
28
28
  const MAINTAIN_SHOWN = 50;
29
29
  /** Slugs shown per finished or awaiting-release bucket: those lists only grow, and the overview runs on every brainstorm. */
30
30
  const FEATURES_SHOWN = 10;
31
+ /** Entries per list in the JSON brief overview: intents, the ids of one intent, the nudges of one goal (137 FU-2jtkch). */
32
+ const BRIEF_LIST_SHOWN = 10;
31
33
 
32
34
  const refusal = (action , finding , message ) => ({ ok: false, action, finding, message });
33
35
 
@@ -116,7 +118,10 @@ function goalView(goal , features , statuses , conflicts = [])
116
118
  const met = goal.items.filter((i ) => i.met).length;
117
119
  const nudges = [];
118
120
  if (buckets.finished.length) {
119
- const who = buckets.finished.length === 1 ? `linked feature ${buckets.finished[0]} is finished` : `linked features ${buckets.finished.join(', ')} are finished`;
121
+ // The nudge names the most recent finished features only; `features.finished` still lists every one.
122
+ const finished = shownBucket(buckets.finished);
123
+ const who = buckets.finished.length === 1 ? `linked feature ${buckets.finished[0]} is finished`
124
+ : `linked features ${finished.shown.join(', ')}${finished.more ? ` (+${finished.more} more)` : ''} are finished`;
120
125
  for (const item of goal.items.filter((i ) => !i.met)) nudges.push(`${item.id} is unchecked and ${who}`);
121
126
  }
122
127
  return {
@@ -160,6 +165,31 @@ function nextLines({ shown, open, items, intents, goals, maintain, pendingItems,
160
165
  return next;
161
166
  }
162
167
 
168
+ /**
169
+ * The JSON brief form of an overview answer: at most BRIEF_LIST_SHOWN intents, ids per intent and nudges per goal. What
170
+ * was cut is counted in `omitted` (absent when nothing was cut): `intents`, and `intentItems`, `nudges` and
171
+ * `finished` (the finished features a goal's nudges leave out of their text) per intent or goal that exceeds the
172
+ * bound. The full view lists everything.
173
+ */
174
+ function capOverviewBrief(result ) {
175
+ const omitted = {};
176
+ const intents = result.intents.slice(0, BRIEF_LIST_SHOWN).map((intent ) => {
177
+ if (intent.items.length > BRIEF_LIST_SHOWN) {
178
+ (omitted.intentItems ??= []).push({ path: intent.path, total: intent.items.length, more: intent.items.length - BRIEF_LIST_SHOWN });
179
+ }
180
+ return { ...intent, items: intent.items.slice(0, BRIEF_LIST_SHOWN) };
181
+ });
182
+ if (result.intents.length > BRIEF_LIST_SHOWN) omitted.intents = { total: result.intents.length, more: result.intents.length - BRIEF_LIST_SHOWN };
183
+ const goals = result.goals.map((goal ) => {
184
+ const finished = goal.features.finished.length;
185
+ if (finished > FEATURES_SHOWN) (omitted.finished ??= []).push({ goal: goal.goal, total: finished, more: finished - FEATURES_SHOWN });
186
+ if (goal.nudges.length <= BRIEF_LIST_SHOWN) return goal;
187
+ (omitted.nudges ??= []).push({ goal: goal.goal, total: goal.nudges.length, more: goal.nudges.length - BRIEF_LIST_SHOWN });
188
+ return { ...goal, nudges: goal.nudges.slice(0, BRIEF_LIST_SHOWN) };
189
+ });
190
+ return { ...result, intents, goals, ...(Object.keys(omitted).length ? { omitted } : {}) };
191
+ }
192
+
163
193
  /**
164
194
  * IC-007.
165
195
  * @param {Object} options
@@ -313,4 +343,4 @@ function activeFeature({ root, cwd, now = new Date(), fsImpl = nodeFs } ) {
313
343
  return derived.ok ? { ...derived, active } : { ...result, slug: active.slug, message: `${active.slug} is tracked by its run only; \`lifecycle --action init\` to track it.` };
314
344
  }
315
345
 
316
- module.exports = { buildOverview, initFeature, featureStatus, activeFeature, goalView, goalConflicts, goalConflictNext, DISCOVERY_SHOWN, MAINTAIN_SHOWN, FEATURES_SHOWN };
346
+ module.exports = { buildOverview, capOverviewBrief, initFeature, featureStatus, activeFeature, goalView, goalConflicts, goalConflictNext, DISCOVERY_SHOWN, MAINTAIN_SHOWN, FEATURES_SHOWN };