@khoavu882/doflow 2.0.0-beta.3 → 2.0.0-beta.4
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/bin/doflow.js +2 -2
- 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/registry/storage.json +87 -83
- package/core/registry/verification.json +1 -1
- package/core/shared/scripts/doflow/bash/render-puml.sh +2 -2
- package/core/shared/skills/do-execute-plan/references/scaffold.md +2 -2
- package/package.json +4 -2
- package/src/adapters/antigravity/{index.ts → index.js} +60 -60
- package/src/adapters/claude/{index.ts → index.js} +48 -48
- package/src/adapters/claude/{mcp.ts → mcp.js} +16 -16
- package/src/adapters/codex/{agents.ts → agents.js} +24 -24
- package/src/adapters/codex/{config.ts → config.js} +49 -49
- package/src/adapters/codex/{hooks.ts → hooks.js} +23 -23
- package/src/adapters/codex/{index.ts → index.js} +98 -98
- package/src/adapters/codex/{mcp.ts → mcp.js} +38 -38
- package/src/adapters/codex/{native-projection.ts → native-projection.js} +5 -5
- package/src/adapters/copilot/{index.ts → index.js} +42 -42
- package/src/adapters/{copy-tree.ts → copy-tree.js} +70 -70
- package/src/adapters/gemini/{hooks.ts → hooks.js} +24 -24
- package/src/adapters/gemini/{index.ts → index.js} +41 -41
- package/src/adapters/{hook-commands.ts → hook-commands.js} +8 -8
- package/src/adapters/{index.ts → index.js} +13 -13
- package/src/adapters/kiro/{index.ts → index.js} +34 -34
- package/src/adapters/{mcp-entries.ts → mcp-entries.js} +55 -55
- package/src/adapters/opencode/{index.ts → index.js} +52 -52
- package/src/adapters/pi/{index.ts → index.js} +30 -30
- package/src/adapters/pi/{json-members.ts → json-members.js} +23 -23
- package/src/adapters/pi/{mcp.ts → mcp.js} +70 -70
- package/src/adapters/{resolve-source.ts → resolve-source.js} +1 -1
- package/src/cli/commands/{install.ts → install.js} +24 -24
- package/src/cli/commands/{list-backups.ts → list-backups.js} +4 -4
- package/src/cli/commands/{reconcile.ts → reconcile.js} +19 -19
- package/src/cli/commands/{remove.ts → remove.js} +10 -10
- package/src/cli/commands/{rollback.ts → rollback.js} +17 -17
- package/src/cli/commands/{self-update.ts → self-update.js} +3 -3
- package/src/cli/commands/{status.ts → status.js} +24 -24
- package/src/cli/commands/{tools.ts → tools.js} +16 -16
- package/src/cli/commands/{update.ts → update.js} +19 -19
- package/src/cli/{index.ts → index.js} +26 -26
- package/src/cli/{runtime-commands.ts → runtime-commands.js} +48 -48
- package/src/cli/{shared.ts → shared.js} +40 -40
- package/src/helper/{bash-helper.ts → bash-helper.js} +7 -7
- package/src/helper/{git.ts → git.js} +2 -2
- package/src/helper/{guardrails.ts → guardrails.js} +10 -10
- package/src/helper/{harness-paths.ts → harness-paths.js} +24 -24
- package/src/helper/{marker-merge.ts → marker-merge.js} +4 -4
- package/src/helper/{prompt.ts → prompt.js} +5 -5
- package/src/helper/{repo-root.ts → repo-root.js} +2 -2
- package/src/helper/{settings-merge.ts → settings-merge.js} +27 -27
- package/src/helper/{settings-scope.ts → settings-scope.js} +3 -3
- package/src/helper/{toml.ts → toml.js} +48 -48
- package/src/install/{backup-set.ts → backup-set.js} +16 -16
- package/src/install/{backup.ts → backup.js} +65 -65
- package/src/install/{context.ts → context.js} +15 -15
- package/src/install/{manifest.ts → manifest.js} +27 -27
- package/src/install/{mcp.ts → mcp.js} +30 -30
- package/src/install/{paths.ts → paths.js} +15 -15
- package/src/install/{targets.ts → targets.js} +10 -10
- package/src/install/{tool-lifecycle.ts → tool-lifecycle.js} +20 -20
- package/src/lifecycle/{bash-availability.ts → bash-availability.js} +1 -1
- package/src/lifecycle/{index.ts → index.js} +140 -140
- package/src/lifecycle/{mcp-index.ts → mcp-index.js} +1 -1
- package/src/lifecycle/{policies.ts → policies.js} +11 -11
- package/src/lifecycle/{view.ts → view.js} +28 -28
- package/src/registry/{index.ts → index.js} +60 -60
- package/src/release/build-package.js +91 -0
- package/src/release/changelog-section.js +70 -0
- package/src/release/{sync-plugin-versions.ts → sync-plugin-versions.js} +4 -4
- package/src/runtime/advice/{cli.ts → cli.js} +31 -31
- package/src/runtime/advice/{client.ts → client.js} +44 -44
- package/src/runtime/advice/{config.ts → config.js} +16 -16
- package/src/runtime/advice/{doctor.ts → doctor.js} +32 -32
- package/src/runtime/advice/{endpoint-guard.ts → endpoint-guard.js} +20 -20
- package/src/runtime/advice/{kinds.ts → kinds.js} +16 -16
- package/src/runtime/advice/providers/{ollama-systemone.ts → ollama-systemone.js} +21 -21
- package/src/runtime/advice/{record.ts → record.js} +19 -19
- package/src/runtime/advice/{registry.ts → registry.js} +16 -16
- package/src/runtime/advice/{strength.ts → strength.js} +7 -7
- package/src/runtime/{brief-tools.ts → brief-tools.js} +50 -50
- package/src/runtime/{c4-project.ts → c4-project.js} +27 -27
- package/src/runtime/{capability-router.ts → capability-router.js} +39 -39
- package/src/runtime/{checkouts.ts → checkouts.js} +21 -21
- package/src/runtime/{claims.ts → claims.js} +30 -30
- package/src/runtime/{cli-result.ts → cli-result.js} +4 -4
- package/src/runtime/{cli.ts → cli.js} +37 -37
- package/src/runtime/{command-detect.ts → command-detect.js} +47 -47
- package/src/runtime/{context-pack.ts → context-pack.js} +20 -20
- package/src/runtime/{decision-register.ts → decision-register.js} +67 -67
- package/src/runtime/{evidence-ledger.ts → evidence-ledger.js} +25 -25
- package/src/runtime/failure/{capture.ts → capture.js} +29 -29
- package/src/runtime/failure/{classifier.ts → classifier.js} +4 -4
- package/src/runtime/failure/{cli.ts → cli.js} +30 -30
- package/src/runtime/failure/{home.ts → home.js} +6 -6
- package/src/runtime/failure/{store.ts → store.js} +32 -32
- package/src/runtime/{feature-resolve.ts → feature-resolve.js} +6 -6
- package/src/runtime/{freshness.ts → freshness.js} +15 -15
- package/src/runtime/{health.ts → health.js} +72 -72
- package/src/runtime/{history-compactor.ts → history-compactor.js} +25 -25
- package/src/runtime/{implementation-gate.ts → implementation-gate.js} +30 -30
- package/src/runtime/inventory/{coverage.ts → coverage.js} +10 -10
- package/src/runtime/inventory/{identity.ts → identity.js} +2 -2
- package/src/runtime/inventory/{index.ts → index.js} +36 -36
- package/src/runtime/inventory/{precedence.ts → precedence.js} +2 -2
- package/src/runtime/inventory/{read-scopes.ts → read-scopes.js} +9 -9
- package/src/runtime/inventory/{siblings.ts → siblings.js} +15 -15
- package/src/runtime/knowledge/{chunker.ts → chunker.js} +12 -12
- package/src/runtime/knowledge/{hybrid.ts → hybrid.js} +17 -17
- package/src/runtime/knowledge/{index-store.ts → index-store.js} +23 -23
- package/src/runtime/knowledge/{retrieval.ts → retrieval.js} +27 -27
- package/src/runtime/{leak-scan.ts → leak-scan.js} +12 -12
- package/src/runtime/lifecycle/{cli.ts → cli.js} +32 -32
- package/src/runtime/lifecycle/{event-store.ts → event-store.js} +48 -48
- package/src/runtime/lifecycle/{fold.ts → fold.js} +41 -41
- package/src/runtime/lifecycle/{followup.ts → followup.js} +54 -54
- package/src/runtime/lifecycle/{goal.ts → goal.js} +50 -50
- package/src/runtime/lifecycle/{intent-writer.ts → intent-writer.js} +5 -5
- package/src/runtime/lifecycle/{overview.ts → overview.js} +47 -47
- package/src/runtime/lifecycle/{release.ts → release.js} +29 -29
- package/src/runtime/lifecycle/{report-store.ts → report-store.js} +25 -25
- package/src/runtime/lifecycle/{retention.ts → retention.js} +37 -37
- package/src/runtime/lifecycle/{root.ts → root.js} +9 -9
- package/src/runtime/lifecycle/{status.ts → status.js} +64 -64
- package/src/runtime/lifecycle/{store-upkeep.ts → store-upkeep.js} +46 -46
- package/src/runtime/{locator-resolve.ts → locator-resolve.js} +7 -7
- package/src/runtime/{mask.ts → mask.js} +22 -22
- package/src/runtime/{mcp-consultations.ts → mcp-consultations.js} +54 -54
- package/src/runtime/{mcp-reach.ts → mcp-reach.js} +24 -24
- package/src/runtime/{model-router.ts → model-router.js} +20 -20
- package/src/runtime/{outcome.ts → outcome.js} +27 -27
- package/src/runtime/{reach.ts → reach.js} +21 -21
- package/src/runtime/{readiness-record.ts → readiness-record.js} +12 -12
- package/src/runtime/{readiness.ts → readiness.js} +35 -35
- package/src/runtime/{recovery.ts → recovery.js} +15 -15
- package/src/runtime/{research-request.ts → research-request.js} +34 -34
- package/src/runtime/{retrieval-plan.ts → retrieval-plan.js} +40 -40
- package/src/runtime/scaffold/{artifacts.ts → artifacts.js} +19 -19
- package/src/runtime/scaffold/{fingerprint.ts → fingerprint.js} +3 -3
- package/src/runtime/scaffold/{generate.ts → generate.js} +109 -109
- package/src/runtime/scaffold/{languages.ts → languages.js} +34 -34
- package/src/runtime/storage/{cli.ts → cli.js} +9 -9
- package/src/runtime/storage/{engine.ts → engine.js} +37 -37
- package/src/runtime/storage/{registry.ts → registry.js} +19 -19
- package/src/runtime/storage/{roots.ts → roots.js} +8 -8
- package/src/runtime/storage/{rules.ts → rules.js} +26 -26
- package/src/runtime/storage/{upkeep.ts → upkeep.js} +35 -35
- package/src/runtime/storage/{work-status.ts → work-status.js} +39 -39
- package/src/runtime/{task-attempts.ts → task-attempts.js} +61 -61
- package/src/runtime/{task-classifier.ts → task-classifier.js} +34 -34
- package/src/runtime/{task-scope.ts → task-scope.js} +13 -13
- package/src/runtime/{task-state.ts → task-state.js} +15 -15
- package/src/runtime/trace/{indicators.ts → indicators.js} +22 -22
- package/src/runtime/trace/{ledger.ts → ledger.js} +42 -42
- package/src/runtime/trace/{render.ts → render.js} +7 -7
- package/src/runtime/trace/{views.ts → views.js} +40 -40
- package/src/runtime/verification/{contract-runner.ts → contract-runner.js} +23 -23
- package/src/runtime/verification/{engine.ts → engine.js} +143 -143
- package/src/runtime/verification/{registry.ts → registry.js} +6 -6
- package/src/runtime/verification/{scope-bound.ts → scope-bound.js} +50 -50
- package/src/runtime/verification/{test-selection.ts → test-selection.js} +28 -28
- package/src/runtime/{workflow-engine.ts → workflow-engine.js} +50 -50
- package/src/runtime/{workflow-orchestrator.ts → workflow-orchestrator.js} +91 -91
- package/src/runtime/{worktree.ts → worktree.js} +31 -31
- package/src/state/{index.ts → index.js} +26 -26
- package/src/state/{lockfile.ts → lockfile.js} +20 -20
- package/src/state/{run-lock.ts → run-lock.js} +44 -44
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
const fs = require('node:fs');
|
|
11
11
|
const os = require('node:os');
|
|
12
12
|
const path = require('node:path');
|
|
13
|
-
const { nativeMcpCatalog } = require('../../registry/index.
|
|
14
|
-
const { readMcpFiles, planMcpEntries, verifyMcpEntries, ownedMcpIds, writeMcpEntries } = require('../mcp-entries.
|
|
13
|
+
const { nativeMcpCatalog } = require('../../registry/index.js');
|
|
14
|
+
const { readMcpFiles, planMcpEntries, verifyMcpEntries, ownedMcpIds, writeMcpEntries } = require('../mcp-entries.js');
|
|
15
15
|
|
|
16
16
|
const HARNESS = 'claude';
|
|
17
17
|
const MCP_CONTAINER = 'mcpServers';
|
|
@@ -20,39 +20,39 @@ const MCP_RENDERER = 'claude-mcp';
|
|
|
20
20
|
// install carries.
|
|
21
21
|
const MCP_ASSET_ID = 'guidance.core';
|
|
22
22
|
|
|
23
|
-
function ownershipIdentity(id
|
|
23
|
+
function ownershipIdentity(id ) { return `doflow:${HARNESS}:mcp-server:${id}`; }
|
|
24
24
|
|
|
25
25
|
/** Claude Code's own per-server shape, the one the registry's native catalog renders. */
|
|
26
|
-
function rendered(servers
|
|
26
|
+
function rendered(servers ) {
|
|
27
27
|
const { serverDefs } = nativeMcpCatalog(servers);
|
|
28
|
-
return servers.map((server
|
|
28
|
+
return servers.map((server ) => ({ id: server.id, entry: serverDefs[server.id] }));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** Everything the shared entry-ownership rules need from this adapter. */
|
|
32
|
-
function mcpEntriesInput({ paths, mcp = [], mcpAdoptable = [], ledger }
|
|
33
|
-
const rows = (ledger?.resources || []).filter((row
|
|
32
|
+
function mcpEntriesInput({ paths, mcp = [], mcpAdoptable = [], ledger } ) {
|
|
33
|
+
const rows = (ledger?.resources || []).filter((row ) => row.kind === 'mcp-server');
|
|
34
34
|
return {
|
|
35
35
|
file: paths.mcp, selected: rendered(mcp), adoptable: rendered(mcpAdoptable),
|
|
36
|
-
ownRows: rows.filter((row
|
|
36
|
+
ownRows: rows.filter((row ) => row.harness === HARNESS).map((row ) => ({
|
|
37
37
|
identity: row.identity, target: row.target, fingerprint: row.fingerprint ?? null, ownershipIdentity: row.ownershipIdentity, legacy: false,
|
|
38
38
|
})),
|
|
39
|
-
foreignRows: rows.filter((row
|
|
39
|
+
foreignRows: rows.filter((row ) => row.harness !== HARNESS).map((row ) => ({ harness: row.harness, identity: row.identity, target: row.target })),
|
|
40
40
|
identityFor: ownershipIdentity, assetId: MCP_ASSET_ID, renderer: MCP_RENDERER, label: 'Claude MCP',
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function readFiles(entries
|
|
44
|
+
function readFiles(entries , fsImpl ) {
|
|
45
45
|
return readMcpFiles({ file: entries.file, ownRows: entries.ownRows, container: MCP_CONTAINER, fsImpl });
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** Server names Claude Code merges into a project from `~/.claude.json` beside the project's own
|
|
49
49
|
* `.mcp.json`: the local scope (`projects[<root>].mcpServers`) and the user scope (`mcpServers`).
|
|
50
50
|
* Read-only and for reach alone; a file that cannot be read contributes nothing (null). */
|
|
51
|
-
function claudeJsonNames({ scopeRoot, homeDir, fsImpl }
|
|
51
|
+
function claudeJsonNames({ scopeRoot, homeDir, fsImpl } ) {
|
|
52
52
|
let doc;
|
|
53
53
|
try { doc = JSON.parse(fsImpl.readFileSync(path.join(homeDir, '.claude.json'), 'utf8')); } catch { return null; }
|
|
54
54
|
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null;
|
|
55
|
-
const names = (servers
|
|
55
|
+
const names = (servers ) => (servers && typeof servers === 'object' && !Array.isArray(servers) ? Object.keys(servers) : []);
|
|
56
56
|
const projects = doc.projects && typeof doc.projects === 'object' ? doc.projects : {};
|
|
57
57
|
let real = scopeRoot;
|
|
58
58
|
try { real = fsImpl.realpathSync(scopeRoot); } catch { /* the path as given */ }
|
|
@@ -64,7 +64,7 @@ function claudeJsonNames({ scopeRoot, homeDir, fsImpl }: any) {
|
|
|
64
64
|
* (`mcpConfigured`): the scope's file, plus at project scope the local- and user-scope entries in
|
|
65
65
|
* `~/.claude.json`. Claude Code also loads plugin, managed and connector servers DoFlow does not
|
|
66
66
|
* read, so the view is never complete (`mcpComplete: false`) and absence is never proof. */
|
|
67
|
-
function discoverMcp({ paths, mcp, mcpAdoptable, ledger, scope, scopeRoot, homeDir = os.homedir(), fsImpl = fs }
|
|
67
|
+
function discoverMcp({ paths, mcp, mcpAdoptable, ledger, scope, scopeRoot, homeDir = os.homedir(), fsImpl = fs } ) {
|
|
68
68
|
const entries = mcpEntriesInput({ paths, mcp, mcpAdoptable, ledger });
|
|
69
69
|
const mcpSnapshot = readFiles(entries, fsImpl);
|
|
70
70
|
// `null` while nothing could be read; every name otherwise, whoever wrote it.
|
|
@@ -75,17 +75,17 @@ function discoverMcp({ paths, mcp, mcpAdoptable, ledger, scope, scopeRoot, homeD
|
|
|
75
75
|
return { mcpSnapshot, mcpOwned: ownedMcpIds({ ...entries, files: mcpSnapshot }), mcpConfigured, mcpComplete: false };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function planMcp({ paths, mcp, mcpAdoptable, ledger, snapshot, removing, fsImpl = fs }
|
|
78
|
+
function planMcp({ paths, mcp, mcpAdoptable, ledger, snapshot, removing, fsImpl = fs } ) {
|
|
79
79
|
const entries = mcpEntriesInput({ paths, mcp, mcpAdoptable, ledger });
|
|
80
80
|
return planMcpEntries({ ...entries, files: snapshot ?? readFiles(entries, fsImpl), removing });
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/** An emptied `mcpServers` stays as `{}`, as Claude Code itself leaves it. */
|
|
84
|
-
function writeMcp(changes
|
|
84
|
+
function writeMcp(changes , { fsImpl = fs } = {}) {
|
|
85
85
|
return writeMcpEntries(changes, { container: MCP_CONTAINER, keepEmptyContainer: true, fsImpl });
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function verifyMcp({ paths, mcp, mcpAdoptable, ledger, snapshot, removing, sourceVersion, fsImpl = fs }
|
|
88
|
+
function verifyMcp({ paths, mcp, mcpAdoptable, ledger, snapshot, removing, sourceVersion, fsImpl = fs } ) {
|
|
89
89
|
const entries = mcpEntriesInput({ paths, mcp, mcpAdoptable, ledger });
|
|
90
90
|
const files = readFiles(entries, fsImpl);
|
|
91
91
|
return verifyMcpEntries({ ...entries, files, snapshot: snapshot ?? files, removing, harness: HARNESS, sourceVersion });
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
const crypto = require('node:crypto');
|
|
9
9
|
const fs = require('node:fs');
|
|
10
10
|
const path = require('node:path');
|
|
11
|
-
const { atomicWrite } = require('./config.
|
|
11
|
+
const { atomicWrite } = require('./config.js');
|
|
12
12
|
|
|
13
13
|
const AGENT_DIRECTORY = 'agents';
|
|
14
14
|
const AGENT_KIND = 'custom-agent';
|
|
@@ -23,38 +23,38 @@ const IMPLEMENTATION_ROLES = new Set([
|
|
|
23
23
|
]);
|
|
24
24
|
const ALLOWED_FIELDS = new Set([...REQUIRED_FIELDS, 'sandbox_mode']);
|
|
25
25
|
|
|
26
|
-
function fingerprint(content
|
|
26
|
+
function fingerprint(content ) {
|
|
27
27
|
return `sha256:${crypto.createHash('sha256').update(content).digest('hex')}`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function assertAgentName(name
|
|
30
|
+
function assertAgentName(name , label = 'agent name') {
|
|
31
31
|
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
32
32
|
throw new Error(`${label} must be a lowercase kebab-case identifier`);
|
|
33
33
|
}
|
|
34
34
|
return name;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function filenameFor(name
|
|
37
|
+
function filenameFor(name ) {
|
|
38
38
|
assertAgentName(name);
|
|
39
39
|
return `${name}.toml`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function expectedName(fileName
|
|
42
|
+
function expectedName(fileName ) {
|
|
43
43
|
if (typeof fileName !== 'string' || path.basename(fileName) !== fileName || !fileName.endsWith('.toml')) {
|
|
44
44
|
throw new Error('Agent filename must be a direct .toml filename');
|
|
45
45
|
}
|
|
46
46
|
return assertAgentName(fileName.slice(0, -'.toml'.length), 'Agent filename');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function parseQuoted(value
|
|
49
|
+
function parseQuoted(value , field , line ) {
|
|
50
50
|
if (!/^"(?:[^"\\\n]|\\.)*"$/.test(value)) throw new Error(`${field} must be a quoted TOML string on line ${line}`);
|
|
51
51
|
try { return JSON.parse(value); } catch { throw new Error(`${field} has an invalid quoted value on line ${line}`); }
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** Parse the intentionally small TOML contract supported by Codex agent definitions. */
|
|
55
|
-
function parseAgentToml(source
|
|
55
|
+
function parseAgentToml(source , { fileName } = {}) {
|
|
56
56
|
if (typeof source !== 'string') throw new Error('Agent TOML source must be text');
|
|
57
|
-
const values
|
|
57
|
+
const values = {};
|
|
58
58
|
const lines = source.split(/\r?\n/);
|
|
59
59
|
for (let index = 0; index < lines.length; index++) {
|
|
60
60
|
const line = lines[index];
|
|
@@ -64,7 +64,7 @@ function parseAgentToml(source: any, { fileName }: { fileName?: string } = {}):
|
|
|
64
64
|
const field = triple[1];
|
|
65
65
|
if (!ALLOWED_FIELDS.has(field)) throw new Error(`Unsupported agent field '${field}'`);
|
|
66
66
|
if (Object.hasOwn(values, field)) throw new Error(`Duplicate agent field '${field}'`);
|
|
67
|
-
const body
|
|
67
|
+
const body = [];
|
|
68
68
|
let closed = false;
|
|
69
69
|
while (++index < lines.length) {
|
|
70
70
|
if (/^"""\s*$/.test(lines[index])) { closed = true; break; }
|
|
@@ -85,7 +85,7 @@ function parseAgentToml(source: any, { fileName }: { fileName?: string } = {}):
|
|
|
85
85
|
return values;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function validateAgentContract(source
|
|
88
|
+
function validateAgentContract(source , { fileName } = {}) {
|
|
89
89
|
const agent = parseAgentToml(source, { fileName });
|
|
90
90
|
for (const field of REQUIRED_FIELDS) {
|
|
91
91
|
if (typeof agent[field] !== 'string' || !agent[field].trim()) throw new Error(`Agent is missing required '${field}'`);
|
|
@@ -106,7 +106,7 @@ function validateAgentContract(source: any, { fileName }: { fileName?: string }
|
|
|
106
106
|
return Object.freeze({ ...agent, fileName: filenameFor(agent.name) });
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function agentDirectory({ scope, codexDir, projectRoot, paths }
|
|
109
|
+
function agentDirectory({ scope, codexDir, projectRoot, paths } = {}) {
|
|
110
110
|
// Preferred: the declared path resolved by the adapter (harnesses.json "paths".agentsDirectory).
|
|
111
111
|
if (paths?.agentsDirectory) return paths.agentsDirectory;
|
|
112
112
|
// Legacy derivation for direct callers passing raw scope inputs; kept byte-compatible with the
|
|
@@ -122,19 +122,19 @@ function agentDirectory({ scope, codexDir, projectRoot, paths }: { scope?: strin
|
|
|
122
122
|
throw new Error(`Unsupported Codex agent scope: ${scope}`);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function destinationFor(directory
|
|
125
|
+
function destinationFor(directory , name ) {
|
|
126
126
|
const file = path.resolve(directory, filenameFor(name));
|
|
127
127
|
const root = `${path.resolve(directory)}${path.sep}`;
|
|
128
128
|
if (!file.startsWith(root)) throw new Error(`Refusing to write agent '${name}' outside the Codex agents directory`);
|
|
129
129
|
return file;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
function discoverCodexAgents(sourceDir
|
|
132
|
+
function discoverCodexAgents(sourceDir , fsImpl = fs) {
|
|
133
133
|
if (!sourceDir) throw new Error('sourceDir is required for Codex agents');
|
|
134
134
|
const directory = path.resolve(sourceDir);
|
|
135
135
|
if (!fsImpl.existsSync(directory)) throw new Error(`Codex agent source directory does not exist: ${sourceDir}`);
|
|
136
|
-
const agents
|
|
137
|
-
for (const entry of fsImpl.readdirSync(directory, { withFileTypes: true }).sort((a
|
|
136
|
+
const agents = [];
|
|
137
|
+
for (const entry of fsImpl.readdirSync(directory, { withFileTypes: true }).sort((a , b ) => a.name.localeCompare(b.name))) {
|
|
138
138
|
if (!entry.name.endsWith('.toml')) continue;
|
|
139
139
|
if (!entry.isFile()) throw new Error(`Codex agent source '${entry.name}' must be a regular file`);
|
|
140
140
|
const name = expectedName(entry.name);
|
|
@@ -145,11 +145,11 @@ function discoverCodexAgents(sourceDir: any, fsImpl: any = fs): any[] {
|
|
|
145
145
|
return agents;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
function isOwnedResource(resource
|
|
148
|
+
function isOwnedResource(resource , scope ) {
|
|
149
149
|
return resource && resource.target === 'codex' && resource.scope === scope && resource.kind === AGENT_KIND && typeof resource.identity === 'string';
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function resourceFor(agent
|
|
152
|
+
function resourceFor(agent , scope , sourceVersion ) {
|
|
153
153
|
return {
|
|
154
154
|
target: 'codex', scope, kind: AGENT_KIND, identity: `agent:${agent.name}`,
|
|
155
155
|
sourceVersion, fingerprint: agent.fingerprint, selection: true,
|
|
@@ -163,13 +163,13 @@ function resourceFor(agent: any, scope: any, sourceVersion: any): any {
|
|
|
163
163
|
* recording ownership instead of refusing. See planCodexConfig for why this is not `force`: a
|
|
164
164
|
* record that disagrees with the file on disk stays a conflict regardless of `adopt`.
|
|
165
165
|
*/
|
|
166
|
-
function planCodexAgents({ scope, codexDir, projectRoot, sourceDir, sourceVersion = 'unknown', managedResources = [], adopt = false, fsImpl = fs }
|
|
166
|
+
function planCodexAgents({ scope, codexDir, projectRoot, sourceDir, sourceVersion = 'unknown', managedResources = [], adopt = false, fsImpl = fs } ) {
|
|
167
167
|
const directory = agentDirectory({ scope, codexDir, projectRoot });
|
|
168
168
|
const agents = discoverCodexAgents(sourceDir, fsImpl);
|
|
169
|
-
const owned = new Map
|
|
170
|
-
const changes
|
|
171
|
-
const conflicts
|
|
172
|
-
const nextManagedResources = managedResources.filter((resource
|
|
169
|
+
const owned = new Map (managedResources.filter((resource ) => isOwnedResource(resource, scope)).map((resource ) => [resource.identity, resource]));
|
|
170
|
+
const changes = [];
|
|
171
|
+
const conflicts = [];
|
|
172
|
+
const nextManagedResources = managedResources.filter((resource ) => !isOwnedResource(resource, scope));
|
|
173
173
|
for (const agent of agents) {
|
|
174
174
|
const file = destinationFor(directory, agent.name);
|
|
175
175
|
const identity = `agent:${agent.name}`;
|
|
@@ -191,13 +191,13 @@ function planCodexAgents({ scope, codexDir, projectRoot, sourceDir, sourceVersio
|
|
|
191
191
|
};
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
function applyCodexAgents(plan
|
|
194
|
+
function applyCodexAgents(plan , { dryRun = false, fsImpl = fs } = {}) {
|
|
195
195
|
if (!plan.ok || dryRun || plan.status === 'unchanged') return { ...plan, applied: false };
|
|
196
196
|
for (const change of plan.changes) atomicWrite(change.file, change.content, fsImpl);
|
|
197
197
|
return { ...plan, applied: true };
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
function reconcileCodexAgents(options
|
|
200
|
+
function reconcileCodexAgents(options ) {
|
|
201
201
|
return applyCodexAgents(planCodexAgents(options), options);
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
const crypto = require('node:crypto');
|
|
9
9
|
const fs = require('node:fs');
|
|
10
10
|
const path = require('node:path');
|
|
11
|
-
const { parseToml, stripComment, tableViolations } = require('../../helper/toml.
|
|
11
|
+
const { parseToml, stripComment, tableViolations } = require('../../helper/toml.js');
|
|
12
12
|
|
|
13
13
|
const CONFIG_NAME = 'config.toml';
|
|
14
14
|
const CONFIG_KIND = 'configuration-entry';
|
|
15
15
|
|
|
16
|
-
function configPath({ scope, codexDir, projectRoot, paths }
|
|
16
|
+
function configPath({ scope, codexDir, projectRoot, paths } = {}) {
|
|
17
17
|
// Preferred: the declared path resolved by the adapter (harnesses.json "paths".configFile).
|
|
18
18
|
if (paths?.configFile) return paths.configFile;
|
|
19
19
|
// Legacy derivation for direct callers passing raw scope inputs (tests, reconcileCodexConfig);
|
|
@@ -29,23 +29,23 @@ function configPath({ scope, codexDir, projectRoot, paths }: { scope?: string, c
|
|
|
29
29
|
throw new Error(`Unsupported Codex configuration scope: ${scope}`);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function fingerprint(value
|
|
32
|
+
function fingerprint(value ) {
|
|
33
33
|
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function commentSuffix(line
|
|
36
|
+
function commentSuffix(line ) {
|
|
37
37
|
const withoutComment = stripComment(line);
|
|
38
38
|
return line.slice(withoutComment.length);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function renderValue(value
|
|
41
|
+
function renderValue(value ) {
|
|
42
42
|
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
|
43
43
|
if (typeof value === 'string') return JSON.stringify(value);
|
|
44
44
|
throw new Error('Managed Codex config values must be boolean, number, or string');
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function normaliseDesired(resources
|
|
48
|
-
const result = new Map
|
|
47
|
+
function normaliseDesired(resources ) {
|
|
48
|
+
const result = new Map ();
|
|
49
49
|
for (const resource of resources || []) {
|
|
50
50
|
if (!resource || (resource.kind && resource.kind !== CONFIG_KIND && resource.kind !== 'config-entry')) continue;
|
|
51
51
|
const identity = resource.identity ?? resource.key;
|
|
@@ -62,49 +62,49 @@ function normaliseDesired(resources: any): Map<string, any> {
|
|
|
62
62
|
|
|
63
63
|
/** The comment on a line DoFlow added to a table the user opened with no entry: it is how removal
|
|
64
64
|
* knows to keep the user's header and restore the file's exact bytes. */
|
|
65
|
-
function addedMarker(table
|
|
65
|
+
function addedMarker(table ) { return ` # DoFlow added this to your [${table}] table`; }
|
|
66
66
|
|
|
67
67
|
/** Where a new entry `<table>.<key>` goes in the parsed file: after the last entry of its table;
|
|
68
68
|
* on the line right after the table's empty header, marked; as a root dotted line after the last
|
|
69
69
|
* root dotted line of that table; refused (`valueEntry`) when the table or a parent of it is set as
|
|
70
70
|
* a value, which TOML cannot extend; otherwise in a new table (no `at`). */
|
|
71
|
-
function placeEntry(identity
|
|
71
|
+
function placeEntry(identity , value , parsed ) {
|
|
72
72
|
const parts = identity.split('.');
|
|
73
73
|
const table = parts.slice(0, -1).join('.');
|
|
74
74
|
const rendered = renderValue(value);
|
|
75
75
|
const assignment = `${parts.at(-1)} = ${rendered}`;
|
|
76
|
-
const entries
|
|
77
|
-
const after = (items
|
|
78
|
-
const inTable = entries.filter(([, entry]
|
|
76
|
+
const entries = [...parsed.entries];
|
|
77
|
+
const after = (items ) => Math.max(...items.map(([, entry] ) => entry.line)) + 1;
|
|
78
|
+
const inTable = entries.filter(([, entry] ) => entry.table === table);
|
|
79
79
|
if (inTable.length) return { table, at: after(inTable), line: assignment };
|
|
80
|
-
const header = parsed.headers.find((item
|
|
80
|
+
const header = parsed.headers.find((item ) => item.table === table);
|
|
81
81
|
if (header) return { table, at: header.line + 1, line: `${assignment}${addedMarker(table)}` };
|
|
82
|
-
const dotted = entries.filter(([key, entry]
|
|
82
|
+
const dotted = entries.filter(([key, entry] ) => entry.table === '' && key.startsWith(`${table}.`));
|
|
83
83
|
if (dotted.length) return { table, at: after(dotted), line: `${identity} = ${rendered}` };
|
|
84
|
-
const valueEntry = entries.find(([key]
|
|
84
|
+
const valueEntry = entries.find(([key] ) => key === table || table.startsWith(`${key}.`));
|
|
85
85
|
if (valueEntry) return { table, valueEntry };
|
|
86
86
|
return { table, line: assignment };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/** Whether a managed entry's line is one placeEntry put into the user's own table: marked, or
|
|
90
90
|
* written as a dotted key outside its table. */
|
|
91
|
-
function placedLine(identity
|
|
91
|
+
function placedLine(identity , entry , lines ) {
|
|
92
92
|
const table = identity.split('.').slice(0, -1).join('.');
|
|
93
93
|
return entry.table !== table || lines[entry.line].trimEnd().endsWith(addedMarker(table));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/** A conflict when `content` breaks a TOML table rule that `original` did not already break, so a
|
|
97
97
|
* file that was valid is never written invalid; null otherwise. */
|
|
98
|
-
function newViolation({ file, original, content, identities }
|
|
99
|
-
const refused = (reason
|
|
98
|
+
function newViolation({ file, original, content, identities } ) {
|
|
99
|
+
const refused = (reason ) => `${file}: DoFlow cannot write ${identities.map((identity) => `'${identity}'`).join(', ')} without making the file invalid TOML (${reason}). Nothing was written.`;
|
|
100
100
|
const before = tableViolations(original);
|
|
101
101
|
let after;
|
|
102
|
-
try { after = tableViolations(content); } catch (error
|
|
103
|
-
const added = after.find((item
|
|
102
|
+
try { after = tableViolations(content); } catch (error ) { return refused(error.message); }
|
|
103
|
+
const added = after.find((item ) => !before.some((known ) => known.kind === item.kind && known.table === item.table));
|
|
104
104
|
return added ? refused(`${added.kind} '${added.table}' on line ${added.line}`) : null;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function isOwnedRecord(resource
|
|
107
|
+
function isOwnedRecord(resource , scope ) {
|
|
108
108
|
return resource && resource.target === 'codex' && resource.scope === scope &&
|
|
109
109
|
(resource.kind === CONFIG_KIND || resource.kind === 'config-entry') && typeof resource.identity === 'string';
|
|
110
110
|
}
|
|
@@ -118,20 +118,20 @@ function isOwnedRecord(resource: any, scope: any): boolean {
|
|
|
118
118
|
* entries written before the neutral ledger existed could never be re-adopted — the condition
|
|
119
119
|
* blocking the install was the same condition an install had to run to clear.
|
|
120
120
|
*/
|
|
121
|
-
function planCodexConfig({ file, scope, managedResources = [], desiredResources = [], adopt = false }
|
|
121
|
+
function planCodexConfig({ file, scope, managedResources = [], desiredResources = [], adopt = false } ) {
|
|
122
122
|
const original = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
123
123
|
let parsed;
|
|
124
|
-
try { parsed = parseToml(original); } catch (error
|
|
124
|
+
try { parsed = parseToml(original); } catch (error ) {
|
|
125
125
|
return { ok: false, status: 'malformed', file, original, changes: [], conflicts: [error.message], managedResources };
|
|
126
126
|
}
|
|
127
127
|
let desired;
|
|
128
|
-
try { desired = normaliseDesired(desiredResources); } catch (error
|
|
128
|
+
try { desired = normaliseDesired(desiredResources); } catch (error ) {
|
|
129
129
|
return { ok: false, status: 'invalid-request', file, original, changes: [], conflicts: [error.message], managedResources };
|
|
130
130
|
}
|
|
131
|
-
const owned = new Map
|
|
132
|
-
const changes
|
|
133
|
-
const conflicts
|
|
134
|
-
const placements = new Map
|
|
131
|
+
const owned = new Map (managedResources.filter((resource ) => isOwnedRecord(resource, scope)).map((resource ) => [resource.identity, resource]));
|
|
132
|
+
const changes = [];
|
|
133
|
+
const conflicts = [];
|
|
134
|
+
const placements = new Map ();
|
|
135
135
|
const all = new Set([...desired.keys(), ...owned.keys()]);
|
|
136
136
|
for (const identity of all) {
|
|
137
137
|
const entry = parsed.entries.get(identity);
|
|
@@ -164,10 +164,10 @@ function planCodexConfig({ file, scope, managedResources = [], desiredResources
|
|
|
164
164
|
}
|
|
165
165
|
if (conflicts.length) return { ok: false, status: 'conflict', file, original, changes: [], conflicts, managedResources };
|
|
166
166
|
const nextLines = [...parsed.lines];
|
|
167
|
-
for (const change of changes.filter((change
|
|
167
|
+
for (const change of changes.filter((change ) => change.type === 'update')) {
|
|
168
168
|
const key = change.identity.split('.').at(-1);
|
|
169
169
|
const originalLine = nextLines[change.line];
|
|
170
|
-
const indentation = originalLine.match(/^\s*/)
|
|
170
|
+
const indentation = originalLine.match(/^\s*/) [0];
|
|
171
171
|
// A user comment beside an owned value is still user content. Retain it even though the
|
|
172
172
|
// owned scalar changes, so reconciliation does not silently erase unrelated explanation.
|
|
173
173
|
const suffix = commentSuffix(originalLine);
|
|
@@ -175,18 +175,18 @@ function planCodexConfig({ file, scope, managedResources = [], desiredResources
|
|
|
175
175
|
}
|
|
176
176
|
// A line DoFlow placed in the user's own table (marker or dotted form) is deleted outright and its
|
|
177
177
|
// header kept, so the file returns to its exact bytes. Any other removal leaves a blank line.
|
|
178
|
-
const removals = changes.filter((change
|
|
179
|
-
const placedInUserTable = removals.filter((change
|
|
178
|
+
const removals = changes.filter((change ) => change.type === 'remove');
|
|
179
|
+
const placedInUserTable = removals.filter((change ) => placedLine(change.identity, parsed.entries.get(change.identity), parsed.lines));
|
|
180
180
|
for (const change of removals) nextLines[change.line] = placedInUserTable.includes(change) ? null : '';
|
|
181
|
-
dropEmptiedTables(nextLines, changes.filter((change
|
|
181
|
+
dropEmptiedTables(nextLines, changes.filter((change ) => !placedInUserTable.includes(change)), parsed.headers);
|
|
182
182
|
// A new key must land INSIDE its own table. Appending at end-of-file only happens to be
|
|
183
183
|
// correct when that table is the file's last one — otherwise the key silently joins whichever
|
|
184
184
|
// table trails the file, so `features.hooks` written after an `[mcp_servers.x]` block becomes
|
|
185
185
|
// `mcp_servers.x.hooks`. placeEntry chooses the spot; genuinely new tables are appended once
|
|
186
186
|
// each, with all of their keys grouped under a single header.
|
|
187
|
-
const insertions
|
|
188
|
-
const newTables = new Map
|
|
189
|
-
for (const change of changes.filter((change
|
|
187
|
+
const insertions = [];
|
|
188
|
+
const newTables = new Map ();
|
|
189
|
+
for (const change of changes.filter((change ) => change.type === 'create')) {
|
|
190
190
|
const placement = placements.get(change.identity);
|
|
191
191
|
if (placement.at !== undefined) insertions.push({ at: placement.at, line: placement.line });
|
|
192
192
|
else newTables.set(placement.table, [...(newTables.get(placement.table) || []), placement.line]);
|
|
@@ -204,11 +204,11 @@ function planCodexConfig({ file, scope, managedResources = [], desiredResources
|
|
|
204
204
|
}
|
|
205
205
|
let content = nextLines.join('\n');
|
|
206
206
|
if (content && !content.endsWith('\n')) content += '\n';
|
|
207
|
-
const invalid = newViolation({ file, original, content, identities: changes.map((change
|
|
207
|
+
const invalid = newViolation({ file, original, content, identities: changes.map((change ) => change.identity) });
|
|
208
208
|
if (invalid) return { ok: false, status: 'conflict', file, original, changes: [], conflicts: [invalid], managedResources };
|
|
209
|
-
const nextManagedResources = managedResources.filter((resource
|
|
209
|
+
const nextManagedResources = managedResources.filter((resource ) => !isOwnedRecord(resource, scope) || desired.has(resource.identity));
|
|
210
210
|
for (const resource of desired.values()) {
|
|
211
|
-
const index = nextManagedResources.findIndex((item
|
|
211
|
+
const index = nextManagedResources.findIndex((item ) => isOwnedRecord(item, scope) && item.identity === resource.identity);
|
|
212
212
|
const updated = { ...resource, target: 'codex', scope, kind: CONFIG_KIND, fingerprint: fingerprint(resource.value) };
|
|
213
213
|
if (index >= 0) nextManagedResources[index] = updated;
|
|
214
214
|
else nextManagedResources.push(updated);
|
|
@@ -221,17 +221,17 @@ function planCodexConfig({ file, scope, managedResources = [], desiredResources
|
|
|
221
221
|
* it was the file's last table, the blank line the table append puts before a new header goes too.
|
|
222
222
|
* A table that also receives a created key, or whose header line carries a comment, is kept.
|
|
223
223
|
* Headers come from the parser's own records, never from re-reading lines. */
|
|
224
|
-
function dropEmptiedTables(lines
|
|
225
|
-
const tableOf = (identity
|
|
226
|
-
const removedTables = new Map
|
|
227
|
-
const createdTables = new Set
|
|
228
|
-
const headers
|
|
224
|
+
function dropEmptiedTables(lines , changes , headerRecords ) {
|
|
225
|
+
const tableOf = (identity ) => identity.split('.').slice(0, -1).join('.');
|
|
226
|
+
const removedTables = new Map (changes.filter((change) => change.type === 'remove').map((change) => [change.line, tableOf(change.identity)]));
|
|
227
|
+
const createdTables = new Set (changes.filter((change) => change.type === 'create').map((change) => tableOf(change.identity)));
|
|
228
|
+
const headers = headerRecords.map((header) => header.line);
|
|
229
229
|
headers.forEach((start, position) => {
|
|
230
230
|
const end = headers[position + 1] ?? lines.length;
|
|
231
231
|
if (stripComment(lines[start]) !== lines[start]) return;
|
|
232
|
-
const body = lines.slice(start + 1, end).map((_
|
|
232
|
+
const body = lines.slice(start + 1, end).map((_ , offset ) => start + 1 + offset);
|
|
233
233
|
const removedHere = body.filter((index) => removedTables.has(index));
|
|
234
|
-
if (!removedHere.length || removedHere.some((index) => createdTables.has(removedTables.get(index)
|
|
234
|
+
if (!removedHere.length || removedHere.some((index) => createdTables.has(removedTables.get(index) ))) return;
|
|
235
235
|
if (body.some((index) => !removedTables.has(index) && lines[index] !== null && lines[index].trim() !== '')) return;
|
|
236
236
|
lines[start] = null;
|
|
237
237
|
for (const index of removedHere) lines[index] = null;
|
|
@@ -241,7 +241,7 @@ function dropEmptiedTables(lines: any[], changes: any[], headerRecords: any[]):
|
|
|
241
241
|
|
|
242
242
|
/** Replace a file through a sibling temporary file, keeping the existing file's permission bits so
|
|
243
243
|
* a config kept private stays so. A file this creates gets the process's default mode. */
|
|
244
|
-
function atomicWrite(file
|
|
244
|
+
function atomicWrite(file , content , fsImpl = fs) {
|
|
245
245
|
fsImpl.mkdirSync(path.dirname(file), { recursive: true });
|
|
246
246
|
const mode = fsImpl.existsSync(file) ? fsImpl.statSync(file).mode & 0o7777 : null;
|
|
247
247
|
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
@@ -254,7 +254,7 @@ function atomicWrite(file: string, content: string, fsImpl: any = fs): void {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
function applyCodexConfig(plan
|
|
257
|
+
function applyCodexConfig(plan , { dryRun = false, fsImpl = fs } = {}) {
|
|
258
258
|
if (!plan.ok) return { ...plan, applied: false };
|
|
259
259
|
if (dryRun || plan.status === 'unchanged') return { ...plan, applied: false };
|
|
260
260
|
// A removal that leaves nothing behind deletes the file rather than leaving it empty.
|
|
@@ -263,7 +263,7 @@ function applyCodexConfig(plan: any, { dryRun = false, fsImpl = fs }: { dryRun?:
|
|
|
263
263
|
return { ...plan, applied: true };
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
function reconcileCodexConfig(options
|
|
266
|
+
function reconcileCodexConfig(options ) {
|
|
267
267
|
const file = options.file ?? configPath(options);
|
|
268
268
|
const plan = planCodexConfig({ ...options, file });
|
|
269
269
|
return applyCodexConfig(plan, options);
|
|
@@ -7,19 +7,19 @@
|
|
|
7
7
|
// that are not executable. Trust is reported as a prerequisite rather than being bypassed.
|
|
8
8
|
const fs = require('node:fs');
|
|
9
9
|
const path = require('node:path');
|
|
10
|
-
const { verifyHookCommands } = require('../hook-commands.
|
|
11
|
-
const { mergeHooks } = require('../../helper/settings-merge.
|
|
12
|
-
const { atomicWrite } = require('./config.
|
|
10
|
+
const { verifyHookCommands } = require('../hook-commands.js');
|
|
11
|
+
const { mergeHooks } = require('../../helper/settings-merge.js');
|
|
12
|
+
const { atomicWrite } = require('./config.js');
|
|
13
13
|
|
|
14
14
|
const SUPPORTED_EVENTS = new Set([
|
|
15
15
|
'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact',
|
|
16
16
|
'UserPromptSubmit', 'SubagentStop', 'Stop', 'SessionStart', 'SubagentStart', 'SessionEnd',
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
-
function validationError(errors
|
|
19
|
+
function validationError(errors , message ) { errors.push(message); }
|
|
20
20
|
|
|
21
|
-
function validateHooksConfig(config
|
|
22
|
-
const errors
|
|
21
|
+
function validateHooksConfig(config ) {
|
|
22
|
+
const errors = [];
|
|
23
23
|
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
24
24
|
return { ok: false, errors: ['Hook configuration must be an object'], events: [] };
|
|
25
25
|
}
|
|
@@ -35,7 +35,7 @@ function validateHooksConfig(config: any): { ok: boolean, errors: string[], even
|
|
|
35
35
|
validationError(errors, `Hook event '${event}' requires a non-empty array of matcher groups`);
|
|
36
36
|
continue;
|
|
37
37
|
}
|
|
38
|
-
groups.forEach((group
|
|
38
|
+
groups.forEach((group , groupIndex ) => {
|
|
39
39
|
if (!group || typeof group !== 'object' || Array.isArray(group)) {
|
|
40
40
|
validationError(errors, `Hook event '${event}' group ${groupIndex} must be an object`);
|
|
41
41
|
return;
|
|
@@ -47,7 +47,7 @@ function validateHooksConfig(config: any): { ok: boolean, errors: string[], even
|
|
|
47
47
|
validationError(errors, `Hook event '${event}' group ${groupIndex} requires a non-empty hooks array`);
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
|
-
group.hooks.forEach((handler
|
|
50
|
+
group.hooks.forEach((handler , handlerIndex ) => {
|
|
51
51
|
const label = `Hook event '${event}' group ${groupIndex} handler ${handlerIndex}`;
|
|
52
52
|
if (!handler || typeof handler !== 'object' || Array.isArray(handler)) {
|
|
53
53
|
validationError(errors, `${label} must be an object`);
|
|
@@ -73,9 +73,9 @@ function validateHooksConfig(config: any): { ok: boolean, errors: string[], even
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** Maps Claude guardrail intent, never fabricating a Codex equivalent for unavailable events. */
|
|
76
|
-
function classifyClaudeGuardrails(claudeHooks
|
|
77
|
-
const supported
|
|
78
|
-
const unsupported
|
|
76
|
+
function classifyClaudeGuardrails(claudeHooks = {}) {
|
|
77
|
+
const supported = [];
|
|
78
|
+
const unsupported = [];
|
|
79
79
|
for (const [event, groups] of Object.entries(claudeHooks || {})) {
|
|
80
80
|
const item = { sourceEvent: event, targetEvent: event, groups: Array.isArray(groups) ? groups.length : 0 };
|
|
81
81
|
if (SUPPORTED_EVENTS.has(event)) supported.push(item);
|
|
@@ -84,7 +84,7 @@ function classifyClaudeGuardrails(claudeHooks: any = {}): { supported: any[], un
|
|
|
84
84
|
return { supported, unsupported };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function destinationFor(context
|
|
87
|
+
function destinationFor(context = {}) {
|
|
88
88
|
// Preferred: the declared path resolved by the adapter (harnesses.json "paths".hooksFile).
|
|
89
89
|
if (context.paths?.hooksFile) return context.paths.hooksFile;
|
|
90
90
|
// Legacy derivation for direct callers passing raw scope inputs; kept byte-compatible with the
|
|
@@ -102,20 +102,20 @@ function destinationFor(context: any = {}): string {
|
|
|
102
102
|
throw new Error('A hook destination or Codex scope is required');
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function planCodexHooks({ config, sourceFile, sourceHooksDir, destinationContext = {}, trusted = false, fsImpl = fs }
|
|
105
|
+
function planCodexHooks({ config, sourceFile, sourceHooksDir, destinationContext = {}, trusted = false, fsImpl = fs } = {}) {
|
|
106
106
|
let desired = config;
|
|
107
107
|
try {
|
|
108
108
|
if (!desired && sourceFile) desired = JSON.parse(fsImpl.readFileSync(sourceFile, 'utf8'));
|
|
109
|
-
} catch (error
|
|
109
|
+
} catch (error ) {
|
|
110
110
|
return { ok: false, status: 'malformed', changes: [], errors: [`Could not parse ${sourceFile}: ${error.message}`] };
|
|
111
111
|
}
|
|
112
112
|
const schema = validateHooksConfig(desired);
|
|
113
113
|
let destination;
|
|
114
|
-
try { destination = destinationFor(destinationContext); } catch (error
|
|
114
|
+
try { destination = destinationFor(destinationContext); } catch (error ) {
|
|
115
115
|
return { ok: false, status: 'invalid-request', changes: [], errors: [...schema.errors, error.message] };
|
|
116
116
|
}
|
|
117
117
|
const commands = verifyHookCommands(desired || {}, { scriptsDir: sourceHooksDir, trusted, fsImpl });
|
|
118
|
-
const errors = [...schema.errors, ...commands.checks.filter((check
|
|
118
|
+
const errors = [...schema.errors, ...commands.checks.filter((check ) => !check.ok).map((check ) => `${check.event}: ${check.reason}${check.script ? ` (${check.script})` : ''}`)];
|
|
119
119
|
let mergedDesired = desired;
|
|
120
120
|
const original = fsImpl.existsSync(destination) ? fsImpl.readFileSync(destination, 'utf8') : '';
|
|
121
121
|
if (original.trim()) {
|
|
@@ -136,19 +136,19 @@ function planCodexHooks({ config, sourceFile, sourceHooksDir, destinationContext
|
|
|
136
136
|
|| path.join(path.dirname(destination), 'hooks');
|
|
137
137
|
// The scripts deployCodexHooks copies beside hooks.json, so a backup of this change holds them too.
|
|
138
138
|
const companionTargets = [...new Set([
|
|
139
|
-
...commands.checks.map((check
|
|
140
|
-
...shippedHookScripts(sourceHooksDir, fsImpl).map((name
|
|
139
|
+
...commands.checks.map((check ) => path.join(destinationHooksDir, check.script)),
|
|
140
|
+
...shippedHookScripts(sourceHooksDir, fsImpl).map((name ) => path.join(destinationHooksDir, name)),
|
|
141
141
|
])].sort();
|
|
142
142
|
return { ok: true, status: changed ? 'change' : 'unchanged', destination, original, content,
|
|
143
143
|
changes: changed ? [{ type: fsImpl.existsSync(destination) ? 'update' : 'create', file: destination, companionTargets }] : [],
|
|
144
144
|
errors: [], commands, trust: commands.trust, scriptsDir: sourceHooksDir, destinationHooksDir };
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
function deployCodexHooks(plan
|
|
147
|
+
function deployCodexHooks(plan , { dryRun = false, fsImpl = fs } = {}) {
|
|
148
148
|
if (!plan.ok) return { ...plan, applied: false };
|
|
149
149
|
if (dryRun || plan.status === 'unchanged') return { ...plan, applied: false };
|
|
150
150
|
atomicWrite(plan.destination, plan.content, fsImpl);
|
|
151
|
-
const deployed = new Set
|
|
151
|
+
const deployed = new Set ();
|
|
152
152
|
for (const check of plan.commands.checks) {
|
|
153
153
|
const target = path.join(plan.destinationHooksDir, check.script);
|
|
154
154
|
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
@@ -174,16 +174,16 @@ function deployCodexHooks(plan: any, { dryRun = false, fsImpl = fs }: { dryRun?:
|
|
|
174
174
|
|
|
175
175
|
/** Every file deployCodexHooks copies beside hooks.json: each regular, non-JSON file in the
|
|
176
176
|
* shipped scripts directory (the command-named scripts are among them). */
|
|
177
|
-
function shippedHookScripts(scriptsDir
|
|
177
|
+
function shippedHookScripts(scriptsDir , fsImpl = fs) {
|
|
178
178
|
if (!scriptsDir || !fsImpl.existsSync(scriptsDir)) return [];
|
|
179
179
|
return fsImpl.readdirSync(scriptsDir)
|
|
180
|
-
.filter((name
|
|
180
|
+
.filter((name ) => !name.endsWith('.json') && fsImpl.statSync(path.join(scriptsDir, name)).isFile());
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/** Undo deployCodexHooks' script copies. The ledger records only hooks.json, so ownership of a
|
|
184
184
|
* script is proven by its bytes: one still identical to the shipped source is deleted, one the
|
|
185
185
|
* user edited or added stays. The directory goes only once it is empty. */
|
|
186
|
-
function removeCodexHookScripts({ scriptsDir, destinationHooksDir }
|
|
186
|
+
function removeCodexHookScripts({ scriptsDir, destinationHooksDir } = {}, { dryRun = false, fsImpl = fs } = {}) {
|
|
187
187
|
if (!destinationHooksDir || !fsImpl.existsSync(destinationHooksDir)) return { removed: 0 };
|
|
188
188
|
let removed = 0;
|
|
189
189
|
for (const name of shippedHookScripts(scriptsDir, fsImpl)) {
|