@khoavu882/doflow 2.0.0 → 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/README.md +4 -5
- package/bin/doflow.js +1 -1
- 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/mcp-tool-guard.sh +4 -1
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/claude/hooks/session-end.sh +21 -1
- package/core/harnesses/claude/settings/settings.json +8 -9
- package/core/harnesses/codex/hooks/hooks.json +0 -10
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
- package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
- package/core/harnesses/copilot/hooks/doflow.json +0 -6
- package/core/harnesses/kiro/agents/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
- package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
- package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
- package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
- package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
- package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
- package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
- package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
- package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +26 -2
- package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
- package/core/registry/contracts.json +1 -1
- package/core/registry/contribution-points.json +5 -2
- package/core/registry/gates.json +5 -5
- package/core/registry/harnesses.json +9 -9
- package/core/registry/intake.json +53 -0
- package/core/registry/lifecycle.json +176 -503
- package/core/registry/package-templates/intake-category/files/category.json +8 -0
- package/core/registry/package-templates/intake-category/template.json +4 -0
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/rules.json +8 -8
- package/core/registry/settings.json +12 -16
- package/core/registry/storage.json +9 -2
- package/core/registry/telemetry.json +1 -1
- package/core/registry/verification.json +0 -5
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
- package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
- package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
- package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
- package/core/shared/guidance/rules/RULES.md +2 -2
- 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/bash/validate-artifacts.sh +1 -1
- package/core/shared/scripts/doflow/bin/doflow-run +23 -2
- package/core/shared/skills/do/SKILL.md +3 -3
- package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
- package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
- package/core/shared/skills/do-code-review/SKILL.md +7 -4
- package/core/shared/skills/do-design/SKILL.md +11 -5
- package/core/shared/skills/do-design/references/data_model_example.md +167 -0
- package/core/shared/skills/do-diagnose/SKILL.md +4 -2
- package/core/shared/skills/do-document/SKILL.md +1 -0
- package/core/shared/skills/do-document/references/embedded-research.md +1 -1
- package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
- package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
- package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
- package/core/shared/skills/do-flow/SKILL.md +4 -4
- package/core/shared/skills/do-flow/references/task_classes.md +5 -5
- package/core/shared/skills/do-implement/SKILL.md +9 -7
- package/core/shared/skills/do-plan/SKILL.md +9 -6
- package/core/shared/templates/doflow/data-model-template.md +136 -102
- package/core/shared/workflows/do-flow-chain.md +2 -0
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +54 -12
- package/src/adapters/claude/index.js +23 -2
- package/src/adapters/codex/agents.js +15 -1
- package/src/adapters/codex/config.js +5 -2
- package/src/adapters/codex/hooks.js +39 -3
- package/src/adapters/codex/index.js +13 -0
- package/src/adapters/copilot/index.js +11 -4
- package/src/adapters/copy-tree.js +8 -2
- 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/status.js +1 -1
- package/src/cli/commands/update.js +7 -3
- package/src/cli/index.js +43 -19
- package/src/cli/runtime-commands.js +13 -5
- 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/coverage.js +1 -30
- package/src/lifecycle/hook-tier.js +2 -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 +37 -53
- 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 +84 -17
- package/src/runtime/context-pack.js +52 -9
- 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/evidence-ledger.js +18 -0
- package/src/runtime/feature-pointer.js +10 -1
- package/src/runtime/health.js +68 -5
- package/src/runtime/implementation-gate.js +14 -6
- package/src/runtime/intake/engine.js +679 -0
- package/src/runtime/intake/index.js +136 -0
- package/src/runtime/intake/manifest.js +140 -0
- package/src/runtime/intake/taxonomy.js +206 -0
- package/src/runtime/knowledge/code-retrieval.js +11 -5
- package/src/runtime/knowledge/semble-provider.js +12 -2
- package/src/runtime/lifecycle/cli.js +76 -6
- package/src/runtime/lifecycle/followup.js +32 -6
- package/src/runtime/lifecycle/overview.js +34 -3
- 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/packages/points.js +10 -0
- 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/attendance.js +137 -0
- package/src/runtime/roadmap/gates.js +6 -3
- package/src/runtime/roadmap/loop.js +127 -33
- package/src/runtime/roadmap/merge.js +17 -8
- package/src/runtime/roadmap/rows.js +15 -5
- package/src/runtime/roadmap/seed.js +153 -41
- package/src/runtime/roadmap/worktree-cleanup.js +147 -0
- package/src/runtime/settings/files.js +3 -1
- package/src/runtime/settings/prepared-run.js +12 -2
- package/src/runtime/settings/registry.js +1 -1
- package/src/runtime/settings/resolve.js +13 -1
- package/src/runtime/settings/write.js +73 -2
- package/src/runtime/slots.js +1 -1
- 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-classifier.js +39 -4
- package/src/runtime/task-scope.js +17 -2
- package/src/runtime/task-state.js +13 -7
- 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 +268 -0
- package/src/runtime/verification/engine.js +0 -45
- package/src/runtime/worker/command.js +4 -2
- package/src/runtime/worker/launch.js +5 -37
- package/src/runtime/worker/readiness.js +10 -6
- package/src/runtime/worker/supervisor.js +18 -6
- package/src/runtime/workflow-orchestrator.js +39 -7
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
- package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -579
|
@@ -189,10 +189,74 @@ function writeLayer({ file, set = {}, unset = [], scrubKeys = [], force = false,
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Where a write to `file` lands. A symlinked settings file (a dotfile manager's link) is written
|
|
194
|
+
* through to its target so the link survives; a project or local file whose link leads outside the
|
|
195
|
+
* project, or leads nowhere, is refused rather than read through or copied.
|
|
196
|
+
*/
|
|
197
|
+
function writeTarget(file , fsImpl ) {
|
|
198
|
+
let stat ;
|
|
199
|
+
try { stat = fsImpl.lstatSync(file); } catch { return { ok: true, target: file }; }
|
|
200
|
+
if (!stat.isSymbolicLink()) return { ok: true, target: file };
|
|
201
|
+
let real ;
|
|
202
|
+
try { real = fsImpl.realpathSync(file); } catch {
|
|
203
|
+
return { ok: false, result: invalid(file, 'is a symbolic link that does not resolve to a file; nothing was written', 'scope') };
|
|
204
|
+
}
|
|
205
|
+
const directory = path.dirname(file);
|
|
206
|
+
if (path.basename(directory) === '.doflow') {
|
|
207
|
+
try {
|
|
208
|
+
const rel = path.relative(fsImpl.realpathSync(path.dirname(directory)), real);
|
|
209
|
+
if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) throw new Error('outside');
|
|
210
|
+
} catch {
|
|
211
|
+
return { ok: false, result: invalid(file, 'is a symbolic link to a path outside the project; nothing was written', 'scope') };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, target: real };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The most significant digits a JSON number keeps exactly when it is read as a double and written back. */
|
|
218
|
+
const EXACT_DIGITS = 15;
|
|
219
|
+
const NUMBER_AT = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The first top-level key of `text` (a JSON document) holding a number that `JSON.parse` followed by
|
|
223
|
+
* `JSON.stringify` would change in value: more than 15 digits and not a safe integer.
|
|
224
|
+
*/
|
|
225
|
+
function inexactNumberKey(text ) {
|
|
226
|
+
let depth = 0;
|
|
227
|
+
let current = null;
|
|
228
|
+
for (let i = 0; i < text.length;) {
|
|
229
|
+
const ch = text[i];
|
|
230
|
+
if (ch === '"') {
|
|
231
|
+
let end = i + 1;
|
|
232
|
+
while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1;
|
|
233
|
+
if (depth === 1) {
|
|
234
|
+
let after = end + 1;
|
|
235
|
+
while (after < text.length && /\s/.test(text[after])) after++;
|
|
236
|
+
if (text[after] === ':') { try { current = JSON.parse(text.slice(i, end + 1)); } catch { current = null; } }
|
|
237
|
+
}
|
|
238
|
+
i = end + 1;
|
|
239
|
+
} else if (ch === '{' || ch === '[') { depth++; i++; }
|
|
240
|
+
else if (ch === '}' || ch === ']') { depth--; i++; }
|
|
241
|
+
else if (ch === '-' || (ch >= '0' && ch <= '9')) {
|
|
242
|
+
NUMBER_AT.lastIndex = i;
|
|
243
|
+
const match = NUMBER_AT.exec(text);
|
|
244
|
+
if (!match) { i++; continue; }
|
|
245
|
+
const token = match[0];
|
|
246
|
+
const digits = token.replace(/[eE].*$/, '').replace(/[-.]/g, '').replace(/^0+/, '').replace(/0+$/, '');
|
|
247
|
+
if (digits.length > EXACT_DIGITS && !(/^-?\d+$/.test(token) && Number.isSafeInteger(Number(token))) && current !== null) return current;
|
|
248
|
+
i += token.length;
|
|
249
|
+
} else i++;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
192
254
|
const INVALID = Symbol('invalid layer');
|
|
193
255
|
|
|
194
256
|
/** The read, merge and rename of `writeLayer`, run while it holds the layer's lock. */
|
|
195
257
|
function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect, backup, fsImpl } ) {
|
|
258
|
+
const landing = writeTarget(file, fsImpl);
|
|
259
|
+
if (!landing.ok) return landing.result;
|
|
196
260
|
let before = null;
|
|
197
261
|
let existing = {};
|
|
198
262
|
let layer ;
|
|
@@ -231,6 +295,13 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
|
|
|
231
295
|
for (const [key, value] of Object.entries(set)) values[key] = value;
|
|
232
296
|
for (const key of unsetKeys) delete values[key];
|
|
233
297
|
|
|
298
|
+
if (layer?.status === 'ok' && typeof layer.source === 'string') {
|
|
299
|
+
const kept = inexactNumberKey(layer.source);
|
|
300
|
+
if (kept !== null && Object.hasOwn(values, kept) && !Object.hasOwn(set, kept)) {
|
|
301
|
+
return invalid(file, `key '${kept}' holds a number with more digits than JSON can rewrite exactly; nothing was written; edit the file by hand`, 'number-precision');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
234
305
|
const after = layerDocument(registry, values);
|
|
235
306
|
const bytes = serializeLayer(after);
|
|
236
307
|
const tooLarge = oversize(file, bytes);
|
|
@@ -244,7 +315,7 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
|
|
|
244
315
|
const directory = path.dirname(file);
|
|
245
316
|
let temporary = null;
|
|
246
317
|
try {
|
|
247
|
-
temporary = path.join(
|
|
318
|
+
temporary = path.join(path.dirname(landing.target), `.${path.basename(landing.target)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
|
|
248
319
|
fsImpl.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
|
|
249
320
|
fsImpl.chmodSync(temporary, 0o600);
|
|
250
321
|
if (path.basename(directory) === '.doflow') {
|
|
@@ -253,7 +324,7 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
|
|
|
253
324
|
return invalid(file, '.doflow symlink points outside the project', 'scope');
|
|
254
325
|
}
|
|
255
326
|
}
|
|
256
|
-
fsImpl.renameSync(temporary,
|
|
327
|
+
fsImpl.renameSync(temporary, landing.target);
|
|
257
328
|
temporary = null;
|
|
258
329
|
clearSettingsCache();
|
|
259
330
|
return { ok: true, file, before, after };
|
package/src/runtime/slots.js
CHANGED
|
@@ -185,7 +185,7 @@ const refuse = (why ) => Object.assign(new Error(why), { code: 'EBUSY' })
|
|
|
185
185
|
* directory name this lock does not make once the lock is 10 s old, as in task-state. An older
|
|
186
186
|
* runtime's lock (its `owner.json` token) is judged the same way. `reclaimed` records that this acquire
|
|
187
187
|
* judged a lock reclaimable, so it holds only after `quiet` (every reclaim quiets; user ruling, 117
|
|
188
|
-
* decisions: amends 115 C-4's "dead owner reclaimed at once").
|
|
188
|
+
* decisions: amends 115 C-4's "dead owner reclaimed at once").
|
|
189
189
|
*/
|
|
190
190
|
function slotLock(fsImpl , deps ) {
|
|
191
191
|
const start = deps.procStart(deps.pid);
|
|
@@ -13,7 +13,7 @@ const RULES = new Map([
|
|
|
13
13
|
['date-partition', ['maxAgeDays']],
|
|
14
14
|
['recovery', ['keepNewest']],
|
|
15
15
|
['lifecycle-settled', ['defaultHours']],
|
|
16
|
-
['report-orphan', ['orphanGraceHours'
|
|
16
|
+
['report-orphan', ['orphanGraceHours']],
|
|
17
17
|
['age', ['maxAgeDays']],
|
|
18
18
|
['existing', []],
|
|
19
19
|
['control', []],
|
|
@@ -132,7 +132,7 @@ function formatBound(store ) {
|
|
|
132
132
|
case 'date-partition': return `prune partitions older than ${bound.maxAgeDays} days`;
|
|
133
133
|
case 'recovery': return `keep newest ${bound.keepNewest}, plus the snapshot named by the ledger`;
|
|
134
134
|
case 'lifecycle-settled': return `retain settled events for ${bound.defaultHours} hours`;
|
|
135
|
-
case 'report-orphan': return `prune orphan bodies after ${bound.orphanGraceHours} hours; foreign folders
|
|
135
|
+
case 'report-orphan': return `prune orphan bodies after ${bound.orphanGraceHours} hours; foreign folders are kept`;
|
|
136
136
|
case 'age': return `prune inactive directories after ${bound.maxAgeDays} days`;
|
|
137
137
|
case 'existing':
|
|
138
138
|
case 'control': return bound.text;
|
|
@@ -31,16 +31,9 @@ function createPruneSettingsResolver({ roots, fsImpl = fs, onView } = {})
|
|
|
31
31
|
if (!loadedView) {
|
|
32
32
|
loadedView = true;
|
|
33
33
|
try {
|
|
34
|
-
|
|
35
|
-
if (fsImpl !== fs) {
|
|
36
|
-
const { loadSettingsRegistry } = require('../settings/registry.js');
|
|
37
|
-
const loaded = loadSettingsRegistry({ fsImpl });
|
|
38
|
-
if (!loaded.ok) throw new Error(loaded.detail);
|
|
39
|
-
settingsRegistry = loaded.registry;
|
|
40
|
-
}
|
|
41
|
-
const { loadSettings } = require('../settings/resolve.js');
|
|
34
|
+
const { loadSettings, registryOn } = require('../settings/resolve.js');
|
|
42
35
|
const projectRoot = roots['project-doflow'] ? path.dirname(roots['project-doflow']) : null;
|
|
43
|
-
view = loadSettings({ projectRoot, env: resolverEnv, fsImpl, ...(
|
|
36
|
+
view = loadSettings({ projectRoot, env: resolverEnv, fsImpl, ...registryOn(fsImpl) });
|
|
44
37
|
} catch {
|
|
45
38
|
// A settings registry fault fails closed for every root, as it did for each old call.
|
|
46
39
|
view = null;
|
|
@@ -69,15 +62,8 @@ function readPruneSetting({ roots, rootId, fsImpl = fs } = {})
|
|
|
69
62
|
const resolverEnv = {};
|
|
70
63
|
if (roots['config-home']) resolverEnv.XDG_CONFIG_HOME = path.dirname(roots['config-home']);
|
|
71
64
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const { loadSettingsRegistry } = require('../settings/registry.js');
|
|
75
|
-
const loaded = loadSettingsRegistry({ fsImpl });
|
|
76
|
-
if (!loaded.ok) throw new Error(loaded.detail);
|
|
77
|
-
settingsRegistry = loaded.registry;
|
|
78
|
-
}
|
|
79
|
-
const { loadSettings } = require('../settings/resolve.js');
|
|
80
|
-
const view = loadSettings({ projectRoot, global, env: resolverEnv, fsImpl, ...(settingsRegistry ? { registry: settingsRegistry } : {}) });
|
|
65
|
+
const { loadSettings, registryOn } = require('../settings/resolve.js');
|
|
66
|
+
const view = loadSettings({ projectRoot, global, env: resolverEnv, fsImpl, ...registryOn(fsImpl) });
|
|
81
67
|
const resolved = view.get('storage.prune', global ? 'global' : 'project');
|
|
82
68
|
return {
|
|
83
69
|
setting: resolved.value === 'on' ? 'on' : 'off',
|
|
@@ -232,16 +232,15 @@ function lifecyclePathsSafe(project , fsImpl ) {
|
|
|
232
232
|
}
|
|
233
233
|
return true;
|
|
234
234
|
}
|
|
235
|
-
function definitelyMissing(fsImpl , target ) {
|
|
236
|
-
try { fsImpl.lstatSync(target); return false; }
|
|
237
|
-
catch (error ) { return error.code === 'ENOENT' || error.code === 'ENOTDIR'; }
|
|
238
|
-
}
|
|
239
235
|
function reportCandidates(store , entries , context ) {
|
|
240
236
|
const { fsImpl, storeDir, roots, now, settings, shouldStop } = context;
|
|
241
237
|
const project = projectRoot(roots);
|
|
242
238
|
const key = project && projectKey(project, fsImpl), selected = [];
|
|
243
239
|
for (const entry of entries) {
|
|
244
240
|
if (shouldStop?.()) throw budgetError();
|
|
241
|
+
// A folder under another project's key stays: ENOENT or ENOTDIR on its recorded root never
|
|
242
|
+
// authorises deleting it, since an unmounted or renamed root looks the same as a gone one.
|
|
243
|
+
if (entry.name !== key) continue;
|
|
245
244
|
const folder = path.join(storeDir, entry.name);
|
|
246
245
|
let children, bodyNames = [];
|
|
247
246
|
try {
|
|
@@ -251,50 +250,27 @@ function reportCandidates(store , entries , context ) {
|
|
|
251
250
|
if (BODY.test(name) && safeStat(fsImpl, path.join(folder, name))?.isFile()) bodyNames.push(name);
|
|
252
251
|
}
|
|
253
252
|
} catch (error ) { if (error.partial) throw error; continue; }
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
for (const id of ids) {
|
|
267
|
-
if (shouldStop?.()) throw budgetError();
|
|
268
|
-
if (typeof id === 'string' && /^FU-[0-9a-hjkmnp-tv-z]{6}$/.test(id)) eventIds.add(id);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
for (const body of bodyNames) {
|
|
272
|
-
if (shouldStop?.()) throw budgetError();
|
|
273
|
-
const stat = safeStat(fsImpl, path.join(folder, body));
|
|
274
|
-
if (stat && !eventIds.has(body.slice(0, -4)) && now - stat.mtimeMs > settings.orphanGraceHours * HOUR)
|
|
275
|
-
selected.push({ name: `${entry.name}/${body}`, reason: 'orphan-body', bytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
276
|
-
}
|
|
277
|
-
} else {
|
|
278
|
-
const marker = path.join(folder, '.root');
|
|
279
|
-
if (!safeStat(fsImpl, marker)?.isFile() || !bodyNames.length) continue;
|
|
280
|
-
let validFolder = true;
|
|
281
|
-
for (const name of children) {
|
|
253
|
+
const lifecycle = path.join(project, '.doflow/state/lifecycle');
|
|
254
|
+
if (!safeStat(fsImpl, path.join(lifecycle, 'migrated.json'))?.isFile() || !lifecyclePathsSafe(project, fsImpl)) continue;
|
|
255
|
+
let snapshot;
|
|
256
|
+
try { snapshot = readEvents(project, { fsImpl, lockFallback: false, shouldStop }); }
|
|
257
|
+
catch { continue; }
|
|
258
|
+
if (snapshot.incomplete) throw budgetError();
|
|
259
|
+
if (snapshot.unreadable.length) continue;
|
|
260
|
+
const eventIds = new Set();
|
|
261
|
+
for (const event of snapshot.events) {
|
|
262
|
+
if (shouldStop?.()) throw budgetError();
|
|
263
|
+
const ids = [event.data.id, ...(Array.isArray(event.data.ids) ? event.data.ids : [])];
|
|
264
|
+
for (const id of ids) {
|
|
282
265
|
if (shouldStop?.()) throw budgetError();
|
|
283
|
-
if (
|
|
266
|
+
if (typeof id === 'string' && /^FU-[0-9a-hjkmnp-tv-z]{6}$/.test(id)) eventIds.add(id);
|
|
284
267
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (shouldStop?.()) throw budgetError();
|
|
292
|
-
const stat = safeStat(fsImpl, path.join(folder, body));
|
|
293
|
-
if (!stat) { newest = Infinity; break; }
|
|
294
|
-
newest = Math.max(newest, stat.mtimeMs);
|
|
295
|
-
}
|
|
296
|
-
if (now - newest > store.bound.foreignMaxAgeDays * DAY)
|
|
297
|
-
selected.push({ name: entry.name, reason: 'foreign-root-gone', bytes: entry.stat.size, mtimeMs: newest });
|
|
268
|
+
}
|
|
269
|
+
for (const body of bodyNames) {
|
|
270
|
+
if (shouldStop?.()) throw budgetError();
|
|
271
|
+
const stat = safeStat(fsImpl, path.join(folder, body));
|
|
272
|
+
if (stat && !eventIds.has(body.slice(0, -4)) && now - stat.mtimeMs > settings.orphanGraceHours * HOUR)
|
|
273
|
+
selected.push({ name: `${entry.name}/${body}`, reason: 'orphan-body', bytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
298
274
|
}
|
|
299
275
|
}
|
|
300
276
|
return selected;
|
|
@@ -81,6 +81,17 @@ function isRunNamespaceDir(name ) {
|
|
|
81
81
|
return safeSegment(name) && !name.endsWith('.lock');
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether a flat run is superseded by a namespaced copy: it names that copy's namespace as its own
|
|
86
|
+
* feature (`featureSlug`, else `slug`) and the copy has the same file name. A flat run that names
|
|
87
|
+
* no feature, or one whose namespace holds no copy, is a run of its own and is never dropped. The
|
|
88
|
+
* one ownership rule for every reader that walks `orchestration/` (health, trace, work status).
|
|
89
|
+
*/
|
|
90
|
+
function isSupersededFlatRun(name , value , copies ) {
|
|
91
|
+
const own = stringField(value, 'featureSlug') || stringField(value, 'slug');
|
|
92
|
+
return own !== null && isRunNamespaceDir(own) && copies.some((copy) => copy.namespace === own && copy.name === name);
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
/**
|
|
85
96
|
* Every run id the orchestration store holds, flat or under a `<slug>/` namespace (115 C-1: a task id
|
|
86
97
|
* other than its feature's slug lives in `orchestration/<slug>/`). `cache.runLocations` maps each id
|
|
@@ -136,9 +147,8 @@ function readRunAt(cache , runId , where ) {
|
|
|
136
147
|
const file = path.join(stateRoot(cache.projectRoot), 'orchestration', ...(where ? [where] : []), `${runId}.json`);
|
|
137
148
|
let result ;
|
|
138
149
|
try {
|
|
139
|
-
// A
|
|
140
|
-
|
|
141
|
-
const text = where && typeof cache.fsImpl.lstatSync === 'function' && typeof cache.fsImpl.openSync === 'function'
|
|
150
|
+
// A link, a FIFO or a device there reads as unreadable (the record is kept) and never blocks.
|
|
151
|
+
const text = typeof cache.fsImpl.lstatSync === 'function' && typeof cache.fsImpl.openSync === 'function'
|
|
142
152
|
? require('../task-scope.js').readRegularFileText(file, cache.fsImpl) : cache.fsImpl.readFileSync(file, 'utf8');
|
|
143
153
|
if (text === null) throw new Error('not a regular file');
|
|
144
154
|
const value = JSON.parse(text);
|
|
@@ -542,4 +552,4 @@ function workStatus({
|
|
|
542
552
|
return answer('finished', run ? 'run-finished' : 'no-run', resolvedRun.id, feature, since);
|
|
543
553
|
}
|
|
544
554
|
|
|
545
|
-
module.exports = { workStatus, recordExtension, createWorkCache, refreshedWorkCache, RUN_OPEN_STATES };
|
|
555
|
+
module.exports = { workStatus, recordExtension, createWorkCache, refreshedWorkCache, RUN_OPEN_STATES, isRunNamespaceDir, isSupersededFlatRun };
|
|
@@ -6,7 +6,8 @@ const path = require('node:path');
|
|
|
6
6
|
const fs = require('node:fs');
|
|
7
7
|
const { TextDecoder } = require('node:util');
|
|
8
8
|
const { WorkflowEngine, CALLER_ROLES } = require('./workflow-engine.js');
|
|
9
|
-
const { finishRuntime } = require('./cli-result.js');
|
|
9
|
+
const { finishRuntime, usageError } = require('./cli-result.js');
|
|
10
|
+
const { ViewUsageError, resolveView, projectBrief, viewLines, withEnvelope, emitJson } = require('./verb-view.js');
|
|
10
11
|
const { REPO_ROOT } = require('../helper/repo-root.js');
|
|
11
12
|
const { loadAdviceRegistry } = require('./advice/registry.js');
|
|
12
13
|
const { createAdviceClient } = require('./advice/client.js');
|
|
@@ -421,6 +422,25 @@ class TaskClassifier {
|
|
|
421
422
|
}
|
|
422
423
|
}
|
|
423
424
|
|
|
425
|
+
/**
|
|
426
|
+
* The brief view of a classify answer (feature 137, IC-005): the fields the skills' classify call sites read.
|
|
427
|
+
* Everything else is dropped; `--view full` returns the whole object.
|
|
428
|
+
*/
|
|
429
|
+
const CLASSIFY_BRIEF = Object.freeze({
|
|
430
|
+
verb: 'classify',
|
|
431
|
+
carry: Object.freeze(['taskClass', 'callingSkill']),
|
|
432
|
+
keep: Object.freeze([
|
|
433
|
+
'$.outcome', '$.taskClass', '$.proposedClass', '$.callingSkill', '$.reason', '$.message', '$.validClasses',
|
|
434
|
+
'$.suggestions', '$.callerSuggestions', '$.fit.state', '$.fit.reason', '$.fit.hostingClasses', '$.secondOpinion',
|
|
435
|
+
'$.workflow.taskClass', '$.workflow.handoff', '$.workflow.stageIds', '$.workflow.hasImplementationStage',
|
|
436
|
+
'$.workflow.requiresImplementationReadiness', '$.workflow.stages[].id', '$.workflow.stages[].skill',
|
|
437
|
+
'$.workflow.stages[].agent', '$.workflow.stages[].kind', '$.workflow.stages[].optional',
|
|
438
|
+
'$.workflow.stages[].readinessTemplate', '$.workflow.stages[].mutatesSource', '$.workflow.gates[].id',
|
|
439
|
+
'$.workflow.gates[].afterStage', '$.workflow.gates[].kind', '$.workflow.gates[].trigger',
|
|
440
|
+
'$.workflow.gates[].prompt',
|
|
441
|
+
]),
|
|
442
|
+
});
|
|
443
|
+
|
|
424
444
|
/**
|
|
425
445
|
* Handles `doflow classify` — validate a proposed task class against the workflow registry.
|
|
426
446
|
*
|
|
@@ -437,16 +457,31 @@ class TaskClassifier {
|
|
|
437
457
|
* @param {string} [options.request] request text for a second opinion
|
|
438
458
|
* @param {string} [options.requestFile] file containing request text for a second opinion
|
|
439
459
|
* @param {boolean} [options.json=false]
|
|
460
|
+
* @param {string} [options.view] `brief` or `full` (default): the JSON answer's view; text output is the same in both
|
|
440
461
|
* @returns {number|Promise<number>} exit code; advisory calls are asynchronous
|
|
441
462
|
*/
|
|
442
|
-
function handleClassifyCommand({ taskClass, rationale, proposedBy, callingSkill, request, requestFile, taskId, projectRoot, env = process.env, json = false } = {}) {
|
|
463
|
+
function handleClassifyCommand({ taskClass, rationale, proposedBy, callingSkill, request, requestFile, taskId, projectRoot, view, env = process.env, json = false } = {}) {
|
|
464
|
+
let resolvedView;
|
|
465
|
+
try {
|
|
466
|
+
resolvedView = resolveView(view, 'full');
|
|
467
|
+
} catch (error ) {
|
|
468
|
+
if (error instanceof ViewUsageError) return usageError('classify', error.message, json);
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
const printJson = (opinion ) => {
|
|
472
|
+
const answer = { ...decision, secondOpinion: opinion };
|
|
473
|
+
if (resolvedView === 'full') return emitJson(answer);
|
|
474
|
+
return emitJson(withEnvelope(projectBrief(answer, CLASSIFY_BRIEF.keep), {
|
|
475
|
+
view: 'brief', next: viewLines('full', { spec: CLASSIFY_BRIEF, carried: { taskClass, callingSkill }, json: true }),
|
|
476
|
+
}));
|
|
477
|
+
};
|
|
443
478
|
const classifier = new TaskClassifier({ repoRoot: REPO_ROOT });
|
|
444
479
|
const decision = classifier.classify({ taskClass, rationale, proposedBy, callingSkill });
|
|
445
480
|
const exitCode = decision.outcome === CLASSIFICATION_OUTCOMES.ACCEPTED
|
|
446
481
|
? 0 : decision.reason === REJECTION_REASONS.MISSING_CLASS ? 2 : 1;
|
|
447
482
|
const hasRequest = request !== undefined || requestFile !== undefined;
|
|
448
483
|
if (!hasRequest) {
|
|
449
|
-
if (json)
|
|
484
|
+
if (json) printJson(null);
|
|
450
485
|
else printClassifyDecision(decision, null);
|
|
451
486
|
return finishRuntime(exitCode);
|
|
452
487
|
}
|
|
@@ -528,7 +563,7 @@ function handleClassifyCommand({ taskClass, rationale, proposedBy, callingSkill,
|
|
|
528
563
|
opinion = emptySecondOpinion('unavailable', 'config-invalid');
|
|
529
564
|
}
|
|
530
565
|
}
|
|
531
|
-
if (json)
|
|
566
|
+
if (json) printJson(opinion);
|
|
532
567
|
else printClassifyDecision(decision, opinion);
|
|
533
568
|
return finishRuntime(exitCode);
|
|
534
569
|
}
|
|
@@ -176,9 +176,23 @@ function readRegularFileText(file , fsImpl = fs) {
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/** Whether `slug` is a register feature (it has `decisions/register.json`), for a record file under
|
|
180
|
+
* a checkout's `.doflow/state`. Such a feature never reads another feature's identity-less flat
|
|
181
|
+
* record (045 isolation). A file whose checkout cannot be told, or a feature that does not resolve,
|
|
182
|
+
* has no register. */
|
|
183
|
+
function featureHasRegister(file , slug ) {
|
|
184
|
+
const marker = `${path.sep}.doflow${path.sep}state${path.sep}`;
|
|
185
|
+
const at = file.lastIndexOf(marker);
|
|
186
|
+
if (at <= 0) return false;
|
|
187
|
+
try {
|
|
188
|
+
const found = resolveFeature({ projectRoot: file.slice(0, at), slug });
|
|
189
|
+
return !found.error && Boolean(found.featureDir) && fs.existsSync(path.join(found.featureDir, 'decisions', 'register.json'));
|
|
190
|
+
} catch { return false; }
|
|
191
|
+
}
|
|
192
|
+
|
|
179
193
|
/**
|
|
180
194
|
* Whether a flat record may be read as `slug`'s (D-3): it names that feature (`featureSlug` on a
|
|
181
|
-
* run, `slug` on a readiness record) or names none. A file that does not parse names nobody, so a
|
|
195
|
+
* run, `slug` on a readiness record), or names none and `slug` has no decision register. A file that does not parse names nobody, so a
|
|
182
196
|
* namespaced read never falls back to it. The probe never blocks and follows no link: a flat file
|
|
183
197
|
* that is not a regular file of at most `MAX_RECORD_FILE_BYTES` is not read; `unsafeIsOurs` is then
|
|
184
198
|
* the answer, so a caller whose reader refuses such a file (the run store's `readRunRecord`) is
|
|
@@ -198,7 +212,8 @@ function flatRecordIsOurs(file , slug , fsImpl = fs, u
|
|
|
198
212
|
data = JSON.parse(text);
|
|
199
213
|
} catch { return false; }
|
|
200
214
|
const named = [data?.featureSlug, data?.slug].find((value) => typeof value === 'string' && value !== '');
|
|
201
|
-
|
|
215
|
+
if (named === undefined) return !slug || !featureHasRegister(file, slug);
|
|
216
|
+
return named === slug;
|
|
202
217
|
}
|
|
203
218
|
|
|
204
219
|
/**
|
|
@@ -103,8 +103,7 @@ function sleep(ms ) {
|
|
|
103
103
|
// Runtimes from before this protocol see a directory they did not create: they wait on a fresh one,
|
|
104
104
|
// and on a stale one their rmdir fails (it is not empty), so they fail closed ("Could not lock",
|
|
105
105
|
// `store-locked`) instead of taking it; their release of a lock reclaimed from them fails the same
|
|
106
|
-
// way. Whatever a process leaves when it dies is reclaimed once LOCK_STALE_MS old.
|
|
107
|
-
// this does not cover) are listed once, in docs/architecture.md under "The task-state lock".
|
|
106
|
+
// way. Whatever a process leaves when it dies is reclaimed once LOCK_STALE_MS old.
|
|
108
107
|
const OWNER_PREFIX = 'owner-';
|
|
109
108
|
// The longest a fresh acquire may take from its mkdir to its entry and still trust the directory is
|
|
110
109
|
// its own: half the stale age, so a process that judged it stale cannot have done so before the
|
|
@@ -307,7 +306,7 @@ function ownedRelease(fsImpl , lockDir , me ) {
|
|
|
307
306
|
try { fsImpl.rmdirSync(entry); } catch { return; } // reclaimed: the lock is not ours to remove
|
|
308
307
|
// Removed only if it is still that directory: a release stalled here past LOCK_STALE_MS may find
|
|
309
308
|
// an older runtime's new (empty) lock at the path. What stays is the gap between this stat and the
|
|
310
|
-
// rmdir
|
|
309
|
+
// rmdir.
|
|
311
310
|
try {
|
|
312
311
|
if (mine && sameDir(mine, fsImpl.statSync(lockDir))) fsImpl.rmdirSync(lockDir);
|
|
313
312
|
} catch { /* a waiting process's entry is in it; that process holds next */ }
|
|
@@ -377,10 +376,14 @@ function acquireLock(fsImpl , file , options = {})
|
|
|
377
376
|
+ strayEntries(fsImpl, lockDir));
|
|
378
377
|
}
|
|
379
378
|
|
|
380
|
-
/**
|
|
379
|
+
/** The non-directory entries in the lock (no DoFlow runtime makes one; each blocks the lock until removed). */
|
|
380
|
+
function strayNames(fsImpl , lockDir ) {
|
|
381
|
+
try { return fsImpl.readdirSync(lockDir, { withFileTypes: true }).filter((d ) => !d.isDirectory()).map((d ) => d.name); } catch { return []; }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** `strayNames` as the sentence the lock timeout carries. */
|
|
381
385
|
function strayEntries(fsImpl , lockDir ) {
|
|
382
|
-
|
|
383
|
-
try { stray = fsImpl.readdirSync(lockDir, { withFileTypes: true }).filter((d ) => !d.isDirectory()).map((d ) => d.name); } catch { return ''; }
|
|
386
|
+
const stray = strayNames(fsImpl, lockDir);
|
|
384
387
|
if (stray.length === 0) return '';
|
|
385
388
|
return ` It holds ${stray.map((name) => `'${name}'`).join(', ')}, which no DoFlow runtime makes and which keeps it locked: `
|
|
386
389
|
+ `remove ${stray.length > 1 ? 'them' : 'it'} once no DoFlow process is running.`;
|
|
@@ -390,7 +393,8 @@ function strayEntries(fsImpl , lockDir ) {
|
|
|
390
393
|
* Takes the cross-process lock for one state file with a bounded wait.
|
|
391
394
|
* Makes 1 try plus `retries` retries (default 3) separated by `waitMs` (default 5); each try is one
|
|
392
395
|
* tryAcquireLock, which waits at most 40 ms, and only on a process that reached the lock at the same moment.
|
|
393
|
-
* Throws an Error('store-locked') after one try plus retries
|
|
396
|
+
* Throws an Error('store-locked') after one try plus retries, carrying `lockPath` (the lock folder) and
|
|
397
|
+
* `strayEntries` (the names in it that no DoFlow runtime makes) as properties.
|
|
394
398
|
* @param {Object} fsImpl
|
|
395
399
|
* @param {string} file
|
|
396
400
|
* @param {Object} [options]
|
|
@@ -421,6 +425,8 @@ function boundedAcquire(
|
|
|
421
425
|
}
|
|
422
426
|
const error = new Error('store-locked');
|
|
423
427
|
(error ).code = 'store-locked';
|
|
428
|
+
(error ).lockPath = `${file}.lock`;
|
|
429
|
+
(error ).strayEntries = strayNames(fsImpl, `${file}.lock`);
|
|
424
430
|
throw error;
|
|
425
431
|
}
|
|
426
432
|
|
|
@@ -115,8 +115,10 @@ function latencyHotspots(scopes , th , latency ) {
|
|
|
115
115
|
const groups = new Map ();
|
|
116
116
|
for (const s of sc.spans) {
|
|
117
117
|
if (!Number.isInteger(s.durationMs) || !ranItself(s)) continue;
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// Every derived `test` span is named 'test'; the command's class tells unrelated shell commands apart.
|
|
119
|
+
const name = s.kind === 'test' && typeof s.attrs?.test_class === 'string' ? s.attrs.test_class : (s.name ?? null);
|
|
120
|
+
const key = `${s.kind}\n${name ?? ''}`;
|
|
121
|
+
if (!groups.has(key)) groups.set(key, { kind: s.kind, name, spans: [] });
|
|
120
122
|
groups.get(key).spans.push(s);
|
|
121
123
|
}
|
|
122
124
|
for (const g of [...groups.values()].sort((a, b) => cmp(a.kind, b.kind) || cmp(a.name ?? '', b.name ?? ''))) {
|
|
@@ -40,27 +40,33 @@ function parseAt(value ) {
|
|
|
40
40
|
return Number.isNaN(ms) ? null : ms;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/** Every
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
/** Every run the `list` action shows (flat and namespaced, this checkout and the other ones, each
|
|
44
|
+
* logical run once), plus the ones that could not be read, named with a fixed reason. A file that
|
|
45
|
+
* cannot be read is counted and named rather than skipped: a report that quietly drops what it could
|
|
46
|
+
* not read overstates its own coverage, which is the defect `malformedLines` already guards against
|
|
47
|
+
* for runs. The reason is never the error's own text, which can carry a path or file bytes. */
|
|
48
|
+
function readRecords(root ) {
|
|
49
|
+
const { orchestrationRuns, runEntriesAt, selectRunEntries, runStoreDir } = require('../implementation-gate.js');
|
|
50
|
+
const listed = orchestrationRuns({ stateRoot: root });
|
|
49
51
|
const unreadable = [];
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
unreadable.push({ file: name, reason:
|
|
52
|
+
let entries ;
|
|
53
|
+
if (listed.ok) entries = selectRunEntries(listed.entries);
|
|
54
|
+
else {
|
|
55
|
+
entries = selectRunEntries(runEntriesAt(root, 'current'));
|
|
56
|
+
unreadable.push({ file: '(other checkouts)', reason: 'could not be listed' });
|
|
57
|
+
}
|
|
58
|
+
const records = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const name = entry.origin === 'other' ? entry.file : path.relative(runStoreDir(entry.root), entry.file);
|
|
61
|
+
if (entry.error) {
|
|
62
|
+
unreadable.push({ file: name, reason: entry.error === 'io' ? 'not a regular file' : 'not valid JSON' });
|
|
63
|
+
} else if (!entry.record || typeof entry.record !== 'object' || !entry.record.taskId) {
|
|
64
|
+
unreadable.push({ file: name, reason: 'not an orchestration record' });
|
|
65
|
+
} else {
|
|
66
|
+
records.push(entry.record);
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
|
-
return { records, unreadable, exists:
|
|
69
|
+
return { records, unreadable, exists: entries.length > 0 || fs.existsSync(orchestrationDir(root)) };
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** A stage row is "not work done" when the orchestrator backfilled it or imported it rather than
|
|
@@ -196,8 +202,7 @@ function printClass(group ) {
|
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
function handleIndicatorsCommand({ json = false, projectRoot = process.cwd() } = {}) {
|
|
199
|
-
const
|
|
200
|
-
const read = readRecords(dir);
|
|
205
|
+
const read = readRecords(projectRoot);
|
|
201
206
|
const view = buildIndicators(read);
|
|
202
207
|
|
|
203
208
|
if (json) {
|
|
@@ -9,6 +9,7 @@ const { resolveRunsLocation, normalizeRunRecord } = require('./ledger.js');
|
|
|
9
9
|
const { SAFE_TOKEN } = require('./telemetry.js');
|
|
10
10
|
const { finish } = require('./render.js');
|
|
11
11
|
const { REPO_ROOT } = require('../../helper/repo-root.js');
|
|
12
|
+
const { isRunNamespaceDir, isSupersededFlatRun } = require('../storage/work-status.js');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `trace spans` (088 IC-011, FR-005, DD-5): spans projected offline from the records that already
|
|
@@ -63,7 +64,6 @@ const { REPO_ROOT } = require('../../helper/repo-root.js');
|
|
|
63
64
|
|
|
64
65
|
const PARTITION = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
65
66
|
const STORE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
|
66
|
-
const NAMESPACE_DIR = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
67
67
|
const HEX16 = /^[0-9a-f]{16}$/;
|
|
68
68
|
const HEX32 = /^[0-9a-f]{32}$/;
|
|
69
69
|
const WORKER_RUN_ID = /^w-[0-9a-f]{12}$/;
|
|
@@ -201,15 +201,14 @@ function readStores(fsImpl , stateDir , rel , gaps , kin
|
|
|
201
201
|
if (!namespaced) return out.map(({ file, value }) => ({ file, value }));
|
|
202
202
|
let entries = [];
|
|
203
203
|
try { entries = fsImpl.readdirSync(base, { withFileTypes: true }); } catch { entries = []; }
|
|
204
|
-
const namespaces = entries.filter((entry ) => entry.isDirectory() &&
|
|
204
|
+
const namespaces = entries.filter((entry ) => entry.isDirectory() && isRunNamespaceDir(entry.name))
|
|
205
205
|
.map((entry ) => entry.name).sort();
|
|
206
206
|
for (const namespace of namespaces) {
|
|
207
207
|
const dir = path.join(base, namespace);
|
|
208
208
|
for (const name of listDir(fsImpl, dir, STORE_FILE)) read(`${rel}/${namespace}/${name}`, name, dir, namespace);
|
|
209
209
|
}
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
return out.filter((entry) => entry.namespace !== '' || !copied(entry)).map(({ file, value }) => ({ file, value }));
|
|
210
|
+
const copies = out.filter((other) => other.namespace !== '');
|
|
211
|
+
return out.filter((entry) => entry.namespace !== '' || !isSupersededFlatRun(entry.name, entry.value, copies)).map(({ file, value }) => ({ file, value }));
|
|
213
212
|
}
|
|
214
213
|
|
|
215
214
|
const objects = (v ) => (v && typeof v === 'object' && !Array.isArray(v)
|