@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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `intake` verb (136 IC-001): `doflow-run intake --action <sort|preview|undo|list|file-prompt>`. This module
|
|
5
|
+
* resolves the feature folder, runs the engine's action and maps its result to an exit status; it never
|
|
6
|
+
* prompts. Exit 0 = done (a sort with zero moves too), 1 = a finding to act on (a sort or undo that skipped or
|
|
7
|
+
* failed an entry, or an undo with nothing left to undo), 2 = the call cannot proceed (no active feature, an
|
|
8
|
+
* unknown action, no input for `file-prompt`, a manifest from a newer schema, a lock that could not be taken).
|
|
9
|
+
* `--json` prints the engine's object unmodified.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const nodeFs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { resolveActiveFeature } = require('../feature-resolve.js');
|
|
15
|
+
const { finishRuntime, usageError } = require('../cli-result.js');
|
|
16
|
+
const { loadTaxonomy, isPlainText } = require('./taxonomy.js');
|
|
17
|
+
const { IntakeUsageError } = require('./manifest.js');
|
|
18
|
+
const { previewSort, sortFolder, undoRun, listFolder, filePrompt, CONTEXT_DIR } = require('./engine.js');
|
|
19
|
+
|
|
20
|
+
const VALID_ACTIONS = ['sort', 'preview', 'undo', 'list', 'file-prompt'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The bytes of `--file`, opened without blocking and checked on the open descriptor: a regular file (a link to one
|
|
24
|
+
* is followed; a FIFO, a device or a folder is refused, and a FIFO is never waited on) of at most `maxBytes`.
|
|
25
|
+
*/
|
|
26
|
+
function readRegularFile(file , maxBytes , fsImpl ) {
|
|
27
|
+
const fd = fsImpl.openSync(path.resolve(file), nodeFs.constants.O_RDONLY | (nodeFs.constants.O_NONBLOCK || 0));
|
|
28
|
+
try {
|
|
29
|
+
const stat = fsImpl.fstatSync(fd);
|
|
30
|
+
if (!stat.isFile()) throw new IntakeUsageError(`--file ${file} is not a regular file; file-prompt reads only a regular text file`);
|
|
31
|
+
if (stat.size > maxBytes) throw new IntakeUsageError(`--file ${file} is ${stat.size} bytes; file-prompt takes at most ${maxBytes}`);
|
|
32
|
+
return fsImpl.readFileSync(fd);
|
|
33
|
+
} finally {
|
|
34
|
+
fsImpl.closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** At most `maxBytes + 1` bytes of stdin: one over the cap is enough to refuse, and a huge stream is never held whole. */
|
|
39
|
+
function readStdinCapped(maxBytes , fsImpl ) {
|
|
40
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
41
|
+
let length = 0;
|
|
42
|
+
while (length < buffer.length) {
|
|
43
|
+
let read ;
|
|
44
|
+
try {
|
|
45
|
+
read = fsImpl.readSync(0, buffer, length, buffer.length - length, null);
|
|
46
|
+
} catch (error ) {
|
|
47
|
+
if (error.code === 'EOF') break; // Windows reports the end of a closed pipe as an error
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
if (read === 0) break;
|
|
51
|
+
length += read;
|
|
52
|
+
}
|
|
53
|
+
return buffer.subarray(0, length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The prompt text as bytes, unchanged: from `--stdin` or from `--file <path>`. Either way it must be plain text
|
|
58
|
+
* (no NUL byte, valid UTF-8) of at most `maxBytes`, so a binary or a huge file is never filed as a prompt.
|
|
59
|
+
*/
|
|
60
|
+
function readInput({ stdin, file } , fsImpl , maxBytes ) {
|
|
61
|
+
if (!stdin && !file) throw new IntakeUsageError('--action file-prompt needs the text from --stdin or --file <path>');
|
|
62
|
+
if (stdin && file) throw new IntakeUsageError('--stdin and --file cannot be combined');
|
|
63
|
+
const from = stdin ? 'stdin' : `--file ${file}`;
|
|
64
|
+
let bytes ;
|
|
65
|
+
try {
|
|
66
|
+
bytes = stdin ? readStdinCapped(maxBytes, fsImpl) : readRegularFile(file, maxBytes, fsImpl);
|
|
67
|
+
} catch (error ) {
|
|
68
|
+
if (error instanceof IntakeUsageError) throw error;
|
|
69
|
+
throw new IntakeUsageError(`cannot read ${from}: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
if (bytes.length > maxBytes) throw new IntakeUsageError(`${from} is ${stdin ? `over ${maxBytes}` : bytes.length} bytes; file-prompt takes at most ${maxBytes}`);
|
|
72
|
+
if (!isPlainText(bytes)) throw new IntakeUsageError(`${from} is not plain text (it holds a NUL byte or is not valid UTF-8); file-prompt files text only`);
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Runs one action and returns its result without printing.
|
|
78
|
+
* @returns {{exitCode:number, result?:Object, usage?:string}}
|
|
79
|
+
*/
|
|
80
|
+
function runIntake({ action, projectRoot, slug = null, flags = {}, now = new Date(), fsImpl = nodeFs, taxonomyOptions = {} } ) {
|
|
81
|
+
try {
|
|
82
|
+
if (!VALID_ACTIONS.includes(action)) throw new IntakeUsageError(`unknown --action '${action}'. Valid: ${VALID_ACTIONS.join(', ')}`);
|
|
83
|
+
const feature = resolveActiveFeature({ projectRoot, slug });
|
|
84
|
+
if (feature.error) throw new IntakeUsageError(feature.message);
|
|
85
|
+
const loaded = loadTaxonomy({ projectRoot: feature.repoRoot, ...taxonomyOptions, fsImpl });
|
|
86
|
+
if (!loaded.ok) throw new IntakeUsageError(`the intake categories cannot be used (${loaded.reason}): ${loaded.detail}`);
|
|
87
|
+
const common = { contextDir: path.join(feature.featureFolder, CONTEXT_DIR), taxonomy: loaded.taxonomy, now, fsImpl };
|
|
88
|
+
let result ;
|
|
89
|
+
if (action === 'sort') result = sortFolder(common);
|
|
90
|
+
else if (action === 'preview') result = previewSort(common);
|
|
91
|
+
else if (action === 'list') result = listFolder(common);
|
|
92
|
+
else if (action === 'undo') result = undoRun({ ...common, runId: flags.runId || null });
|
|
93
|
+
else result = filePrompt({ ...common, bytes: readInput(flags, fsImpl, loaded.taxonomy.pointerMaxBytes), stage: flags.stage || null });
|
|
94
|
+
const finding = (Array.isArray(result.skipped) && result.skipped.length > 0) || (Array.isArray(result.kept) && result.kept.length > 0) || result.state === 'nothing-to-undo';
|
|
95
|
+
return { exitCode: finding ? 1 : 0, result };
|
|
96
|
+
} catch (error ) {
|
|
97
|
+
if (error instanceof IntakeUsageError) return { exitCode: 2, usage: error.message };
|
|
98
|
+
// Another writer holds the manifest lock: the call cannot proceed now (IC-001), so it is usage, not a finding.
|
|
99
|
+
if (/^Could not lock/.test(error?.message || '')) return { exitCode: 2, usage: error.message };
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function humanLines(result ) {
|
|
105
|
+
const lines = [];
|
|
106
|
+
if (result.state === 'absent') return [`no ${CONTEXT_DIR} folder in this feature; nothing to ${result.action}`];
|
|
107
|
+
if (result.action === 'sort' || result.action === 'preview') {
|
|
108
|
+
for (const move of result.moves) lines.push(`${result.action === 'preview' ? 'would move' : 'moved'} ${move.from} -> ${move.to} [${move.category}]`);
|
|
109
|
+
for (const skip of result.skipped) lines.push(`skipped ${skip.path} (${skip.reason}${skip.detail ? `: ${skip.detail}` : ''})`);
|
|
110
|
+
lines.push(`${result.moves.length} ${result.action === 'preview' ? 'to move' : 'moved'}, ${result.skipped.length} skipped, ${result.unmatched.length} unmatched${result.runId ? `; undo with --action undo --run-id ${result.runId}` : ''}`);
|
|
111
|
+
} else if (result.action === 'undo') {
|
|
112
|
+
if (result.state === 'nothing-to-undo') return ['no run left to undo'];
|
|
113
|
+
for (const move of result.restored) lines.push(`restored ${move.to} -> ${move.from}`);
|
|
114
|
+
for (const skip of result.skipped) lines.push(`skipped ${skip.to} (${skip.reason}${skip.detail ? `: ${skip.detail}` : ''})`);
|
|
115
|
+
for (const move of result.kept) lines.push(`kept ${move.to} (failed move from ${move.from}${move.reason ? `: ${move.reason}` : ''})`);
|
|
116
|
+
for (const dir of result.removedDirs) lines.push(`removed empty folder ${dir}`);
|
|
117
|
+
lines.push(`${result.restored.length} restored, ${result.skipped.length} skipped${result.kept.length ? `, ${result.kept.length} kept` : ''} (${result.runId})`);
|
|
118
|
+
} else if (result.action === 'list') {
|
|
119
|
+
for (const file of result.files) lines.push(`${file.path} ${file.category ?? '-'} ${file.kind} ${file.bytes} bytes ${file.unreadable ? 'unreadable' : file.sha256}${file.pointer ? ' pointer' : ''}`);
|
|
120
|
+
lines.push(`${result.files.length} file${result.files.length === 1 ? '' : 's'}`);
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(`filed ${result.path} (${result.bytes} bytes, ${result.sha256}; ${result.runId})`);
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `intake` verb. Exit 0 = done, 1 = a finding, 2 = usage. `--json` prints the result object unmodified. */
|
|
128
|
+
function handleIntakeCommand({ action = 'sort', projectRoot, slug = null, json = false, flags = {} } = {}) {
|
|
129
|
+
const run = runIntake({ action, projectRoot: projectRoot || process.cwd(), slug, flags });
|
|
130
|
+
if (run.usage !== undefined) return usageError('intake', run.usage, json);
|
|
131
|
+
if (json) console.log(JSON.stringify(run.result, null, 2));
|
|
132
|
+
else for (const line of humanLines(run.result)) console.log(line);
|
|
133
|
+
return finishRuntime(run.exitCode);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { handleIntakeCommand, runIntake, readInput, humanLines, VALID_ACTIONS };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The intake manifest (136 IC-007): `additional-context/.intake/manifest.json`, the one record of every sort
|
|
5
|
+
* and every filed prompt. It is rewritten whole through a temporary file and a rename, under the
|
|
6
|
+
* `acquireLock` lock `task-state.ts` provides for its own files, the way `decision-register.ts` writes its
|
|
7
|
+
* register. The reader refuses what it cannot trust (invalid JSON, a newer schema, a path that leaves the
|
|
8
|
+
* folder) with an `IntakeUsageError` and never rewrites such a file: the manifest is evidence of what moved.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const nodeFs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const nodeCrypto = require('node:crypto');
|
|
14
|
+
const { acquireLock } = require('../task-state.js');
|
|
15
|
+
|
|
16
|
+
const MANIFEST_SCHEMA = 1;
|
|
17
|
+
const INTAKE_DIR = '.intake';
|
|
18
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
19
|
+
const STATES = ['planned', 'done', 'failed', 'undone', 'undo-skipped'];
|
|
20
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
21
|
+
|
|
22
|
+
/** A caller mistake or an input the engine refuses to guess at (exit 2). */
|
|
23
|
+
class IntakeUsageError extends Error {}
|
|
24
|
+
|
|
25
|
+
function manifestFile(contextDir ) { return path.join(contextDir, INTAKE_DIR, MANIFEST_FILE); }
|
|
26
|
+
|
|
27
|
+
/** True for a relative path of plain segments: nothing absolute, no `..`, no hidden segment, no backslash. */
|
|
28
|
+
function isConfinedPath(rel ) {
|
|
29
|
+
if (typeof rel !== 'string' || rel === '' || rel.includes('\\') || rel.includes('\0')) return false;
|
|
30
|
+
return rel.split('/').every((segment ) => segment !== '' && !segment.startsWith('.'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Refuses a `.intake` that is a link or a plain file: the lock and the manifest would be written wherever it
|
|
35
|
+
* points. An absent `.intake` is fine (it is created as a real folder).
|
|
36
|
+
*/
|
|
37
|
+
function assertIntakeDir(contextDir , fsImpl = nodeFs) {
|
|
38
|
+
const dir = path.join(contextDir, INTAKE_DIR);
|
|
39
|
+
let stat ;
|
|
40
|
+
try { stat = fsImpl.lstatSync(dir); } catch (error ) {
|
|
41
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new IntakeUsageError(`${dir} is not a real folder; intake writes its lock and manifest only inside a real ${INTAKE_DIR} folder. Nothing was written.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function badManifest(file , why ) {
|
|
48
|
+
return new IntakeUsageError(`${file} ${why}; the intake manifest is never rewritten when it cannot be trusted. Restore it or move it aside.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateMove(move , file , where ) {
|
|
52
|
+
const ok = move && typeof move === 'object'
|
|
53
|
+
&& (move.from === null || isConfinedPath(move.from))
|
|
54
|
+
&& isConfinedPath(move.to)
|
|
55
|
+
&& typeof move.sha256 === 'string' && HASH.test(move.sha256)
|
|
56
|
+
&& Number.isInteger(move.bytes) && move.bytes >= 0
|
|
57
|
+
&& typeof move.at === 'string'
|
|
58
|
+
&& STATES.includes(move.state)
|
|
59
|
+
&& (move.reason === null || typeof move.reason === 'string');
|
|
60
|
+
if (!ok) throw badManifest(file, `has a malformed or unconfined move at ${where}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The manifest, or an empty one when none exists. Throws IntakeUsageError for a file it cannot trust. */
|
|
64
|
+
function readManifest(contextDir , fsImpl = nodeFs) {
|
|
65
|
+
assertIntakeDir(contextDir, fsImpl);
|
|
66
|
+
const file = manifestFile(contextDir);
|
|
67
|
+
if (!fsImpl.existsSync(file)) return { schema: MANIFEST_SCHEMA, runs: [] };
|
|
68
|
+
let data ;
|
|
69
|
+
try {
|
|
70
|
+
data = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
71
|
+
} catch (error ) {
|
|
72
|
+
throw badManifest(file, `is not valid JSON (${error.message})`);
|
|
73
|
+
}
|
|
74
|
+
if (!data || typeof data !== 'object' || !Number.isInteger(data.schema) || !Array.isArray(data.runs)) {
|
|
75
|
+
throw badManifest(file, 'is not an intake manifest');
|
|
76
|
+
}
|
|
77
|
+
if (data.schema > MANIFEST_SCHEMA) {
|
|
78
|
+
throw new IntakeUsageError(`${file} is schema ${data.schema} and this DoFlow reads schema ${MANIFEST_SCHEMA}; upgrade DoFlow. The manifest was not touched.`);
|
|
79
|
+
}
|
|
80
|
+
if (data.schema !== MANIFEST_SCHEMA) throw badManifest(file, `has schema ${data.schema}`);
|
|
81
|
+
data.runs.forEach((run , i ) => {
|
|
82
|
+
const ok = run && typeof run === 'object' && typeof run.id === 'string' && ['sort', 'file-prompt'].includes(run.action)
|
|
83
|
+
&& Array.isArray(run.createdDirs) && run.createdDirs.every(isConfinedPath) && Array.isArray(run.moves);
|
|
84
|
+
if (!ok) throw badManifest(file, `has a malformed run at runs[${i}]`);
|
|
85
|
+
run.moves.forEach((move , j ) => validateMove(move, file, `runs[${i}].moves[${j}]`));
|
|
86
|
+
});
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Writes the manifest whole: temp file, then rename, so a reader sees the old file or the new one. `assertHeld`
|
|
92
|
+
* (from `withManifestLock`) is checked between the two: a writer that stalled past the lock's staleness and was
|
|
93
|
+
* reclaimed writes nothing (the way `updateTaskState` does).
|
|
94
|
+
*/
|
|
95
|
+
function writeManifest(contextDir , manifest , fsImpl = nodeFs, assertHeld = () => {}) {
|
|
96
|
+
const file = manifestFile(contextDir);
|
|
97
|
+
fsImpl.mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
99
|
+
try {
|
|
100
|
+
fsImpl.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
101
|
+
assertHeld();
|
|
102
|
+
fsImpl.renameSync(tmp, file);
|
|
103
|
+
} catch (error ) {
|
|
104
|
+
try { fsImpl.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs `fn` holding the manifest's lock. A lock that cannot be taken throws the lock error unchanged. `fn` gets
|
|
111
|
+
* `assertHeld`, which throws an `IntakeUsageError` (exit 2) once the lock was reclaimed by another writer; the
|
|
112
|
+
* engine calls it before every manifest rename and every move of a user's file, so nothing more is written or moved.
|
|
113
|
+
*/
|
|
114
|
+
function withManifestLock (contextDir , fn , fsImpl = nodeFs) {
|
|
115
|
+
assertIntakeDir(contextDir, fsImpl);
|
|
116
|
+
fsImpl.mkdirSync(path.join(contextDir, INTAKE_DIR), { recursive: true });
|
|
117
|
+
// The first check and the mkdir are two calls: a link put at `.intake` in between would make mkdir succeed
|
|
118
|
+
// silently and the lock land wherever it points. Looked at again here. Ceiling accepted: a swap after this
|
|
119
|
+
// look and before the lock is taken is one syscall wide; Node has no openat to close it.
|
|
120
|
+
assertIntakeDir(contextDir, fsImpl);
|
|
121
|
+
const release = acquireLock(fsImpl, manifestFile(contextDir));
|
|
122
|
+
const assertHeld = () => {
|
|
123
|
+
if (typeof release.held === 'function' && !release.held()) {
|
|
124
|
+
throw new IntakeUsageError(`the intake lock on ${manifestFile(contextDir)} was taken over by another writer (this run stalled too long); nothing further was written or moved. Run the command again.`);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
return fn(assertHeld);
|
|
129
|
+
} finally {
|
|
130
|
+
release();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `run-<UTC yyyymmddThhmmssZ>-<4 hex>`. */
|
|
135
|
+
function newRunId(now ) {
|
|
136
|
+
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
137
|
+
return `run-${stamp}-${nodeCrypto.randomBytes(2).toString('hex')}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { readManifest, writeManifest, withManifestLock, newRunId, manifestFile, isConfinedPath, assertIntakeDir, IntakeUsageError, MANIFEST_SCHEMA, INTAKE_DIR };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The intake taxonomy (136 IC-005): `core/registry/intake.json` read on its own, not through `loadRegistry`,
|
|
5
|
+
* so a bad file turns intake off with a reason and never fails an installer command (the transcripts pattern,
|
|
6
|
+
* `src/runtime/transcripts/registry.js`). `validateTaxonomy` checks the closed shape and the id and name
|
|
7
|
+
* uniqueness; `loadTaxonomy` returns the core categories in file order, then the valid `intake-category`
|
|
8
|
+
* contributions of active packages (IC-006). A rule matches on a file's name and extension only, never on its content.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { REPO_ROOT } = require('../../helper/repo-root.js');
|
|
14
|
+
|
|
15
|
+
const INTAKE_FILE = 'core/registry/intake.json';
|
|
16
|
+
|
|
17
|
+
const TOP_KEYS = ['version', 'description', 'recentWindowSeconds', 'pointerMaxBytes', 'textExtensions', 'promptCategory', 'categories'];
|
|
18
|
+
const CATEGORY_KEYS = ['id', 'names', 'summary', 'rules'];
|
|
19
|
+
const RULE_KEYS = ['extensions', 'namePattern', 'into', 'companionOf'];
|
|
20
|
+
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
21
|
+
const EXTENSION = /^[a-z0-9]+$/;
|
|
22
|
+
const NAME_MAX = 40;
|
|
23
|
+
const INTO_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
24
|
+
|
|
25
|
+
function isObject(value ) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unknownKeys(value , allowed , where , errors ) {
|
|
30
|
+
for (const key of Object.keys(value)) {
|
|
31
|
+
if (!allowed.includes(key)) errors.push(`${where}.${key} is not an allowed key (allowed: ${allowed.join(', ')})`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extensionList(value , where , errors , { required } ) {
|
|
36
|
+
if (!Array.isArray(value) || (required && value.length === 0)) {
|
|
37
|
+
errors.push(`${where} must be ${required ? 'a non-empty' : 'an'} array of lower-case extensions without a dot`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const ext of value) {
|
|
41
|
+
if (typeof ext !== 'string' || !EXTENSION.test(ext)) errors.push(`${where} entry ${JSON.stringify(ext)} must be a lower-case extension without a dot`);
|
|
42
|
+
}
|
|
43
|
+
if (new Set(value).size !== value.length) errors.push(`${where} names an extension twice`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateRule(rule , where , errors ) {
|
|
47
|
+
if (!isObject(rule)) { errors.push(`${where} must be an object`); return; }
|
|
48
|
+
unknownKeys(rule, RULE_KEYS, where, errors);
|
|
49
|
+
extensionList(rule.extensions, `${where}.extensions`, errors, { required: true });
|
|
50
|
+
if (Object.hasOwn(rule, 'namePattern')) {
|
|
51
|
+
if (typeof rule.namePattern !== 'string' || rule.namePattern === '') {
|
|
52
|
+
errors.push(`${where}.namePattern must be a non-empty regular expression string`);
|
|
53
|
+
} else {
|
|
54
|
+
try { new RegExp(rule.namePattern, 'i'); } catch (error ) { errors.push(`${where}.namePattern does not compile (${error.message})`); }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (Object.hasOwn(rule, 'into')) {
|
|
58
|
+
const into = rule.into;
|
|
59
|
+
const segments = typeof into === 'string' ? into.split('/') : [];
|
|
60
|
+
if (typeof into !== 'string' || segments.some((segment ) => !INTO_SEGMENT.test(segment) || segment === '..')) {
|
|
61
|
+
errors.push(`${where}.into must be a relative sub-path of plain names, with no '..', no leading dot and no empty segment`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (Object.hasOwn(rule, 'companionOf')) {
|
|
65
|
+
extensionList(rule.companionOf, `${where}.companionOf`, errors, { required: true });
|
|
66
|
+
if (Object.hasOwn(rule, 'into')) errors.push(`${where} has companionOf, so it follows its companion and takes no into`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The shape errors of one category (IC-005, and IC-006's contributed entry): closed keys, a lower-kebab `id`
|
|
72
|
+
* and `names`, a summary, and valid rules. Uniqueness across categories is the caller's, who sees them all.
|
|
73
|
+
*/
|
|
74
|
+
function validateCategory(category , where ) {
|
|
75
|
+
const errors = [];
|
|
76
|
+
if (!isObject(category)) return [`${where} must be an object`];
|
|
77
|
+
unknownKeys(category, CATEGORY_KEYS, where, errors);
|
|
78
|
+
for (const key of CATEGORY_KEYS) {
|
|
79
|
+
if (!Object.hasOwn(category, key)) errors.push(`${where}.${key} is missing`);
|
|
80
|
+
}
|
|
81
|
+
const kebab = (value ) => typeof value === 'string' && value.length <= NAME_MAX && KEBAB.test(value);
|
|
82
|
+
if (Object.hasOwn(category, 'id') && !kebab(category.id)) errors.push(`${where}.id must be lower-kebab, at most ${NAME_MAX} characters`);
|
|
83
|
+
if (Object.hasOwn(category, 'names')) {
|
|
84
|
+
if (!Array.isArray(category.names) || !category.names.every(kebab)) errors.push(`${where}.names must be an array of lower-kebab names, at most ${NAME_MAX} characters each`);
|
|
85
|
+
}
|
|
86
|
+
if (Object.hasOwn(category, 'summary') && (typeof category.summary !== 'string' || category.summary.trim() === '')) {
|
|
87
|
+
errors.push(`${where}.summary must be a non-empty string`);
|
|
88
|
+
}
|
|
89
|
+
if (Object.hasOwn(category, 'rules')) {
|
|
90
|
+
if (!Array.isArray(category.rules)) errors.push(`${where}.rules must be an array`);
|
|
91
|
+
else category.rules.forEach((rule , i ) => validateRule(rule, `${where}.rules[${i}]`, errors));
|
|
92
|
+
}
|
|
93
|
+
return errors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Checks the registry's shape, then that every id and name is used once across all categories. */
|
|
97
|
+
function validateTaxonomy(registry ) {
|
|
98
|
+
const errors = [];
|
|
99
|
+
if (!isObject(registry)) return { ok: false, errors: ['registry must be an object'] };
|
|
100
|
+
unknownKeys(registry, TOP_KEYS, 'registry', errors);
|
|
101
|
+
for (const key of TOP_KEYS) {
|
|
102
|
+
if (!Object.hasOwn(registry, key)) errors.push(`registry.${key} is missing`);
|
|
103
|
+
}
|
|
104
|
+
if (Object.hasOwn(registry, 'version') && registry.version !== 1) errors.push('version must be 1');
|
|
105
|
+
if (Object.hasOwn(registry, 'description') && (typeof registry.description !== 'string' || registry.description.trim() === '')) {
|
|
106
|
+
errors.push('description must be a non-empty string');
|
|
107
|
+
}
|
|
108
|
+
if (Object.hasOwn(registry, 'recentWindowSeconds') && !(Number.isInteger(registry.recentWindowSeconds) && registry.recentWindowSeconds >= 0)) {
|
|
109
|
+
errors.push('recentWindowSeconds must be an integer of 0 or more');
|
|
110
|
+
}
|
|
111
|
+
if (Object.hasOwn(registry, 'pointerMaxBytes') && !(Number.isInteger(registry.pointerMaxBytes) && registry.pointerMaxBytes > 0)) {
|
|
112
|
+
errors.push('pointerMaxBytes must be a positive integer');
|
|
113
|
+
}
|
|
114
|
+
if (Object.hasOwn(registry, 'textExtensions')) extensionList(registry.textExtensions, 'textExtensions', errors, { required: true });
|
|
115
|
+
if (!Array.isArray(registry.categories) || registry.categories.length === 0) {
|
|
116
|
+
errors.push('categories must be a non-empty array');
|
|
117
|
+
return { ok: false, errors };
|
|
118
|
+
}
|
|
119
|
+
registry.categories.forEach((category , i ) => errors.push(...validateCategory(category, `categories[${i}]`)));
|
|
120
|
+
const owner = new Map ();
|
|
121
|
+
const claim = (name , id ) => {
|
|
122
|
+
if (typeof name !== 'string') return;
|
|
123
|
+
if (owner.has(name)) errors.push(`'${name}' is used by both '${owner.get(name)}' and '${id}'; ids and names are unique across categories`);
|
|
124
|
+
else owner.set(name, String(id));
|
|
125
|
+
};
|
|
126
|
+
for (const category of registry.categories) {
|
|
127
|
+
if (!isObject(category)) continue;
|
|
128
|
+
claim(category.id, category.id);
|
|
129
|
+
if (Array.isArray(category.names)) for (const name of category.names) claim(name, category.id);
|
|
130
|
+
}
|
|
131
|
+
const prompt = registry.categories.find((category ) => isObject(category) && category.id === registry.promptCategory);
|
|
132
|
+
if (!prompt) errors.push(`promptCategory '${registry.promptCategory}' is not a category id`);
|
|
133
|
+
else if (Array.isArray(prompt.rules) && prompt.rules.length > 0) errors.push(`the prompt category '${prompt.id}' must have no rules`);
|
|
134
|
+
return { ok: errors.length === 0, errors };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns `{ ok: true, taxonomy }` with the core categories in file order, each marked `origin: 'core'`, followed by
|
|
139
|
+
* the categories active packages contribute (IC-006), or `{ ok: false, reason: 'config-invalid', detail }` when the
|
|
140
|
+
* core file cannot be read or fails its shape. A contribution is tried after every core category, in ascending
|
|
141
|
+
* qualified-id order; its `id` becomes the qualified id, which is also its folder name. One whose id or names
|
|
142
|
+
* collide with a core category or an earlier contribution is left out and named in `problems`. The core list never
|
|
143
|
+
* depends on the package view: `contributionsFor` yields nothing when the point registry fails to load.
|
|
144
|
+
* `contributions: false` skips the package view. `view`, `homeDir`, `env`, `projectRoot` and `tmpDir` reach it.
|
|
145
|
+
*/
|
|
146
|
+
function loadTaxonomy({ repoRoot = REPO_ROOT, file, fsImpl = fs, contributions = true, problems, view, homeDir, env, projectRoot, tmpDir } = {}) {
|
|
147
|
+
const target = file || path.join(path.resolve(repoRoot), INTAKE_FILE);
|
|
148
|
+
let registry ;
|
|
149
|
+
try {
|
|
150
|
+
registry = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
|
|
151
|
+
} catch (error ) {
|
|
152
|
+
return { ok: false, reason: 'config-invalid', detail: `${target}: ${error.message}` };
|
|
153
|
+
}
|
|
154
|
+
const validation = validateTaxonomy(registry);
|
|
155
|
+
if (!validation.ok) return { ok: false, reason: 'config-invalid', detail: validation.errors.join('; ') };
|
|
156
|
+
const categories = registry.categories.map((category ) => ({ ...category, origin: 'core' }));
|
|
157
|
+
if (contributions) {
|
|
158
|
+
const used = new Set (categories.flatMap((category ) => [category.id, ...category.names]));
|
|
159
|
+
const { contributionsFor } = require('../packages/contributions.js');
|
|
160
|
+
for (const contribution of contributionsFor('intake-category', 1, { repoRoot, fsImpl, view, homeDir, env, projectRoot, tmpDir, problems })) {
|
|
161
|
+
const id = contribution.qualifiedId;
|
|
162
|
+
const clash = [id, ...contribution.data.names].find((name ) => used.has(name));
|
|
163
|
+
if (clash !== undefined) {
|
|
164
|
+
problems?.push(`intake-category contribution '${id}' is left out: '${clash}' is already the id or a name of another category`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const name of [id, ...contribution.data.names]) used.add(name);
|
|
168
|
+
categories.push({ ...contribution.data, id, origin: id });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, taxonomy: { ...registry, categories } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The folder a category files into: the first of its id and known names that is already a folder in the
|
|
176
|
+
* context folder, else its id (FR-003, IC-005). `existing` is the set of folder names found there.
|
|
177
|
+
*/
|
|
178
|
+
function categoryFolder(category , existing ) {
|
|
179
|
+
return [category.id, ...category.names].find((name ) => existing.has(name)) ?? category.id;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True for bytes a text file can hold: no NUL byte and valid UTF-8. A binary saved as `.md` fails it. */
|
|
183
|
+
function isPlainText(buffer ) {
|
|
184
|
+
if (buffer.includes(0)) return false;
|
|
185
|
+
try {
|
|
186
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* True for a `/`-separated path inside a feature's user-context folder: `agent-docs/doflow/<slug>/additional-context/`
|
|
195
|
+
* anywhere in it (a main checkout, a linked worktree or a relative path between them). A folder of that name
|
|
196
|
+
* elsewhere in a project (`src/additional-context/`) is not the user's. The path is normalised first (a backslash
|
|
197
|
+
* becomes `/`, then `.`, `//` and `a/..` are resolved), so `x/./additional-context/` and `x/y/../additional-context/`
|
|
198
|
+
* are read as what they name and `additional-context/../../a.ts` as `a.ts`. A `..` left after that is only a leading
|
|
199
|
+
* one, the relative path between two checkouts, which the rule above keeps.
|
|
200
|
+
*/
|
|
201
|
+
function isUserContextPath(file ) {
|
|
202
|
+
const parts = path.posix.normalize(file.replace(/\\/g, '/')).split('/');
|
|
203
|
+
return parts.some((part, i) => part === 'agent-docs' && parts[i + 1] === 'doflow' && Boolean(parts[i + 2]) && parts[i + 3] === 'additional-context');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { isUserContextPath, isPlainText, loadTaxonomy, validateTaxonomy, validateCategory, categoryFolder, INTAKE_FILE };
|
|
@@ -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 };
|