@khoavu882/doflow 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/session-end.sh +21 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
- package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +25 -1
- package/core/registry/contribution-points.json +1 -1
- package/core/registry/intake.json +3 -3
- package/core/registry/lifecycle.json +256 -5
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/settings.json +12 -12
- package/core/registry/storage.json +1 -2
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
- package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
- package/core/shared/scripts/doflow/bin/doflow-run +18 -0
- package/core/shared/skills/do-design/SKILL.md +6 -2
- package/core/shared/skills/do-design/references/data_model_example.md +167 -0
- package/core/shared/templates/doflow/data-model-template.md +136 -102
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +54 -12
- package/src/adapters/codex/agents.js +15 -1
- package/src/adapters/codex/config.js +5 -2
- package/src/adapters/copilot/index.js +11 -4
- package/src/adapters/copy-tree.js +7 -0
- package/src/adapters/created-file.js +41 -0
- package/src/adapters/mcp-entries.js +11 -1
- package/src/adapters/opencode/index.js +32 -5
- package/src/adapters/pi/index.js +15 -4
- package/src/adapters/pi/mcp.js +44 -18
- package/src/cli/commands/remove.js +3 -1
- package/src/cli/commands/update.js +7 -3
- package/src/cli/index.js +24 -12
- package/src/cli/runtime-commands.js +1 -1
- package/src/cli/shared.js +5 -3
- package/src/helper/marker-merge.js +11 -7
- package/src/install/backup-set.js +7 -3
- package/src/install/backup.js +13 -7
- package/src/install/context.js +2 -1
- package/src/install/manifest.js +35 -11
- package/src/install/paths.js +38 -0
- package/src/install/targets.js +11 -2
- package/src/lifecycle/index.js +80 -3
- package/src/lifecycle/retired-harnesses.js +16 -1
- package/src/lifecycle/view.js +3 -2
- package/src/registry/index.js +21 -4
- package/src/release/docs-blocks.js +5 -1
- package/src/runtime/advice/doctor.js +2 -2
- package/src/runtime/brief-tools.js +2 -1
- package/src/runtime/capability-router.js +26 -3
- package/src/runtime/context/pointer.js +17 -5
- package/src/runtime/context-pack.js +48 -8
- package/src/runtime/dispatch/integrate.js +27 -8
- package/src/runtime/dispatch/loop.js +7 -0
- package/src/runtime/dispatch/verify-run.js +9 -3
- package/src/runtime/feature-pointer.js +10 -1
- package/src/runtime/health.js +9 -4
- package/src/runtime/implementation-gate.js +10 -2
- package/src/runtime/intake/engine.js +11 -9
- package/src/runtime/knowledge/code-retrieval.js +11 -5
- package/src/runtime/knowledge/semble-provider.js +12 -2
- package/src/runtime/lifecycle/cli.js +7 -5
- package/src/runtime/lifecycle/overview.js +32 -2
- package/src/runtime/lifecycle/release.js +16 -3
- package/src/runtime/lifecycle/retention.js +12 -15
- package/src/runtime/lifecycle/store-upkeep.js +13 -3
- package/src/runtime/mask.js +3 -3
- package/src/runtime/mcp-consultations.js +11 -5
- package/src/runtime/research-request.js +3 -1
- package/src/runtime/review/command.js +3 -7
- package/src/runtime/review/start.js +24 -10
- package/src/runtime/roadmap/loop.js +14 -3
- package/src/runtime/roadmap/rows.js +2 -1
- package/src/runtime/settings/files.js +3 -1
- package/src/runtime/settings/prepared-run.js +12 -2
- package/src/runtime/settings/resolve.js +13 -1
- package/src/runtime/settings/write.js +73 -2
- package/src/runtime/storage/registry.js +2 -2
- package/src/runtime/storage/roots.js +4 -18
- package/src/runtime/storage/rules.js +22 -46
- package/src/runtime/storage/work-status.js +14 -4
- package/src/runtime/task-scope.js +17 -2
- package/src/runtime/task-state.js +11 -4
- package/src/runtime/trace/analyze.js +4 -2
- package/src/runtime/trace/indicators.js +25 -20
- package/src/runtime/trace/spans.js +4 -5
- package/src/runtime/verb-view.js +6 -5
- package/src/runtime/worker/command.js +4 -2
- package/src/runtime/worker/readiness.js +8 -4
- package/src/runtime/worker/supervisor.js +18 -6
- package/src/runtime/workflow-orchestrator.js +6 -2
package/src/install/manifest.js
CHANGED
|
@@ -25,6 +25,20 @@ function canonicalManifestPath(scopeRoot ) {
|
|
|
25
25
|
return path.join(path.resolve(scopeRoot), '.doflow', MANIFEST_FILE_NAME);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Atomic same-directory write of the manifest document. */
|
|
29
|
+
function atomicWrite(file , manifest , date ) {
|
|
30
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
31
|
+
// Security: the temp file must live next to `file` itself, not a shared os.tmpdir() — a
|
|
32
|
+
// world-writable /tmp plus a PID+timestamp-derived (i.e. guessable) name is a symlink-race
|
|
33
|
+
// waiting to happen (plant a symlink at the predicted path, the write clobbers its target).
|
|
34
|
+
// 'wx' (O_CREAT|O_EXCL) refuses to open through an existing symlink or file at all, and being
|
|
35
|
+
// on the same filesystem as `file` makes the final rename a guaranteed atomic same-fs op
|
|
36
|
+
// (os.tmpdir() offered no such guarantee — e.g. containers where /tmp is a separate tmpfs mount).
|
|
37
|
+
const tmp = path.join(path.dirname(file), `.install-manifest-${process.pid}-${date.getTime()}.json.tmp`);
|
|
38
|
+
fs.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
|
|
39
|
+
fs.renameSync(tmp, file); // atomic on the same filesystem, matches sync.sh's mktemp + mv
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
|
|
29
43
|
|
|
30
44
|
|
|
@@ -88,19 +102,29 @@ function writeManifest({ scopeRoot, claudeDir, scriptVersion, operation, repoRoo
|
|
|
88
102
|
const resources = managedResources ?? existingManagedResources;
|
|
89
103
|
if (resources !== undefined) manifest.managed_resources = resources;
|
|
90
104
|
|
|
91
|
-
|
|
92
|
-
// Security: the temp file must live next to `file` itself, not a shared os.tmpdir() — a
|
|
93
|
-
// world-writable /tmp plus a PID+timestamp-derived (i.e. guessable) name is a symlink-race
|
|
94
|
-
// waiting to happen (plant a symlink at the predicted path, the write clobbers its target).
|
|
95
|
-
// 'wx' (O_CREAT|O_EXCL) refuses to open through an existing symlink or file at all, and being
|
|
96
|
-
// on the same filesystem as `file` makes the final rename a guaranteed atomic same-fs op
|
|
97
|
-
// (os.tmpdir() offered no such guarantee — e.g. containers where /tmp is a separate tmpfs mount).
|
|
98
|
-
const tmp = path.join(path.dirname(file), `.install-manifest-${process.pid}-${date.getTime()}.json.tmp`);
|
|
99
|
-
fs.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
|
|
100
|
-
fs.renameSync(tmp, file); // atomic on the same filesystem, matches sync.sh's mktemp + mv
|
|
105
|
+
atomicWrite(file, manifest, date);
|
|
101
106
|
return file;
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
/**
|
|
110
|
+
* After a removal: stop listing `tools` as installed. Nothing else in the record changes except the
|
|
111
|
+
* operation and time, and a missing or unreadable manifest is left alone (nothing lists them).
|
|
112
|
+
* Returns whether the file was rewritten.
|
|
113
|
+
*/
|
|
114
|
+
function forgetManifestTools({ scopeRoot, tools, date = new Date() } ) {
|
|
115
|
+
const file = canonicalManifestPath(scopeRoot);
|
|
116
|
+
if (!fs.existsSync(file)) return false;
|
|
117
|
+
let manifest ;
|
|
118
|
+
try { manifest = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return false; }
|
|
119
|
+
const listed = tools.filter((tool) => manifest?.tools && Object.hasOwn(manifest.tools, tool));
|
|
120
|
+
if (!listed.length) return false;
|
|
121
|
+
for (const tool of listed) delete manifest.tools[tool];
|
|
122
|
+
manifest.last_operation = 'remove';
|
|
123
|
+
manifest.last_run = date.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
124
|
+
atomicWrite(file, manifest, date);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
104
128
|
/** null if no manifest yet. */
|
|
105
129
|
function readManifestFile(file ) {
|
|
106
130
|
if (!fs.existsSync(file)) return null;
|
|
@@ -141,5 +165,5 @@ function readManifest(claudeDir ) {
|
|
|
141
165
|
|
|
142
166
|
module.exports = {
|
|
143
167
|
LEGACY_MANIFEST_FILE_NAME, MANIFEST_FILE_NAME, manifestPath, canonicalManifestPath,
|
|
144
|
-
writeManifest, readManifest, readInstallManifest,
|
|
168
|
+
writeManifest, forgetManifestTools, readManifest, readInstallManifest,
|
|
145
169
|
};
|
package/src/install/paths.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): authored as `.ts`, run directly by Node
|
|
4
4
|
// native type-stripping — no build step, no runtime dependency. Body stays CommonJS so a `.js`
|
|
5
5
|
// caller's `require('../install/paths.js')` keeps working while the rest of the tree is still `.js`.
|
|
6
|
+
const fs = require('node:fs');
|
|
6
7
|
const path = require('node:path');
|
|
7
8
|
|
|
8
9
|
const DOFLOW_DIR_NAME = '.doflow';
|
|
@@ -72,7 +73,44 @@ function doflowPaths({ scopeRoot } ) {
|
|
|
72
73
|
};
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/** The one spelling of a path that ownership comparisons use: `fs.realpathSync` of the resolved path,
|
|
77
|
+
* or the resolved path itself when it does not exist (nothing to follow yet). macOS reaches the
|
|
78
|
+
* temp folder as both /var/... and /private/var/..., and a ledger row written under one spelling
|
|
79
|
+
* must match the other. */
|
|
80
|
+
function canonicalPath(target ) {
|
|
81
|
+
const resolved = path.resolve(target);
|
|
82
|
+
try { return fs.realpathSync(resolved); } catch { return resolved; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rebaseString(value , from , to ) {
|
|
86
|
+
if (value === from) return to;
|
|
87
|
+
return value.startsWith(from + path.sep) ? to + value.slice(from.length) : value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function rebaseDeep(value , from , to ) {
|
|
91
|
+
if (typeof value === 'string') return rebaseString(value, from, to);
|
|
92
|
+
if (Array.isArray(value)) return value.map((item) => rebaseDeep(item, from, to));
|
|
93
|
+
if (value && typeof value === 'object') {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const key of Object.keys(value)) out[key] = rebaseDeep(value[key], from, to);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A ledger read from disk, with every path that names the scope root under a different spelling of
|
|
102
|
+
* it (the aliased path an earlier install recorded) rewritten to `scopeRoot`, the canonical one.
|
|
103
|
+
* The stored format is unchanged; a ledger already in canonical form, with no scope root of its
|
|
104
|
+
* own, or recording an unrelated directory is returned as it came. */
|
|
105
|
+
function canonicalLedger(ledger , scopeRoot ) {
|
|
106
|
+
const recorded = ledger && typeof ledger.scopeRoot === 'string' && ledger.scopeRoot ? path.resolve(ledger.scopeRoot) : null;
|
|
107
|
+
if (!recorded || recorded === scopeRoot || canonicalPath(recorded) !== scopeRoot) return ledger;
|
|
108
|
+
return rebaseDeep(ledger, recorded, scopeRoot);
|
|
109
|
+
}
|
|
110
|
+
|
|
75
111
|
module.exports = {
|
|
112
|
+
canonicalPath,
|
|
113
|
+
canonicalLedger,
|
|
76
114
|
DOFLOW_DIR_NAME,
|
|
77
115
|
BACKUP_DIR_NAME,
|
|
78
116
|
INSTALL_MANIFEST_FILE_NAME,
|
package/src/install/targets.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// caller's `require('../install/targets.js')` keeps working while the rest of the tree is still `.js`.
|
|
15
15
|
const os = require('node:os');
|
|
16
16
|
const path = require('node:path');
|
|
17
|
+
const { canonicalPath } = require('./paths.js');
|
|
17
18
|
|
|
18
19
|
const VALID = ['claude', 'codex', 'copilot', 'kiro', 'opencode', 'pi', 'antigravity'];
|
|
19
20
|
|
|
@@ -58,6 +59,14 @@ function resolveTargets(requested , { registry }
|
|
|
58
59
|
return [...new Set(targets)];
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/** The harnesses an install manifest lists as installed, in VALID order; [] when there is no
|
|
63
|
+
* manifest or it lists none DoFlow still knows (a retired id is not a target). `update` without
|
|
64
|
+
* `-t` acts on these instead of DEFAULT_TARGETS, so it never installs a harness the scope lacks. */
|
|
65
|
+
function installedTargets(manifest ) {
|
|
66
|
+
const tools = manifest?.tools ?? {};
|
|
67
|
+
return VALID.filter((id) => Object.hasOwn(tools, id) && tools[id]?.installed !== false);
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
|
|
62
71
|
|
|
63
72
|
|
|
@@ -91,7 +100,7 @@ function resolveTargets(requested , { registry }
|
|
|
91
100
|
*/
|
|
92
101
|
function toolDirs(opts = {}) {
|
|
93
102
|
const { global = false, projectRoot = '.' } = opts;
|
|
94
|
-
const root = global ? os.homedir() :
|
|
103
|
+
const root = global ? os.homedir() : canonicalPath(projectRoot);
|
|
95
104
|
return {
|
|
96
105
|
claude: path.join(root, '.claude'),
|
|
97
106
|
codex: path.join(root, '.codex'),
|
|
@@ -102,4 +111,4 @@ function toolDirs(opts = {}) {
|
|
|
102
111
|
};
|
|
103
112
|
}
|
|
104
113
|
|
|
105
|
-
module.exports = { VALID, DEFAULT_TARGETS, resolveTargets, toolDirs };
|
|
114
|
+
module.exports = { VALID, DEFAULT_TARGETS, resolveTargets, installedTargets, toolDirs };
|
package/src/lifecycle/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const { renderMcpIndex } = require('./mcp-index.js');
|
|
|
17
17
|
const { hasBashCapableShell } = require('./bash-availability.js');
|
|
18
18
|
const { planHookCompanions } = require('./hook-step.js');
|
|
19
19
|
const { tieredRegistry, hookHoldersFromLedger, hooksTieredOff, withoutHookSources, planHookTierRemovals } = require('./hook-tier.js');
|
|
20
|
+
const { forgetManifestTools } = require('../install/manifest.js');
|
|
20
21
|
const { readLock, lockPackages } = require('../state/lockfile.js');
|
|
21
22
|
const { resolveRegistrySource } = require('../adapters/resolve-source.js');
|
|
22
23
|
const { planRetiredHarnesses, planHandovers, handoversFor, applyRetiredHarnesses, retiredReleaseKeys, claimKey } = require('./retired-harnesses.js');
|
|
@@ -118,6 +119,34 @@ function markUnownedAbsences(statuses , ledger , harness ) {
|
|
|
118
119
|
));
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/** An adapter verifies every asset the current source declares for it, comparing disk to source. For
|
|
123
|
+
* an asset the removal leaves this harness nothing to delete from, that is not its business: the
|
|
124
|
+
* files belong to another harness's rows (retained removals, or an older install that gave this
|
|
125
|
+
* harness no rows there: a v1.14.2 Pi install recorded none for the guidance, scripts and runtime
|
|
126
|
+
* trees) and so carry that install's legacy bytes. Those bytes read as conflicts on files this
|
|
127
|
+
* removal never touches, and the ones that do match source would be written back as NEW rows for
|
|
128
|
+
* the harness being removed. So for such an asset the `conflict` status, its `<assetId>: ...`
|
|
129
|
+
* conflict strings and its copy-tree resources are dropped. An asset the plan deletes from, or one
|
|
130
|
+
* where a ledger row has no removal, is judged exactly as before. Without a ledger nothing is
|
|
131
|
+
* proven settled, so the strict reading is kept. */
|
|
132
|
+
function dropSettledAssetVerification(verification , statuses , conflicts , removals , ledger ) {
|
|
133
|
+
const unchanged = { resources: verification.resources, statuses, conflicts };
|
|
134
|
+
if (!ledger) return unchanged;
|
|
135
|
+
const rows = (ledger.resources || []).filter((resource ) => resource.harness === verification.harness);
|
|
136
|
+
const settled = (assetId ) => !removals.some((change ) => change.assetId === assetId && !change.retained)
|
|
137
|
+
&& !rows.some((row ) => row.assetId === assetId && !removals.some((change ) => matchesChange(row, change)));
|
|
138
|
+
const settledAssets = new Set([
|
|
139
|
+
...statuses.filter((status ) => status?.status === 'conflict').map((status ) => status.assetId),
|
|
140
|
+
...verification.resources.filter((resource ) => resource?.kind === 'copy-tree-file').map((resource ) => resource.assetId),
|
|
141
|
+
].filter((assetId ) => assetId && settled(assetId)));
|
|
142
|
+
if (!settledAssets.size) return unchanged;
|
|
143
|
+
return {
|
|
144
|
+
resources: verification.resources.filter((resource ) => !(resource?.kind === 'copy-tree-file' && settledAssets.has(resource.assetId))),
|
|
145
|
+
statuses: statuses.map((status ) => (settledAssets.has(status?.assetId) && status.status === 'conflict' ? { ...status, status: 'not-managed' } : status)),
|
|
146
|
+
conflicts: conflicts.filter((reason ) => ![...settledAssets].some((assetId) => String(reason).startsWith(`${assetId}: `))),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
function normalizeRemovalVerification(verification , changes , ledger ) {
|
|
122
151
|
const removals = changes.filter((change ) => change.operation === 'remove' && change.harness === verification.harness);
|
|
123
152
|
if (!removals.length) return verification;
|
|
@@ -128,7 +157,9 @@ function normalizeRemovalVerification(verification , changes , ledger
|
|
|
128
157
|
// before.
|
|
129
158
|
const removed = removals.filter((change ) => !change.retained);
|
|
130
159
|
let statuses = [...verification.statuses];
|
|
131
|
-
|
|
160
|
+
let conflicts = [...(verification.conflicts || [])];
|
|
161
|
+
let resources ;
|
|
162
|
+
({ resources, statuses, conflicts } = dropSettledAssetVerification(verification, statuses, conflicts, removals, ledger));
|
|
132
163
|
for (const change of removed) {
|
|
133
164
|
const outcome = reconcileRemovedChange(verification, statuses, change);
|
|
134
165
|
if (outcome.conflict) { conflicts.push(outcome.conflict); continue; }
|
|
@@ -137,7 +168,7 @@ function normalizeRemovalVerification(verification , changes , ledger
|
|
|
137
168
|
}
|
|
138
169
|
// After reconciling the plan's own removals, so this only ever judges what the plan left over.
|
|
139
170
|
statuses = markUnownedAbsences(statuses, ledger, verification.harness);
|
|
140
|
-
return { ...verification, statuses, conflicts, ok: removalVerificationOk(verification.ok, statuses, conflicts) };
|
|
171
|
+
return { ...verification, resources, statuses, conflicts, ok: removalVerificationOk(verification.ok, statuses, conflicts) };
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
/** An adapter may initially report ok:false because a now-removed resource is missing. Once
|
|
@@ -913,7 +944,15 @@ function updateLedger({ ledger, scope, scopeRoot, verifications, changes, recove
|
|
|
913
944
|
// Record the relocation, then sweep the stale copy — but only when its bytes still hash to the
|
|
914
945
|
// fingerprint DoFlow last verified there. A mismatch means someone edited that file after us:
|
|
915
946
|
// their content outranks our tidiness, so the file stays and the unswept tombstone says why.
|
|
947
|
+
// An MCP entry is one member of a file the user shares, and the adapter has already taken it out
|
|
948
|
+
// of (or released it in) the old file by entry. A whole-file tombstone for it would carry the
|
|
949
|
+
// entry's fingerprint, never match the file's bytes and so stay unswept for good. Earlier runs
|
|
950
|
+
// left such rows behind for Pi's mcp.json (which borrows the guidance.codex-pointer asset id);
|
|
951
|
+
// they are retired here, and no file is touched.
|
|
952
|
+
next.tombstones = next.tombstones.filter((entry ) => entry.sweptAt || entry.harness !== 'pi'
|
|
953
|
+
|| entry.assetId !== 'guidance.codex-pointer' || path.basename(String(entry.fromTarget)) !== 'mcp.json');
|
|
916
954
|
for (const resource of next.resources) {
|
|
955
|
+
if (resource.kind === 'mcp-server') continue;
|
|
917
956
|
const prior = priorByIdentity.get(`${resource.harness}\u0000${resource.assetId}\u0000${resource.identity ?? ''}`);
|
|
918
957
|
if (!prior || prior.target === resource.target) continue;
|
|
919
958
|
if (!next.tombstones.some((entry ) => entry.harness === resource.harness
|
|
@@ -925,6 +964,16 @@ function updateLedger({ ledger, scope, scopeRoot, verifications, changes, recove
|
|
|
925
964
|
});
|
|
926
965
|
}
|
|
927
966
|
}
|
|
967
|
+
// A legacy retirement: an adapter removed an old projection that no row owned (its change carries
|
|
968
|
+
// `legacyRetirement.toTarget`, where the claim lives now). Journaled as a tombstone already swept.
|
|
969
|
+
for (const change of changes) {
|
|
970
|
+
if (!change.legacyRetirement || change.operation !== 'remove' || change.retained) continue;
|
|
971
|
+
const { harness, assetId, target: fromTarget, fingerprint } = change;
|
|
972
|
+
const toTarget = change.legacyRetirement.toTarget;
|
|
973
|
+
if (next.tombstones.some((entry ) => entry.harness === harness && entry.fromTarget === fromTarget && entry.toTarget === toTarget)) continue;
|
|
974
|
+
const at = new Date().toISOString();
|
|
975
|
+
next.tombstones.push({ harness, assetId, fromTarget, toTarget, fingerprint, movedAt: at, sweptAt: at });
|
|
976
|
+
}
|
|
928
977
|
const claims = new Set(next.resources.map((resource ) => resource.target));
|
|
929
978
|
for (const entry of next.tombstones) {
|
|
930
979
|
if (entry.sweptAt || !fsImpl.existsSync(entry.fromTarget) || claims.has(entry.fromTarget)) continue;
|
|
@@ -1152,9 +1201,37 @@ function settleSuspendedProjection({ homeDir } ) {
|
|
|
1152
1201
|
}
|
|
1153
1202
|
}
|
|
1154
1203
|
|
|
1204
|
+
/** Remove the directories a removal emptied, from each removed file's folder upward, tool root
|
|
1205
|
+
* included (`.pi`, `.opencode`, `.agents`); `rmdir` refuses a folder that holds anything, so no
|
|
1206
|
+
* file is touched. The walk stops at the scope root and never takes a shared `.config` folder. */
|
|
1207
|
+
function pruneEmptiedDirs(changes , scopeRoot , fsImpl = fs) {
|
|
1208
|
+
const root = path.resolve(scopeRoot);
|
|
1209
|
+
for (const change of changes) {
|
|
1210
|
+
if (change.operation !== 'remove' || change.retained || change.release || typeof change.target !== 'string') continue;
|
|
1211
|
+
let dir = path.dirname(path.resolve(change.target));
|
|
1212
|
+
if (!isUnder(dir, root)) continue;
|
|
1213
|
+
// The removed file's own folders may already be gone; the first one still standing starts the walk.
|
|
1214
|
+
while (dir !== root && isUnder(dir, root) && !fsImpl.existsSync(dir)) dir = path.dirname(dir);
|
|
1215
|
+
while (dir !== root && isUnder(dir, root) && path.basename(dir) !== '.config') {
|
|
1216
|
+
try { fsImpl.rmdirSync(dir); } catch { break; }
|
|
1217
|
+
dir = path.dirname(dir);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Stop the install manifest listing a harness the removal left with no ownership row. */
|
|
1223
|
+
function forgetRemovedTools(targets , ledger , scopeRoot ) {
|
|
1224
|
+
const standing = new Set((ledger?.resources || []).map((resource ) => resource.harness));
|
|
1225
|
+
const gone = (targets || []).filter((harness) => !standing.has(harness));
|
|
1226
|
+
if (gone.length) forgetManifestTools({ scopeRoot, tools: gone });
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1155
1229
|
function removeLifecycle(options ) {
|
|
1156
1230
|
const plan = planLifecycle({ ...options, context: { ...(options.context || {}), operation: 'remove' } });
|
|
1157
|
-
|
|
1231
|
+
const result = applyLifecycle({ ...options, plan, mode: 'remove', acceptPrerequisites: options.acceptPrerequisites });
|
|
1232
|
+
pruneEmptiedDirs(plan.targets.flatMap((target ) => target.changes), plan.scopeRoot);
|
|
1233
|
+
forgetRemovedTools(options.targets, result.ledger, plan.scopeRoot);
|
|
1234
|
+
return result;
|
|
1158
1235
|
}
|
|
1159
1236
|
|
|
1160
1237
|
module.exports = { OPERATIONS, MCP_KEEP, recordMcpOwnership, isPackageAsset, registryScope, normalizeTargets, normalizeChange, matchesChange, normalizeRemovalVerification, adapterConflicts, planLifecycle, verifyLifecycle, applyLifecycle, removeLifecycle, updateLedger, mcpIndexPath, applyMcpIndex, targetNeedsHooks, assertBashAvailableForHooks, hookWiringStatus, markRetainedRemovals, retentionSummary, PRUNED_TREES, settleSuspendedProjection };
|
|
@@ -128,6 +128,20 @@ function markerState(text ) { return markedS
|
|
|
128
128
|
/** Rule 3a's verdict for one retired row (IC-003, IC-007, design D7). */
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
/** The one rule for "the replacement manages this file": at project scope the replacement writes its
|
|
132
|
+
* declared instruction file whatever else shares it; at global scope it stays out while another live
|
|
133
|
+
* harness in `registry` declares the same file (that harness's adapter keeps it). The handover plan
|
|
134
|
+
* here and antigravity's `instructionTarget` both ask it, so a row is never released to a harness that
|
|
135
|
+
* then plans nothing. */
|
|
136
|
+
function replacementManagesFile({ registry, harnessId, surface, target, scope, scopeRoot } ) {
|
|
137
|
+
if (scope !== 'global') return true;
|
|
138
|
+
const declaredBy = (harness ) => {
|
|
139
|
+
if (harness?.id === harnessId || !harness?.paths?.[surface]) return false;
|
|
140
|
+
try { return resolveHarnessPaths({ [surface]: harness.paths[surface] }, { scope: 'global', scopeRoot, homeDir: scopeRoot })[surface] === target; } catch { return false; }
|
|
141
|
+
};
|
|
142
|
+
return !(registry?.harnesses ?? []).some(declaredBy);
|
|
143
|
+
}
|
|
144
|
+
|
|
131
145
|
/**
|
|
132
146
|
* Rule 3a, decided before the live harnesses plan, because the replacement needs the verdict to plan
|
|
133
147
|
* its own change: `context.handovers` for each `to`. A retired row qualifies when a `removal.handover`
|
|
@@ -155,6 +169,7 @@ function planHandovers({ registry, ledger, scope, scopeRoot, planned = [], fsImp
|
|
|
155
169
|
let declared = null;
|
|
156
170
|
try { declared = resolveHarnessPaths({ [entry.surface]: to.paths[entry.surface] }, { scope, scopeRoot, homeDir: scopeRoot })[entry.surface]; } catch { continue; }
|
|
157
171
|
if (declared !== row.target || !fsImpl.existsSync(row.target) || !resolvesInPlace(row.target, scopeRoot, fsImpl)) continue;
|
|
172
|
+
if (!replacementManagesFile({ registry, harnessId: to.id, surface: entry.surface, target: declared, scope, scopeRoot })) continue;
|
|
158
173
|
const base = { to: to.id, target: row.target, fromKey: ownershipKey(row) };
|
|
159
174
|
if (plannedIds.has(to.id)) {
|
|
160
175
|
decisions.set(base.fromKey, { action: 'handover', ...base, edited: sha256(fsImpl.readFileSync(row.target)) !== row.fingerprint });
|
|
@@ -315,4 +330,4 @@ function formatRetiredPlans(plans ) {
|
|
|
315
330
|
]);
|
|
316
331
|
}
|
|
317
332
|
|
|
318
|
-
module.exports = { planRetiredHarnesses, planHandovers, handoversFor, applyRetiredHarnesses, retiredReleaseKeys, formatRetiredPlans, claimKey };
|
|
333
|
+
module.exports = { replacementManagesFile, planRetiredHarnesses, planHandovers, handoversFor, applyRetiredHarnesses, retiredReleaseKeys, formatRetiredPlans, claimKey };
|
package/src/lifecycle/view.js
CHANGED
|
@@ -19,6 +19,7 @@ const { createCopilotAdapter } = require('../adapters/copilot/index.js');
|
|
|
19
19
|
const { createKiroAdapter } = require('../adapters/kiro/index.js');
|
|
20
20
|
const { createAntigravityAdapter } = require('../adapters/antigravity/index.js');
|
|
21
21
|
const { planLifecycle } = require('./index.js');
|
|
22
|
+
const { canonicalPath, canonicalLedger } = require('../install/paths.js');
|
|
22
23
|
const { formatRetiredPlans } = require('./retired-harnesses.js');
|
|
23
24
|
const { resolveHookTier, hookTierWarnings } = require('./hook-tier.js');
|
|
24
25
|
const { stateRoot, readLedger, defaultLedger } = require('../state/index.js');
|
|
@@ -51,9 +52,9 @@ function codexConfigResources(repoRoot , fsImpl ) {
|
|
|
51
52
|
* reloaded — the same registry also resolves every harness's MCP selection for that command. */
|
|
52
53
|
function registryLifecycleView({ registry, scope, targets, mcpSelections = {}, mcpAdoptable = {}, retainedMcpIds = [], operation, repoRoot, force = false, adopt = false, permissions = false, statusline = false, hookHarnesses, fsImpl = fs } ) {
|
|
53
54
|
const lifecycleScope = codexScope(scope);
|
|
54
|
-
const scopeRoot = scope.global ? os.homedir() :
|
|
55
|
+
const scopeRoot = scope.global ? os.homedir() : canonicalPath(scope.projectRoot);
|
|
55
56
|
const neutralStateRoot = stateRoot({ scope: lifecycleScope, projectRoot: scopeRoot, homeDir: scopeRoot });
|
|
56
|
-
const ledger = readLedger(neutralStateRoot) ?? defaultLedger({ scope: lifecycleScope, scopeRoot });
|
|
57
|
+
const ledger = canonicalLedger(readLedger(neutralStateRoot), scopeRoot) ?? defaultLedger({ scope: lifecycleScope, scopeRoot });
|
|
57
58
|
const tier = hookHarnesses ? { harnesses: hookHarnesses, workers: null } : resolveHookTier();
|
|
58
59
|
// Mirrors buildAdapterRegistry() in src/cli/shared.js — including the declared-paths wiring —
|
|
59
60
|
// and is kept as a literal construction here because the registry guard pins every declared
|
package/src/registry/index.js
CHANGED
|
@@ -582,6 +582,8 @@ const WORKER_BILLING = new Set(['free', 'metered', 'unknown']);
|
|
|
582
582
|
const WORKER_KEYS = Object.freeze(['profile', 'billing', ...Object.keys(WORKER_CAPABILITIES), 'modelChecks']);
|
|
583
583
|
const WORKER_ALLOWED_KEYS = Object.freeze([...WORKER_KEYS, ...Object.keys(WORKER_OPTIONAL_CAPABILITIES), 'family']);
|
|
584
584
|
const WORKER_ENTRY_FIELDS = new Set(['status', 'evidence', 'verification', 'verifiedOn', 'reason']);
|
|
585
|
+
// Soft hyphen, Mongolian vowel separator, zero-width and directional marks, word joiner and invisible operators, BOM.
|
|
586
|
+
const INVISIBLE_CHARACTER = /[\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/;
|
|
585
587
|
const WORKER_RECIPE_FIELDS = new Set(['binary', 'versionArgv', 'argv', 'promptVia', 'parser', 'marker', 'quotaSignals']);
|
|
586
588
|
const WORKER_QUOTA_SIGNALS_FIELDS = new Set(['signals', 'basis', 'evidence']);
|
|
587
589
|
const WORKER_FACTS = new Set(['hooksLoaded', 'identityForwarded', 'guidanceLoaded', 'login']);
|
|
@@ -681,10 +683,25 @@ function validateWorker(value , location , errors ) {
|
|
|
681
683
|
else {
|
|
682
684
|
for (const field of Object.keys(quota)) if (!WORKER_QUOTA_SIGNALS_FIELDS.has(field)) fail(key, `quotaSignals has unknown field '${field}'`);
|
|
683
685
|
if (!Array.isArray(quota.signals) || quota.signals.length < 1 || quota.signals.length > 16) fail(key, 'quotaSignals.signals must contain 1 to 16 signals');
|
|
684
|
-
else
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
686
|
+
else {
|
|
687
|
+
const folded = new Set ();
|
|
688
|
+
for (const signal of quota.signals) {
|
|
689
|
+
if (typeof signal !== 'string' || signal !== signal.trim() || signal.length < 3 || signal.length > 64 || /[\u0000-\u001f\u007f]/.test(signal)) {
|
|
690
|
+
fail(key, 'quotaSignals.signals must be trimmed strings of 3 to 64 characters without control characters');
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
// The matcher lowercases both sides, so a signal that differs only by case never adds a match; a
|
|
694
|
+
// zero-width or format character makes one that reads like another yet matches nothing.
|
|
695
|
+
if (INVISIBLE_CHARACTER.test(signal)) {
|
|
696
|
+
fail(key, 'quotaSignals.signals must not contain zero-width or invisible format characters');
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
const lowered = signal.toLowerCase();
|
|
700
|
+
if (folded.has(lowered)) {
|
|
701
|
+
fail(key, `quotaSignals.signals has a duplicate signal '${signal}' (compared without letter case)`);
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
folded.add(lowered);
|
|
688
705
|
}
|
|
689
706
|
}
|
|
690
707
|
if (quota.basis !== 'unverified' && quota.basis !== 'transcript') fail(key, "quotaSignals.basis must be 'unverified' or 'transcript'");
|
|
@@ -463,7 +463,11 @@ function main(argv , root = REPO_ROOT) {
|
|
|
463
463
|
}
|
|
464
464
|
const findings = argv[0] === '--write' ? writeAll(root) : checkAll(root);
|
|
465
465
|
for (const finding of findings) process.stderr.write(`docs-blocks: ${finding.page}: ${finding.id}: ${finding.kind}${finding.detail ? ` (${oneLine(finding.detail)})` : ''}\n`);
|
|
466
|
-
if (findings.length && argv[0] === '--check')
|
|
466
|
+
if (findings.length && argv[0] === '--check') {
|
|
467
|
+
process.stderr.write('docs-blocks: run node src/release/docs-blocks.js --write, then review the diff\n');
|
|
468
|
+
// --write rewrites llms.txt from the pages; when the rewritten text is itself over the cap, only shorter descriptions fix it.
|
|
469
|
+
if (findings.some((f) => f.kind === 'over-cap')) process.stderr.write(`docs-blocks: llms.txt over ${LLMS_CAP} bytes: if --write leaves it over, shorten the page descriptions it is built from\n`);
|
|
470
|
+
}
|
|
467
471
|
return findings.length ? 1 : 0;
|
|
468
472
|
}
|
|
469
473
|
|
|
@@ -116,12 +116,12 @@ async function buildAdviceDoctor({ repoRoot = REPO_ROOT, projectRoot = process.c
|
|
|
116
116
|
decisionModels: [], lastCall: latestCall(window.records),
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
-
return { decisionModel, advice, _inspection: inspection, _offReason: setting.state === 'off' ? offReason(setting) : null };
|
|
119
|
+
return { decisionModel, advice, _inspection: inspection, _offReason: setting.state === 'off' ? (loaded.ok ? offReason(setting) : `unreadable advice registry: ${loaded.detail}`) : null };
|
|
120
120
|
} catch (error ) {
|
|
121
121
|
return { decisionModel: { setting: { state: 'off', reason: 'config-invalid', decidedBy: { user: null, project: null }, modelFrom: null, notes: [error.message] },
|
|
122
122
|
endpoint: { url: null, state: 'not-checked', version: null, minVersion: null, versionOk: false, message: null },
|
|
123
123
|
model: { name: null, installed: false, decisionCapable: false }, decisionModels: [], lastCall: null },
|
|
124
|
-
advice: { windowDays: WINDOW_DAYS, store: 'advice-picks-user', kinds: [], unreadableLines: 0 }, _inspection: null, _offReason:
|
|
124
|
+
advice: { windowDays: WINDOW_DAYS, store: 'advice-picks-user', kinds: [], unreadableLines: 0 }, _inspection: null, _offReason: `advice doctor could not complete: ${error.message}` };
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -328,7 +328,7 @@ function adviceRecord(result ) {
|
|
|
328
328
|
/**
|
|
329
329
|
* The tier renderer remains synchronous/pure; optional local advice is requested afterward.
|
|
330
330
|
*/
|
|
331
|
-
async function handleBriefToolsCommand({ owner, tier, harness, slug, headingLevel, tasks, json = false, repoRoot = REPO_ROOT, projectRoot = process.cwd(), env = process.env, buildAdapters = null, probe = probeProviders, adviceClient = null } = {}) {
|
|
331
|
+
async function handleBriefToolsCommand({ owner, tier, harness, slug, headingLevel, tasks, json = false, repoRoot = REPO_ROOT, projectRoot = process.cwd(), env = process.env, buildAdapters = null, probe = probeProviders, adviceClient = null, now = () => new Date() } = {}) {
|
|
332
332
|
if (!owner) return usageError('brief-tools', '--owner is required (the agent type the brief is for)', json);
|
|
333
333
|
const level = headingLevel === undefined ? 2 : Number(headingLevel);
|
|
334
334
|
if (level !== 2 && level !== 3) return usageError('brief-tools', `--heading-level must be 2 or 3, got '${headingLevel}'`, json);
|
|
@@ -349,6 +349,7 @@ async function handleBriefToolsCommand({ owner, tier, harness, slug, headingLeve
|
|
|
349
349
|
let markdown = rendered.markdown;
|
|
350
350
|
const record = rendered.record;
|
|
351
351
|
record.missing.push(...notes);
|
|
352
|
+
record.createdAt = now().toISOString();
|
|
352
353
|
|
|
353
354
|
const taskIds = Array.isArray(tasks) ? tasks : typeof tasks === 'string' ? tasks.split(',').filter(Boolean) : [];
|
|
354
355
|
if (taskIds.length) {
|
|
@@ -298,6 +298,15 @@ class CapabilityRouter {
|
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/** The registry's `capabilities.mcp.toolName` template for a harness id, or null when it has none. */
|
|
302
|
+
harnessToolTemplate(harnessId ) {
|
|
303
|
+
return this.harnessList().find((item ) => item.id === harnessId)?.capabilities?.mcp?.toolName || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
harnessList() {
|
|
307
|
+
return parseYamlFile(path.join(this.registryDir, 'harnesses.json'), this.fsImpl).harnesses || [];
|
|
308
|
+
}
|
|
309
|
+
|
|
301
310
|
/**
|
|
302
311
|
* Formats concrete execution instructions for a provider based on intent parameters.
|
|
303
312
|
*
|
|
@@ -320,7 +329,17 @@ class CapabilityRouter {
|
|
|
320
329
|
// back to the registry entry with that id.
|
|
321
330
|
const mcp = provider.mcp
|
|
322
331
|
|| Object.values(this.capabilities).flatMap((cap ) => cap.providers || []).find((p ) => p.id === provider.id && p.mcp)?.mcp;
|
|
323
|
-
|
|
332
|
+
// Default (no `params.harness`): the Claude tool-name form, unchanged. With a target harness:
|
|
333
|
+
// that harness's registry `capabilities.mcp.toolName` template, or — when it has no verified
|
|
334
|
+
// template — the neutral `{server, tool}` identifiers and no tool name.
|
|
335
|
+
let mcpTool = {};
|
|
336
|
+
if (mcp && !params.harness) mcpTool = { mcpTool: `mcp__${mcp.server}__${mcp.tool}` };
|
|
337
|
+
else if (mcp) {
|
|
338
|
+
const template = this.harnessToolTemplate(params.harness);
|
|
339
|
+
mcpTool = template
|
|
340
|
+
? { mcpTool: template.replace('{server}', () => mcp.server).replace('{tool}', () => mcp.tool) }
|
|
341
|
+
: { mcp: { server: mcp.server, tool: mcp.tool } };
|
|
342
|
+
}
|
|
324
343
|
const withDisplay = (result ) => ({ ...mcpTool, ...result, ...(result.argv ? { cliCommand: result.argv.map(shellQuote).join(' ') } : {}) });
|
|
325
344
|
|
|
326
345
|
if (provider.id === 'semble.search') {
|
|
@@ -580,15 +599,19 @@ class CapabilityRouter {
|
|
|
580
599
|
* @param {string} [options.projectRoot]
|
|
581
600
|
* @returns {number} exit code
|
|
582
601
|
*/
|
|
583
|
-
function handleRouteCommand({ intent, query, check = false, json = false, projectRoot } = {}) {
|
|
602
|
+
function handleRouteCommand({ intent, query, check = false, json = false, projectRoot, harness } = {}) {
|
|
584
603
|
const router = new CapabilityRouter({ repoRoot: REPO_ROOT, projectRoot });
|
|
585
604
|
if (typeof intent !== 'string' || intent.trim() === '') {
|
|
586
605
|
return usageError('route', `--intent is required. Declared intents: ${Object.keys(router.routes).join(', ')}`, json);
|
|
587
606
|
}
|
|
588
607
|
|
|
608
|
+
if (harness !== undefined && !router.harnessList().some((item ) => item.id === harness)) {
|
|
609
|
+
return usageError('route', `unknown --harness '${harness}'. Known harnesses: ${router.harnessList().map((item ) => item.id).join(', ')}`, json);
|
|
610
|
+
}
|
|
611
|
+
|
|
589
612
|
let resolution ;
|
|
590
613
|
try {
|
|
591
|
-
resolution = router.resolveIntent(intent, { query, path: projectRoot || '.' }, { deepCheck: check });
|
|
614
|
+
resolution = router.resolveIntent(intent, { query, path: projectRoot || '.', ...(harness ? { harness } : {}) }, { deepCheck: check });
|
|
592
615
|
} catch (error ) {
|
|
593
616
|
return usageError('route', `${error.message}. Declared intents: ${Object.keys(router.routes).join(', ')}`, json, error);
|
|
594
617
|
}
|
|
@@ -40,6 +40,14 @@ function inside(root , file , fsImpl = fs) {
|
|
|
40
40
|
return normalized;
|
|
41
41
|
}
|
|
42
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
|
+
|
|
43
51
|
function lineSlice(lines , startLine , endLine ) {
|
|
44
52
|
return lines.slice(startLine - 1, endLine).join('\n') + '\n';
|
|
45
53
|
}
|
|
@@ -157,8 +165,10 @@ function collectPointers({ projectRoot, feature, fsImpl = fs, intakeFile } )
|
|
|
157
165
|
const root = path.resolve(projectRoot);
|
|
158
166
|
const pointers = [];
|
|
159
167
|
for (const source of sourceFiles({ projectRoot: root, feature, fsImpl, intakeFile })) {
|
|
160
|
-
|
|
161
|
-
|
|
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;
|
|
162
172
|
const user = source.user || Boolean(originOf(rel));
|
|
163
173
|
let text ;
|
|
164
174
|
try {
|
|
@@ -184,9 +194,11 @@ function collectPointers({ projectRoot, feature, fsImpl = fs, intakeFile } )
|
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
function readPointerSlice({ projectRoot, pointer, fsImpl = fs } ) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
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;
|
|
190
202
|
try {
|
|
191
203
|
const lines = fsImpl.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
192
204
|
const slice = lineSlice(lines, pointer.range.startLine, pointer.range.endLine);
|