@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.
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/session-end.sh +21 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
- package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +25 -1
- package/core/registry/contribution-points.json +1 -1
- package/core/registry/intake.json +3 -3
- package/core/registry/lifecycle.json +256 -5
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/settings.json +12 -12
- package/core/registry/storage.json +1 -2
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
- package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
- package/core/shared/scripts/doflow/bin/doflow-run +18 -0
- package/core/shared/skills/do-design/SKILL.md +6 -2
- package/core/shared/skills/do-design/references/data_model_example.md +167 -0
- package/core/shared/templates/doflow/data-model-template.md +136 -102
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +54 -12
- package/src/adapters/codex/agents.js +15 -1
- package/src/adapters/codex/config.js +5 -2
- package/src/adapters/copilot/index.js +11 -4
- package/src/adapters/copy-tree.js +7 -0
- package/src/adapters/created-file.js +41 -0
- package/src/adapters/mcp-entries.js +11 -1
- package/src/adapters/opencode/index.js +32 -5
- package/src/adapters/pi/index.js +15 -4
- package/src/adapters/pi/mcp.js +44 -18
- package/src/cli/commands/remove.js +3 -1
- package/src/cli/commands/update.js +7 -3
- package/src/cli/index.js +24 -12
- package/src/cli/runtime-commands.js +1 -1
- package/src/cli/shared.js +5 -3
- package/src/helper/marker-merge.js +11 -7
- package/src/install/backup-set.js +7 -3
- package/src/install/backup.js +13 -7
- package/src/install/context.js +2 -1
- package/src/install/manifest.js +35 -11
- package/src/install/paths.js +38 -0
- package/src/install/targets.js +11 -2
- package/src/lifecycle/index.js +80 -3
- package/src/lifecycle/retired-harnesses.js +16 -1
- package/src/lifecycle/view.js +3 -2
- package/src/registry/index.js +21 -4
- package/src/release/docs-blocks.js +5 -1
- package/src/runtime/advice/doctor.js +2 -2
- package/src/runtime/brief-tools.js +2 -1
- package/src/runtime/capability-router.js +26 -3
- package/src/runtime/context/pointer.js +17 -5
- package/src/runtime/context-pack.js +48 -8
- package/src/runtime/dispatch/integrate.js +27 -8
- package/src/runtime/dispatch/loop.js +7 -0
- package/src/runtime/dispatch/verify-run.js +9 -3
- package/src/runtime/feature-pointer.js +10 -1
- package/src/runtime/health.js +9 -4
- package/src/runtime/implementation-gate.js +10 -2
- package/src/runtime/intake/engine.js +11 -9
- package/src/runtime/knowledge/code-retrieval.js +11 -5
- package/src/runtime/knowledge/semble-provider.js +12 -2
- package/src/runtime/lifecycle/cli.js +7 -5
- package/src/runtime/lifecycle/overview.js +32 -2
- package/src/runtime/lifecycle/release.js +16 -3
- package/src/runtime/lifecycle/retention.js +12 -15
- package/src/runtime/lifecycle/store-upkeep.js +13 -3
- package/src/runtime/mask.js +3 -3
- package/src/runtime/mcp-consultations.js +11 -5
- package/src/runtime/research-request.js +3 -1
- package/src/runtime/review/command.js +3 -7
- package/src/runtime/review/start.js +24 -10
- package/src/runtime/roadmap/loop.js +14 -3
- package/src/runtime/roadmap/rows.js +2 -1
- package/src/runtime/settings/files.js +3 -1
- package/src/runtime/settings/prepared-run.js +12 -2
- package/src/runtime/settings/resolve.js +13 -1
- package/src/runtime/settings/write.js +73 -2
- package/src/runtime/storage/registry.js +2 -2
- package/src/runtime/storage/roots.js +4 -18
- package/src/runtime/storage/rules.js +22 -46
- package/src/runtime/storage/work-status.js +14 -4
- package/src/runtime/task-scope.js +17 -2
- package/src/runtime/task-state.js +11 -4
- package/src/runtime/trace/analyze.js +4 -2
- package/src/runtime/trace/indicators.js +25 -20
- package/src/runtime/trace/spans.js +4 -5
- package/src/runtime/verb-view.js +6 -5
- package/src/runtime/worker/command.js +4 -2
- package/src/runtime/worker/readiness.js +8 -4
- package/src/runtime/worker/supervisor.js +18 -6
- 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
|
|
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(
|
|
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(
|
|
1297
|
-
const flatPlan = path.join(
|
|
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
|
|
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).
|
|
21
|
-
// (
|
|
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
|
|
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
|
|
28
|
-
*
|
|
29
|
-
|
|
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')
|
|
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 };
|
package/src/runtime/health.js
CHANGED
|
@@ -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(
|
|
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() ||
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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`;
|
|
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
|
|
391
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
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 };
|