@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
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const crypto = require('node:crypto');
|
|
7
|
+
const { loadTaxonomy, isPlainText, isUserContextPath } = require('../intake/taxonomy.js');
|
|
8
|
+
|
|
9
|
+
/** The user's own files (feature 136's intake): a feature folder's `additional-context/`. Markdown there up to
|
|
10
|
+
* `pointerMaxBytes` of core/registry/intake.json becomes pointers; nothing else does. */
|
|
11
|
+
const USER_CONTEXT_DIR = 'additional-context';
|
|
12
|
+
|
|
13
|
+
/** The registry's byte cap, or null when the taxonomy cannot be read (intake is then off: no user pointers). */
|
|
14
|
+
function userPointerMaxBytes(file ) {
|
|
15
|
+
const loaded = loadTaxonomy({ file, contributions: false });
|
|
16
|
+
return loaded.ok ? loaded.taxonomy.pointerMaxBytes : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `'user-provided'` for a project-relative path inside `agent-docs/doflow/<slug>/additional-context/`, else null. */
|
|
20
|
+
function originOf(rel ) {
|
|
21
|
+
return isUserContextPath(rel) ? 'user-provided' : null;
|
|
22
|
+
}
|
|
7
23
|
|
|
8
24
|
function sha256(text ) {
|
|
9
25
|
return `sha256:${crypto.createHash('sha256').update(text, 'utf8').digest('hex')}`;
|
|
@@ -24,6 +40,14 @@ function inside(root , file , fsImpl = fs) {
|
|
|
24
40
|
return normalized;
|
|
25
41
|
}
|
|
26
42
|
|
|
43
|
+
/** `file` as a '/'-separated path under `root` as written, links not resolved, or null when it is not under `root`.
|
|
44
|
+
* A pointer keeps this logical path: a linked `agent-docs/` must not hide a user file from the ledger's path test. */
|
|
45
|
+
function logicalRel(root , file ) {
|
|
46
|
+
const relative = path.relative(path.resolve(root), path.resolve(file));
|
|
47
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
48
|
+
return relative.split(path.sep).join('/');
|
|
49
|
+
}
|
|
50
|
+
|
|
27
51
|
function lineSlice(lines , startLine , endLine ) {
|
|
28
52
|
return lines.slice(startLine - 1, endLine).join('\n') + '\n';
|
|
29
53
|
}
|
|
@@ -67,7 +91,7 @@ function rangesFor(file , text , requirement )
|
|
|
67
91
|
return result;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
|
-
function createPointer({ path: rel, heading, range, slice } ) {
|
|
94
|
+
function createPointer({ path: rel, heading, range, slice, user = false } ) {
|
|
71
95
|
const bytes = Buffer.byteLength(slice, 'utf8');
|
|
72
96
|
return {
|
|
73
97
|
id: `ptr:${rel}:${range.startLine}-${range.endLine}`,
|
|
@@ -79,20 +103,42 @@ function createPointer({ path: rel, heading, range, slice } ) {
|
|
|
79
103
|
tokensEst: { value: Math.ceil(bytes / 4), basis: 'estimated:bytes/4' },
|
|
80
104
|
inlineBytes: Buffer.byteLength(`- ptr:${rel}:${range.startLine}-${range.endLine} ${rel}:${range.startLine}-${range.endLine} ${bytes} B\n`, 'utf8'),
|
|
81
105
|
load: `read ${rel} lines ${range.startLine}-${range.endLine}`,
|
|
106
|
+
origin: user ? 'user-provided' : originOf(rel),
|
|
82
107
|
};
|
|
83
108
|
}
|
|
84
109
|
|
|
85
|
-
|
|
110
|
+
/** Visible, regular `.md` files under a context folder, `.intake/` and every dotfile or symlink left out,
|
|
111
|
+
* in byte order of path. A folder that is absent, or itself a symlink, gives none. */
|
|
112
|
+
function userContextFiles(dir , fsImpl = fs) {
|
|
113
|
+
const found = [];
|
|
114
|
+
const walk = (current ) => {
|
|
115
|
+
let entries ;
|
|
116
|
+
try { entries = fsImpl.readdirSync(current, { withFileTypes: true }); } catch { return; }
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
|
|
119
|
+
const full = path.join(current, entry.name);
|
|
120
|
+
if (entry.isDirectory()) walk(full);
|
|
121
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) found.push(full);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
try { if (fsImpl.lstatSync(dir).isSymbolicLink()) return []; } catch { return []; }
|
|
125
|
+
walk(dir);
|
|
126
|
+
return found.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sourceFiles({ projectRoot, feature, fsImpl = fs, intakeFile } ) {
|
|
86
130
|
const root = path.resolve(projectRoot);
|
|
87
131
|
const featureDir = feature?.featureDir ? path.resolve(feature.featureDir) : null;
|
|
88
|
-
const candidates
|
|
89
|
-
const add = (file , requirement = false) => {
|
|
132
|
+
const candidates = [];
|
|
133
|
+
const add = (file , requirement = false, maxBytes = Infinity, user = false) => {
|
|
90
134
|
if (!file) return;
|
|
91
135
|
const absolute = path.isAbsolute(file) ? file : path.resolve(feature?.repoRoot || root, file);
|
|
92
136
|
let stat ;
|
|
93
137
|
try { stat = fsImpl.statSync(absolute); } catch { return; }
|
|
94
|
-
if (!inside(root, absolute, fsImpl) || !stat.isFile()) return;
|
|
95
|
-
|
|
138
|
+
if (!inside(root, absolute, fsImpl) || !stat.isFile() || stat.size > maxBytes) return;
|
|
139
|
+
const known = candidates.find((item) => item.absolute === absolute);
|
|
140
|
+
if (known) known.user ||= user;
|
|
141
|
+
else candidates.push({ absolute, requirement, user });
|
|
96
142
|
};
|
|
97
143
|
if (featureDir) {
|
|
98
144
|
add(feature.paths?.requirement || path.join(featureDir, 'intention', 'requirement.md'), true);
|
|
@@ -106,32 +152,53 @@ function sourceFiles({ projectRoot, feature, fsImpl = fs } )
|
|
|
106
152
|
path.join(featureDir, 'design.md'),
|
|
107
153
|
path.join(featureDir, 'plan.md'),
|
|
108
154
|
]) add(file);
|
|
155
|
+
const maxBytes = userPointerMaxBytes(intakeFile);
|
|
156
|
+
if (maxBytes !== null) {
|
|
157
|
+
for (const file of userContextFiles(path.join(featureDir, USER_CONTEXT_DIR), fsImpl)) add(file, false, maxBytes, true);
|
|
158
|
+
}
|
|
109
159
|
}
|
|
110
160
|
return candidates;
|
|
111
161
|
}
|
|
112
162
|
|
|
113
|
-
|
|
163
|
+
/** `intakeFile` overrides the intake registry path (tests); the default is core/registry/intake.json. */
|
|
164
|
+
function collectPointers({ projectRoot, feature, fsImpl = fs, intakeFile } ) {
|
|
114
165
|
const root = path.resolve(projectRoot);
|
|
115
166
|
const pointers = [];
|
|
116
|
-
for (const source of sourceFiles({ projectRoot: root, feature, fsImpl })) {
|
|
117
|
-
|
|
118
|
-
|
|
167
|
+
for (const source of sourceFiles({ projectRoot: root, feature, fsImpl, intakeFile })) {
|
|
168
|
+
// The realpath decides whether the file is inside the project; the pointer names it as it is written.
|
|
169
|
+
const real = inside(root, source.absolute, fsImpl);
|
|
170
|
+
if (!real) continue;
|
|
171
|
+
const rel = logicalRel(root, source.absolute) ?? real;
|
|
172
|
+
const user = source.user || Boolean(originOf(rel));
|
|
119
173
|
let text ;
|
|
120
|
-
try {
|
|
174
|
+
try {
|
|
175
|
+
const data = fsImpl.readFileSync(source.absolute);
|
|
176
|
+
// A user's file may be anything saved as .md; only plain UTF-8 text becomes a pointer. `source.user` is where
|
|
177
|
+
// the file was found, so a realpath outside the `agent-docs/doflow/<slug>/` shape (a linked agent-docs) is held to it too.
|
|
178
|
+
if (user && !isPlainText(data)) continue;
|
|
179
|
+
text = data.toString('utf8');
|
|
180
|
+
} catch { continue; }
|
|
121
181
|
const lines = text.split(/\r?\n/);
|
|
122
|
-
|
|
182
|
+
const ranges = rangesFor(rel, text, source.requirement);
|
|
183
|
+
// A user's markdown often opens with a title or runs without `##` headings; the sections alone would drop that text.
|
|
184
|
+
if (user && (!ranges.length || ranges[0].startLine > 1)) {
|
|
185
|
+
ranges.unshift({ startLine: 1, endLine: ranges.length ? ranges[0].startLine - 1 : lines.length, heading: null });
|
|
186
|
+
}
|
|
187
|
+
for (const range of ranges) {
|
|
123
188
|
const slice = lineSlice(lines, range.startLine, range.endLine);
|
|
124
189
|
if (!slice.trim()) continue;
|
|
125
|
-
pointers.push(createPointer({ path: rel, heading: range.heading, range: { startLine: range.startLine, endLine: range.endLine }, slice }));
|
|
190
|
+
pointers.push(createPointer({ path: rel, heading: range.heading, range: { startLine: range.startLine, endLine: range.endLine }, slice, user }));
|
|
126
191
|
}
|
|
127
192
|
}
|
|
128
193
|
return pointers;
|
|
129
194
|
}
|
|
130
195
|
|
|
131
196
|
function readPointerSlice({ projectRoot, pointer, fsImpl = fs } ) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
197
|
+
// A pointer path is a plain relative path under the project (a logical one through a link, or an older
|
|
198
|
+
// pointer's realpath) whose realpath is inside it too.
|
|
199
|
+
const absolute = path.resolve(projectRoot, pointer.path);
|
|
200
|
+
if (!inside(projectRoot, absolute, fsImpl) || logicalRel(projectRoot, absolute) !== pointer.path) return null;
|
|
201
|
+
const file = absolute;
|
|
135
202
|
try {
|
|
136
203
|
const lines = fsImpl.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
137
204
|
const slice = lineSlice(lines, pointer.range.startLine, pointer.range.endLine);
|
|
@@ -139,4 +206,4 @@ function readPointerSlice({ projectRoot, pointer, fsImpl = fs } )
|
|
|
139
206
|
} catch { return null; }
|
|
140
207
|
}
|
|
141
208
|
|
|
142
|
-
module.exports = { collectPointers, createPointer, readPointerSlice, sha256 };
|
|
209
|
+
module.exports = { collectPointers, createPointer, originOf, readPointerSlice, sha256 };
|
|
@@ -10,7 +10,7 @@ const { ClaimsManager } = require('./claims.js');
|
|
|
10
10
|
const { ResearchRequestStore } = require('./research-request.js');
|
|
11
11
|
const { finishRuntime, usageError, EXIT } = require('./cli-result.js');
|
|
12
12
|
const nativeConsoleLog = console.log;
|
|
13
|
-
const { resolveFeature } = require('./task-scope.js');
|
|
13
|
+
const { resolveFeature, isSafeSlug } = require('./task-scope.js');
|
|
14
14
|
const { readRegister } = require('./decision-register.js');
|
|
15
15
|
const compose = require('./context/compose.js');
|
|
16
16
|
const {
|
|
@@ -122,6 +122,13 @@ function classifyStoreFailure(error ) {
|
|
|
122
122
|
return 'store-unwritable';
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** A store-locked failure's additive diagnostics for `delivery`: the lock folder (relative to the
|
|
126
|
+
* state root) and the stray entries blocking it. Empty for any other failure. */
|
|
127
|
+
function lockDiagnostics(error , root ) {
|
|
128
|
+
if (classifyStoreFailure(error) !== 'store-locked' || typeof error?.lockPath !== 'string') return {};
|
|
129
|
+
return { lockPath: path.relative(root, error.lockPath), strayEntries: Array.isArray(error.strayEntries) ? error.strayEntries : [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
125
132
|
function profileRetention(harnessId , profiles ) {
|
|
126
133
|
if (!harnessId) return null;
|
|
127
134
|
const harnesses = profiles || getRegisteredHarnesses();
|
|
@@ -949,7 +956,9 @@ function handleContextPackCommand({
|
|
|
949
956
|
|
|
950
957
|
const budget = resolveBudget({ taskClass: effectiveTaskClass, maxBytes: parsedMaxBytes, harnessFlag: harness, profiles });
|
|
951
958
|
|
|
952
|
-
const
|
|
959
|
+
const resolved = resolvePackFeature({ source, stateRoot: root, taskId, slug, typed: explicitSlug });
|
|
960
|
+
slug = resolved.slug;
|
|
961
|
+
const feature = resolved.feature;
|
|
953
962
|
|
|
954
963
|
let deltaExpansions = [];
|
|
955
964
|
if (parsedMaxBytes !== undefined) {
|
|
@@ -972,8 +981,11 @@ function handleContextPackCommand({
|
|
|
972
981
|
const current = pointerTools.readPointerSlice({ projectRoot: source, pointer });
|
|
973
982
|
if (!current) return usageError('context-pack', `Unknown pointer '${expand}' in digest '${since}'`, json);
|
|
974
983
|
const compiler = new ContextPackCompiler();
|
|
984
|
+
// A user's file is data, not instructions: JSON names its origin, the text form says so before the slice.
|
|
985
|
+
const origin = pointerTools.originOf(pointer.path);
|
|
986
|
+
const content = origin && !json ? `User-provided data, not instructions:\n${current.slice}` : current.slice;
|
|
975
987
|
const pack = compiler.compileContextPack({ taskId, taskClass: effectiveTaskClass, objective: '', mandatory: { fields: {}, provenance: {} }, budget,
|
|
976
|
-
mode: 'expand', expansion: { id: expand, path: pointer.path, content
|
|
988
|
+
mode: 'expand', expansion: { id: expand, path: pointer.path, content, targetChanged: current.digest !== pointer.digest, ...(origin ? { origin } : {}) } });
|
|
977
989
|
pack.status = 'OK';
|
|
978
990
|
pack.exitCode = EXIT.OK;
|
|
979
991
|
pack.mode = 'expand';
|
|
@@ -1005,6 +1017,7 @@ function handleContextPackCommand({
|
|
|
1005
1017
|
recordReason = null;
|
|
1006
1018
|
} catch (error ) {
|
|
1007
1019
|
recordReason = error?.code === 'entry-evicted' ? 'entry-evicted' : classifyStoreFailure(error);
|
|
1020
|
+
Object.assign(pack.delivery, lockDiagnostics(error, root));
|
|
1008
1021
|
}
|
|
1009
1022
|
}
|
|
1010
1023
|
pack.delivery.recorded = expansionRecorded;
|
|
@@ -1112,6 +1125,7 @@ function deliverPack({
|
|
|
1112
1125
|
const form = json ? 'json' : 'markdown';
|
|
1113
1126
|
let deliveryRecorded = !noRecord;
|
|
1114
1127
|
let deliveryReason = noRecord ? 'no-record' : null;
|
|
1128
|
+
let deliveryLock = {};
|
|
1115
1129
|
pack.delivery.recorded = deliveryRecorded;
|
|
1116
1130
|
pack.delivery.reason = deliveryReason;
|
|
1117
1131
|
pack.delivery.key = noRecord ? null : `${pack.delivery.packDigest}.${form}.${budget.harness?.id || 'unstated'}`;
|
|
@@ -1172,6 +1186,7 @@ function deliverPack({
|
|
|
1172
1186
|
pack = trimmed;
|
|
1173
1187
|
pack.delivery.recorded = deliveryRecorded;
|
|
1174
1188
|
pack.delivery.reason = deliveryReason;
|
|
1189
|
+
Object.assign(pack.delivery, deliveryLock);
|
|
1175
1190
|
pack.delivery.key = deliveryRecorded ? `${pack.delivery.packDigest}.${form}.${budget.harness?.id || 'unstated'}` : null;
|
|
1176
1191
|
pack.accounting.tokens = makeTokenRecord(json ? jsonBytes : mdBytes);
|
|
1177
1192
|
pack.omissions = pack.mode === 'bare' ? pack.omissions : compose.groupOmissions(pack.deliveryOmissions, !noRecord);
|
|
@@ -1221,6 +1236,8 @@ function deliverPack({
|
|
|
1221
1236
|
} catch (error ) {
|
|
1222
1237
|
deliveryRecorded = false;
|
|
1223
1238
|
deliveryReason = classifyStoreFailure(error);
|
|
1239
|
+
deliveryLock = lockDiagnostics(error, root);
|
|
1240
|
+
Object.assign(pack.delivery, deliveryLock);
|
|
1224
1241
|
pack.delivery.key = null;
|
|
1225
1242
|
pack.delivery.recorded = deliveryRecorded;
|
|
1226
1243
|
pack.delivery.reason = deliveryReason;
|
|
@@ -1271,10 +1288,13 @@ function resolveDeltaBasis({ root, source = root, taskId, slug, since, budget, p
|
|
|
1271
1288
|
function gatherMandatory({ root, taskId, feature } ) {
|
|
1272
1289
|
let reqText = null;
|
|
1273
1290
|
let reqPath = 'requirement.md';
|
|
1291
|
+
// The folder lives in `featureRoot`, the checkout that holds it: the main checkout when the
|
|
1292
|
+
// checkout the verb runs in has none of its own.
|
|
1293
|
+
const folder = feature.featureFolder || feature.featureDir;
|
|
1274
1294
|
if (!feature.error && feature.featureDir) {
|
|
1275
1295
|
reqPath = feature.paths?.requirement
|
|
1276
|
-
? path.resolve(feature.repoRoot || root, feature.paths.requirement)
|
|
1277
|
-
: path.join(
|
|
1296
|
+
? path.resolve(feature.featureRoot || feature.repoRoot || root, feature.paths.requirement)
|
|
1297
|
+
: path.join(folder, 'intention', 'requirement.md');
|
|
1278
1298
|
try {
|
|
1279
1299
|
if (fs.existsSync(reqPath)) {
|
|
1280
1300
|
reqText = fs.readFileSync(reqPath, 'utf8');
|
|
@@ -1288,10 +1308,10 @@ function gatherMandatory({ root, taskId, feature } ) {
|
|
|
1288
1308
|
if (!feature.error && feature.featureDir && taskId) {
|
|
1289
1309
|
let planPath = null;
|
|
1290
1310
|
if (feature.paths?.plan) {
|
|
1291
|
-
planPath = path.resolve(feature.repoRoot || root, feature.paths.plan);
|
|
1311
|
+
planPath = path.resolve(feature.featureRoot || feature.repoRoot || root, feature.paths.plan);
|
|
1292
1312
|
} else {
|
|
1293
|
-
const nestedPlan = path.join(
|
|
1294
|
-
const flatPlan = path.join(
|
|
1313
|
+
const nestedPlan = path.join(folder, 'plan', 'plan.md');
|
|
1314
|
+
const flatPlan = path.join(folder, 'plan.md');
|
|
1295
1315
|
if (fs.existsSync(nestedPlan)) planPath = nestedPlan;
|
|
1296
1316
|
else if (fs.existsSync(flatPlan)) planPath = flatPlan;
|
|
1297
1317
|
}
|
|
@@ -1320,6 +1340,27 @@ function gatherMandatory({ root, taskId, feature } ) {
|
|
|
1320
1340
|
});
|
|
1321
1341
|
}
|
|
1322
1342
|
|
|
1343
|
+
/**
|
|
1344
|
+
* The feature a pack is compiled for, as `{slug, feature}`. A `--slug` the caller typed wins; without
|
|
1345
|
+
* one, a task id that names an existing feature folder (in this checkout, the state root's, or the
|
|
1346
|
+
* checkout the branch's feature was found in) is that feature's own task, ahead of the branch's feature, so the
|
|
1347
|
+
* objective, artifacts and records all select the same feature (a pack for feature B's task id run
|
|
1348
|
+
* on feature A's branch). The common case, a task id that names no feature, resolves nothing extra.
|
|
1349
|
+
* @param {{source: string, taskId: string, slug?: string|null, typed?: string|null}} options
|
|
1350
|
+
* `typed`: the `--slug` the caller typed, when it is known; absent means `slug` was given on purpose.
|
|
1351
|
+
*/
|
|
1352
|
+
function resolvePackFeature({ source, stateRoot = source, taskId, slug, typed } ) {
|
|
1353
|
+
const feature = resolveFeature({ projectRoot: source, slug: slug || null });
|
|
1354
|
+
if (typed || (typed === undefined && slug) || !isSafeSlug(taskId) || (!feature.error && feature.paths?.feature_slug === taskId)) return { slug, feature };
|
|
1355
|
+
const roots = new Set ([source, stateRoot, feature.repoRoot, feature.paths?.repo_root].filter((root) => typeof root === 'string' && root !== ''));
|
|
1356
|
+
if (![...roots].some((root) => fs.existsSync(path.join(root, 'agent-docs', 'doflow', taskId)))) return { slug, feature };
|
|
1357
|
+
try {
|
|
1358
|
+
const own = resolveFeature({ projectRoot: source, slug: taskId });
|
|
1359
|
+
if (!own.error && fs.existsSync(own.featureFolder || own.featureDir || '')) return { slug: taskId, feature: own };
|
|
1360
|
+
} catch { /* a failed lookup keeps the branch's feature */ }
|
|
1361
|
+
return { slug, feature };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1323
1364
|
/**
|
|
1324
1365
|
* 078 IC-001: the context pack `emitContext()` delivers on `launch` and `delta`, compiled by
|
|
1325
1366
|
* compileContextPack() through the verb's own steps, with `maxBytes` as the budget and `since` the
|
|
@@ -1328,7 +1369,9 @@ function gatherMandatory({ root, taskId, feature } ) {
|
|
|
1328
1369
|
*/
|
|
1329
1370
|
function packForEmission({ root, stateRoot = root, taskId = 'default', taskClass = 'feature', slug = null, harness = null, maxBytes, since = null, noRecord = false, env = process.env } ) {
|
|
1330
1371
|
const budget = resolveBudget({ taskClass, maxBytes, harnessFlag: harness, env });
|
|
1331
|
-
const
|
|
1372
|
+
const resolved = resolvePackFeature({ source: root, stateRoot, taskId, slug, typed: slug || null });
|
|
1373
|
+
slug = resolved.slug;
|
|
1374
|
+
const feature = resolved.feature;
|
|
1332
1375
|
// 115 C-2: the delivery record goes where `context-pack` puts it, under the state root; the feature
|
|
1333
1376
|
// folder, the plan and the hashed source files are read in `root`, the checkout emitting.
|
|
1334
1377
|
const basis = resolveDeltaBasis({ root: stateRoot, source: root, taskId, slug, since, budget, profiles: null });
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
// Integration (IC-018). One dispatch per call, under the store's lease: `cherry-pick --no-commit`
|
|
18
18
|
// of `C` on the integration HEAD, `write-tree`, `commit-tree` on the pre-integration head, then
|
|
19
19
|
// `update-ref` naming that head as the expected old value, so a concurrent move of the branch fails
|
|
20
|
-
// the update instead of being overwritten (DEC-050).
|
|
21
|
-
// (
|
|
20
|
+
// the update instead of being overwritten (DEC-050). The new commit is recorded as the lease's
|
|
21
|
+
// `pickedHead` before `update-ref` runs (RT-10), so a stop between the two is recoverable (IC-032). Verification follows on the integrated HEAD
|
|
22
|
+
// (IC-019), then B.3's end rule finishes or releases the 080 attempt inside the dispatch lock, once
|
|
23
|
+
// the integration worktree is found on its branch, at this integration's head and clean again.
|
|
22
24
|
// Undo is `reset --hard` to the pre-integration head, run only while the integration branch is
|
|
23
25
|
// checked out there and points at a head this integration made (IC-020).
|
|
24
26
|
//
|
|
@@ -310,7 +312,7 @@ function endStale(store , dispatch , reason , ctx , now
|
|
|
310
312
|
* `ctx` is B.3's 080 context (`ledgerFile`, `roots`, `slug`, `env`, `cwd`, `fsImpl`, `now`); `env`
|
|
311
313
|
* is the environment git and the verification inherit, less the repository variables. `holder` is
|
|
312
314
|
* the lease holder `{ pid, start }` (this process by default). `beforeUpdateRef` is a test seam
|
|
313
|
-
* called
|
|
315
|
+
* called after the lease records the commit and before `update-ref`, and `afterLease` one called between steps 1 and 2;
|
|
314
316
|
* the loop passes neither.
|
|
315
317
|
*
|
|
316
318
|
* Resolves `{ outcome, dispatchId, task, token, ... }`, `outcome` one of `none`, `not-running`,
|
|
@@ -446,15 +448,19 @@ async function integrateOnce({
|
|
|
446
448
|
const made = runGit(integration.worktree, ['commit-tree', tree.stdout.trim(), '-p', preHead, '-m', `${task}: dispatch ${dispatchId} token ${token}`], { env: identityEnv, timeout: WRITE_TIMEOUT_MS });
|
|
447
449
|
const commit = made.stdout.trim();
|
|
448
450
|
if (!made.ok || !SHA.test(commit)) return releaseWithError('commit-tree-failed', made.status, null);
|
|
451
|
+
// RT-10: the commit is recorded in this dispatch's lease BEFORE the ref moves, so a stop between
|
|
452
|
+
// the two leaves a head the lease rule (IC-032) knows as this integration's. Not recorded (the lease
|
|
453
|
+
// is no longer this dispatch's), no ref move.
|
|
454
|
+
const recorded = mutateDispatch(file, (store ) => {
|
|
455
|
+
if (!store.lease || store.lease.dispatchId !== dispatchId) return { write: false, result: false };
|
|
456
|
+
store.lease.pickedHead = commit;
|
|
457
|
+
return { write: true, result: true };
|
|
458
|
+
});
|
|
459
|
+
if (!recorded) return releaseWithError('picked-head-unrecorded', null, null);
|
|
449
460
|
if (beforeUpdateRef) await beforeUpdateRef({ worktree: integration.worktree, branch: integration.branch, preHead, commit });
|
|
450
461
|
const moved = runGit(integration.worktree, ['update-ref', `refs/heads/${integration.branch}`, commit, preHead], { env: identityEnv, timeout: WRITE_TIMEOUT_MS });
|
|
451
462
|
if (!moved.ok) return releaseWithError('update-ref-failed', moved.status, null);
|
|
452
463
|
pickedHead = commit;
|
|
453
|
-
mutateDispatch(file, (store ) => {
|
|
454
|
-
if (!store.lease || store.lease.dispatchId !== dispatchId) return { write: false, result: null };
|
|
455
|
-
store.lease.pickedHead = commit;
|
|
456
|
-
return { write: true, result: null };
|
|
457
|
-
});
|
|
458
464
|
}
|
|
459
465
|
}
|
|
460
466
|
|
|
@@ -487,6 +493,19 @@ async function integrateOnce({
|
|
|
487
493
|
}, record);
|
|
488
494
|
}
|
|
489
495
|
|
|
496
|
+
// A passing verification must also have left the integration worktree as it found it: on its branch,
|
|
497
|
+
// at this integration's head, listing nothing. Anything else is not this integration's to reset, so
|
|
498
|
+
// nothing is undone and the work stays for recovery; the run blocks instead of completing.
|
|
499
|
+
const post = integrationClean({ ...where, preHead: pickedHead ?? preHead });
|
|
500
|
+
if (!post.clean) {
|
|
501
|
+
return settle((store, d) => {
|
|
502
|
+
record(store);
|
|
503
|
+
retryLater(store, d);
|
|
504
|
+
blockRun(store, { task, kind: 'integration-dirty', reason: `after-verification:${post.reason}`, paths: post.paths });
|
|
505
|
+
return { ...base, outcome: 'integration-dirty', reason: `after-verification:${post.reason}`, paths: post.paths, verification };
|
|
506
|
+
}, record);
|
|
507
|
+
}
|
|
508
|
+
|
|
490
509
|
const after = readLedger(ctx);
|
|
491
510
|
const finished = mutateDispatch(file, (store ) => {
|
|
492
511
|
const d = store.dispatches[dispatchId];
|
|
@@ -951,6 +951,13 @@ async function launchChildren(p ) {
|
|
|
951
951
|
const started = Date.now();
|
|
952
952
|
const granter = current.dispatches[req.granterDispatchId];
|
|
953
953
|
const who = { dispatchId: req.granterDispatchId, task: granter ? granter.task : null, token: granter ? granter.token : null };
|
|
954
|
+
// RR-7: a launch whose record write was refused keeps its worktree and child run on the request
|
|
955
|
+
// (RR-5); launching it again would reuse the tree and overwrite that child run id.
|
|
956
|
+
if (req.childRunId != null) {
|
|
957
|
+
refuse(req.requestId, 'child-run-recorded');
|
|
958
|
+
p.act('launch-child', who, 'refused', `${req.requestId} child-run-recorded`, Date.now() - started);
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
954
961
|
if (!granter || granter.phase !== 'launched' || !granter.workerRunId) {
|
|
955
962
|
refuse(req.requestId, 'granter-not-running');
|
|
956
963
|
p.act('launch-child', who, 'refused', `${req.requestId} granter-not-running`, Date.now() - started);
|
|
@@ -24,9 +24,15 @@ const processTree = require('../worker/process-tree.js');
|
|
|
24
24
|
const { EvidenceLedger } = require('../evidence-ledger.js');
|
|
25
25
|
const { emitVerifyTelemetry } = require('../verification/telemetry-emit.js');
|
|
26
26
|
|
|
27
|
-
/** The
|
|
28
|
-
*
|
|
29
|
-
|
|
27
|
+
/** The repository-local variables of `git rev-parse --local-env-vars` (git 2.50): inherited, they would point git at
|
|
28
|
+
* another repository. Hardcoded so no git process is spawned to compute it. Fetch (`packages/fetch.ts`) strips these
|
|
29
|
+
* and also `GIT_ALLOW_PROTOCOL`; the integration step and the review checks inherit this list. `GIT_CONFIG*` is left
|
|
30
|
+
* out on purpose: CI injects authentication through it. */
|
|
31
|
+
const REPO_ENV = Object.freeze([
|
|
32
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_OBJECT_DIRECTORY', 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_IMPLICIT_WORK_TREE',
|
|
33
|
+
'GIT_GRAFT_FILE', 'GIT_INDEX_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_PREFIX',
|
|
34
|
+
'GIT_SHALLOW_FILE', 'GIT_COMMON_DIR',
|
|
35
|
+
]);
|
|
30
36
|
/** How long the streams may stay open after the leader's exit (a grandchild may hold them). */
|
|
31
37
|
const DRAIN_MS = 500;
|
|
32
38
|
/** Bound on the `git rev-parse HEAD` read of the verified head. */
|
|
@@ -9,6 +9,7 @@ const { REPO_ROOT } = require('../helper/repo-root.js');
|
|
|
9
9
|
const { updateTaskState, readTaskState, mergeRecords } = require('./task-state.js');
|
|
10
10
|
const { taskRecord } = require('./task-scope.js');
|
|
11
11
|
const { assertNoScoreFields } = require('./evidence-fields.js');
|
|
12
|
+
const { isUserContextPath } = require('./intake/taxonomy.js');
|
|
12
13
|
|
|
13
14
|
const VALID_EVIDENCE_KINDS = new Set([
|
|
14
15
|
'exact-search',
|
|
@@ -25,6 +26,11 @@ const VALID_EVIDENCE_KINDS = new Set([
|
|
|
25
26
|
|
|
26
27
|
const VALID_PROVENANCE = new Set(['extracted', 'inferred', 'asserted']);
|
|
27
28
|
|
|
29
|
+
/** The user's own files (feature 136's intake) and the one source allowed to cite them. */
|
|
30
|
+
const USER_CONTEXT_DIR = 'additional-context';
|
|
31
|
+
const USER_PROVIDER = 'doflow.intake';
|
|
32
|
+
const USER_PROVIDED_CAPABILITY = 'user-provided';
|
|
33
|
+
|
|
28
34
|
/** An item's lifecycle status, distinct from `freshness.status` (a snapshot judgment about the
|
|
29
35
|
* file the item points at, which a later edit can only ever move toward STALE, never back).
|
|
30
36
|
* 'active' is the only non-terminal value; 'superseded' is terminal and permanent — mirrors
|
|
@@ -154,6 +160,18 @@ class EvidenceLedger {
|
|
|
154
160
|
capability: 'general',
|
|
155
161
|
};
|
|
156
162
|
|
|
163
|
+
// A file in a feature's additional-context folder is the user's data, not a repo observation:
|
|
164
|
+
// it is cited only with the intake's user-provided source, wherever the checkout holding it sits.
|
|
165
|
+
if (typeof item.locator?.file === 'string'
|
|
166
|
+
&& isUserContextPath(item.locator.file)
|
|
167
|
+
&& !(source.provider === USER_PROVIDER && source.capability === USER_PROVIDED_CAPABILITY)) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Evidence locator '${item.locator.file}' is in a feature's ${USER_CONTEXT_DIR} folder; cite it with source `
|
|
170
|
+
+ `{ provider: '${USER_PROVIDER}', capability: '${USER_PROVIDED_CAPABILITY}' }, not `
|
|
171
|
+
+ `{ provider: '${source.provider}', capability: '${source.capability}' }`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
157
175
|
const freshness = {
|
|
158
176
|
gitCommit: item.freshness?.gitCommit || null,
|
|
159
177
|
fileHash: item.freshness?.fileHash || null,
|
|
@@ -24,6 +24,8 @@ const { resolveActiveFeature } = require('./feature-resolve.js');
|
|
|
24
24
|
const SCHEMA = 1;
|
|
25
25
|
const POINTER_DIR = path.join('.doflow', 'state', 'feature');
|
|
26
26
|
const POINTER_NAME = /^[0-9a-f]{16}\.json$/;
|
|
27
|
+
/** The single-file pointer written before row 135 (one per store, not per checkout). It is never migrated or deleted. */
|
|
28
|
+
const LEGACY_FILE = path.join('.doflow', 'state', 'feature.json');
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
|
|
@@ -145,7 +147,14 @@ function describeActive({ storeRoot, cwd } )
|
|
|
145
147
|
const file = pointerFile(storeRoot, here.key);
|
|
146
148
|
const read = readEntry(file);
|
|
147
149
|
if (read.status === 'unreadable') return { ...base, state: 'unreadable', reason: read.reason, file: path.relative(storeRoot, file) };
|
|
148
|
-
if (read.status === 'none')
|
|
150
|
+
if (read.status === 'none') {
|
|
151
|
+
// A pre-135 feature.json is not read as an answer, but it is not silence either: say it is there and how to retire it.
|
|
152
|
+
const legacy = path.join(storeRoot, LEGACY_FILE);
|
|
153
|
+
if (fs.existsSync(legacy)) {
|
|
154
|
+
return { ...base, state: 'unreadable', file: LEGACY_FILE, reason: 'a pre-row-135 single-file pointer that is not migrated to the per-checkout pointer' };
|
|
155
|
+
}
|
|
156
|
+
return { ...base, state: 'none' };
|
|
157
|
+
}
|
|
149
158
|
const entry = read.data ;
|
|
150
159
|
const mismatch = entry.branch !== current.branch || (derived !== null && derived !== entry.slug);
|
|
151
160
|
if (mismatch) return { ...base, state: 'mismatch', pointer: entry };
|
package/src/runtime/health.js
CHANGED
|
@@ -30,6 +30,10 @@ const { REPO_ROOT } = require('../helper/repo-root.js');
|
|
|
30
30
|
const { readGrants, detectedProjectRoot } = require('./packages/grants.js');
|
|
31
31
|
const { readState, grantRevoked } = require('./packages/state.js');
|
|
32
32
|
const { transcriptsDoctorView } = require('./transcripts/registry.js');
|
|
33
|
+
const { readLedger, stateRoot } = require('../state/index.js');
|
|
34
|
+
const { readRegularFileText } = require('./task-scope.js');
|
|
35
|
+
const { migrationPending } = require('./lifecycle/store-upkeep.js');
|
|
36
|
+
const { isRunNamespaceDir, isSupersededFlatRun } = require('./storage/work-status.js');
|
|
33
37
|
|
|
34
38
|
/** Answered a probe. */
|
|
35
39
|
const HEALTHY = 'HEALTHY';
|
|
@@ -611,22 +615,21 @@ function mcpConsultationsView({ projectRoot, fsImpl } ) {
|
|
|
611
615
|
const listed = [];
|
|
612
616
|
const readRunFile = (rel , name , namespace ) => {
|
|
613
617
|
let run;
|
|
614
|
-
try { run = JSON.parse(
|
|
618
|
+
try { run = JSON.parse(readRegularFileText(path.join(dir, rel), fsImpl) ?? 'null'); } catch { run = null; }
|
|
615
619
|
listed.push({ rel, name, namespace, run });
|
|
616
620
|
};
|
|
617
621
|
let entries = [];
|
|
618
622
|
try { entries = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { entries = []; }
|
|
619
623
|
for (const entry of entries) if (entry.name.endsWith('.json') && !entry.isDirectory()) readRunFile(entry.name, entry.name, '');
|
|
620
624
|
for (const entry of entries) {
|
|
621
|
-
if (!entry.isDirectory() ||
|
|
625
|
+
if (!entry.isDirectory() || !isRunNamespaceDir(entry.name)) continue;
|
|
622
626
|
let inner = [];
|
|
623
627
|
try { inner = fsImpl.readdirSync(path.join(dir, entry.name), { withFileTypes: true }); } catch { inner = []; }
|
|
624
628
|
for (const file of inner) if (file.name.endsWith('.json') && !file.isDirectory()) readRunFile(`${entry.name}/${file.name}`, file.name, entry.name);
|
|
625
629
|
}
|
|
626
630
|
listed.sort((a, b) => byText(a.rel, b.rel));
|
|
627
631
|
// A flat run whose namespaced copy exists is that copy's seed, not a second run.
|
|
628
|
-
const files = listed.filter((one) => one.namespace !== '' || !listed.
|
|
629
|
-
&& (typeof one.run?.featureSlug !== 'string' || one.run.featureSlug === other.namespace)));
|
|
632
|
+
const files = listed.filter((one) => one.namespace !== '' || !isSupersededFlatRun(one.name, one.run, listed.filter((other) => other.namespace !== '')));
|
|
630
633
|
let unreadableRuns = 0;
|
|
631
634
|
const bySkill = new Map();
|
|
632
635
|
for (const { name, run } of files) {
|
|
@@ -813,6 +816,59 @@ function nodeSecurityWarning(version )
|
|
|
813
816
|
};
|
|
814
817
|
}
|
|
815
818
|
|
|
819
|
+
/**
|
|
820
|
+
* The Claude permission lists a user's own array hides. `mergeSettings` keeps an existing array whole
|
|
821
|
+
* (a union would widen the user's own permissions), so a settings.json that already had a
|
|
822
|
+
* `permissions.allow`, `deny` or `ask` array never receives DoFlow's entries for that list. A file is
|
|
823
|
+
* judged only when the install ledger of its scope records a Claude `settings.json` projection (the
|
|
824
|
+
* ledger, not hook presence, so an install with Claude outside `hooks.harnesses` is judged too); the
|
|
825
|
+
* ledgers read are the global one and each project one on the walk from `projectRoot` up to the home
|
|
826
|
+
* directory, the same walk the reach check makes. The entries named are the shipped ones missing from
|
|
827
|
+
* the user's array, so the person can add them or leave the list as it is.
|
|
828
|
+
*/
|
|
829
|
+
function claudePermissionWarnings({ repoRoot, projectRoot, homeDir, fsImpl = fs } ) {
|
|
830
|
+
let managed ;
|
|
831
|
+
try {
|
|
832
|
+
managed = JSON.parse(fsImpl.readFileSync(path.join(repoRoot, 'core', 'harnesses', 'claude', 'settings', 'settings.json'), 'utf8'));
|
|
833
|
+
} catch { return []; }
|
|
834
|
+
const lists = managed?.permissions && typeof managed.permissions === 'object' ? managed.permissions : {};
|
|
835
|
+
const projects = (() => {
|
|
836
|
+
const found = [];
|
|
837
|
+
const home = path.resolve(homeDir);
|
|
838
|
+
for (let dir = path.resolve(projectRoot); dir !== home; dir = path.dirname(dir)) {
|
|
839
|
+
found.push(dir);
|
|
840
|
+
if (path.dirname(dir) === dir) break;
|
|
841
|
+
}
|
|
842
|
+
return found;
|
|
843
|
+
})();
|
|
844
|
+
const scopes = [
|
|
845
|
+
{ installRoot: path.resolve(homeDir), stateDir: stateRoot({ scope: 'global', homeDir }) },
|
|
846
|
+
...projects.map((installRoot) => ({ installRoot, stateDir: path.join(installRoot, '.doflow', 'state') })),
|
|
847
|
+
];
|
|
848
|
+
const warnings = [];
|
|
849
|
+
for (const { installRoot, stateDir } of scopes) {
|
|
850
|
+
let ledger ;
|
|
851
|
+
try { ledger = readLedger(stateDir, { fsImpl }); } catch { continue; }
|
|
852
|
+
const projected = (ledger?.resources || []).some((resource ) => resource?.harness === 'claude' && resource.kind === 'settings-file' && resource.identity === 'settings.json');
|
|
853
|
+
if (!projected) continue;
|
|
854
|
+
const file = path.join(installRoot, '.claude', 'settings.json');
|
|
855
|
+
let current ;
|
|
856
|
+
try { current = JSON.parse(fsImpl.readFileSync(file, 'utf8')); } catch { continue; }
|
|
857
|
+
for (const [list, shipped] of Object.entries (lists)) {
|
|
858
|
+
const own = current?.permissions?.[list];
|
|
859
|
+
if (!Array.isArray(own) || !Array.isArray(shipped)) continue;
|
|
860
|
+
const missing = shipped.filter((entry ) => !own.includes(entry));
|
|
861
|
+
if (!missing.length) continue;
|
|
862
|
+
warnings.push({
|
|
863
|
+
kind: 'claude-permissions-user-array',
|
|
864
|
+
subject: `${file}: permissions.${list}`,
|
|
865
|
+
detail: `your own permissions.${list} list wins whole, so DoFlow's entries are absent from it (${missing.join(', ')}); DoFlow never merges into a list you wrote, so add them yourself if you want that behaviour`,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return warnings;
|
|
870
|
+
}
|
|
871
|
+
|
|
816
872
|
function buildHealthReport({ repoRoot, projectRoot = process.cwd(), router, execFileImpl, fsImpl = fs, detector, adapters, env = process.env, nodeVersion = process.version, homeDir, tmpDir, now } = {}) {
|
|
817
873
|
const root = repoRoot || REPO_ROOT;
|
|
818
874
|
const registry = loadRegistry({ repoRoot: root });
|
|
@@ -877,8 +933,13 @@ function buildHealthReport({ repoRoot, projectRoot = process.cwd(), router, exec
|
|
|
877
933
|
.map((row ) => ({ kind: 'runtime-no-reach', subject: `${row.harness}:${row.scope}`, detail: `${row.reason}; fix: ${row.fix}` })),
|
|
878
934
|
];
|
|
879
935
|
const nodeWarning = nodeSecurityWarning(nodeVersion);
|
|
936
|
+
const home = homeDir ?? (typeof env?.HOME === 'string' && path.isAbsolute(env.HOME) ? env.HOME : os.homedir());
|
|
880
937
|
const warnings = [
|
|
881
938
|
...(nodeWarning ? [nodeWarning] : []),
|
|
939
|
+
...claudePermissionWarnings({ repoRoot: root, projectRoot, homeDir: home, fsImpl }),
|
|
940
|
+
...(migrationPending(path.resolve(projectRoot), { fsImpl })
|
|
941
|
+
? [{ kind: 'lifecycle-migration-pending', subject: 'lifecycle store', detail: 'the lifecycle store has not been copied yet from agent-docs/lifecycle; run any followup, lifecycle or goal command once without DOFLOW_UPKEEP=off. doctor wrote nothing.' }]
|
|
942
|
+
: []),
|
|
882
943
|
...freshness
|
|
883
944
|
.filter((item) => item.state === 'STALE')
|
|
884
945
|
.map((item) => ({ kind: 'index-stale', subject: item.providerId, detail: `${item.label} is behind the working tree; refresh with: ${item.refresh}` })),
|
|
@@ -1375,7 +1436,8 @@ async function handleDoctorCommand({ json = false, repoRoot, projectRoot = proce
|
|
|
1375
1436
|
|
|
1376
1437
|
if (report.warnings.length) {
|
|
1377
1438
|
console.log('\n[Warnings] — not blocking; a stale or unprobeable provider still degrades cleanly, and a Node below the security release of its line is named here');
|
|
1378
|
-
|
|
1439
|
+
// A subject at or past the column width (a file path) would run into its detail, so it gets a gap.
|
|
1440
|
+
for (const warning of report.warnings) console.log(` ${warning.subject.padEnd(18)}${warning.subject.length >= 18 ? ' ' : ''}${warning.detail}`);
|
|
1379
1441
|
}
|
|
1380
1442
|
|
|
1381
1443
|
console.log('\n[Findings]');
|
|
@@ -1399,6 +1461,7 @@ module.exports = {
|
|
|
1399
1461
|
probeProviders,
|
|
1400
1462
|
probeLocalProvider,
|
|
1401
1463
|
probeFreshness,
|
|
1464
|
+
claudePermissionWarnings,
|
|
1402
1465
|
PROVIDER_ARTIFACTS,
|
|
1403
1466
|
newestSourceMtime,
|
|
1404
1467
|
detectProjectCommands,
|