@khoavu882/doflow 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/bin/doflow.js +1 -1
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +4 -1
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/claude/hooks/session-end.sh +21 -1
- package/core/harnesses/claude/settings/settings.json +8 -9
- package/core/harnesses/codex/hooks/hooks.json +0 -10
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
- package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
- package/core/harnesses/copilot/hooks/doflow.json +0 -6
- package/core/harnesses/kiro/agents/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
- package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
- package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
- package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
- package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
- package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
- package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
- package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
- package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +26 -2
- package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
- package/core/registry/contracts.json +1 -1
- package/core/registry/contribution-points.json +5 -2
- package/core/registry/gates.json +5 -5
- package/core/registry/harnesses.json +9 -9
- package/core/registry/intake.json +53 -0
- package/core/registry/lifecycle.json +176 -503
- package/core/registry/package-templates/intake-category/files/category.json +8 -0
- package/core/registry/package-templates/intake-category/template.json +4 -0
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/rules.json +8 -8
- package/core/registry/settings.json +12 -16
- package/core/registry/storage.json +9 -2
- package/core/registry/telemetry.json +1 -1
- package/core/registry/verification.json +0 -5
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
- package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
- package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
- package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
- package/core/shared/guidance/rules/RULES.md +2 -2
- package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
- package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
- package/core/shared/scripts/doflow/bash/validate-artifacts.sh +1 -1
- package/core/shared/scripts/doflow/bin/doflow-run +23 -2
- package/core/shared/skills/do/SKILL.md +3 -3
- package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
- package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
- package/core/shared/skills/do-code-review/SKILL.md +7 -4
- package/core/shared/skills/do-design/SKILL.md +11 -5
- package/core/shared/skills/do-design/references/data_model_example.md +167 -0
- package/core/shared/skills/do-diagnose/SKILL.md +4 -2
- package/core/shared/skills/do-document/SKILL.md +1 -0
- package/core/shared/skills/do-document/references/embedded-research.md +1 -1
- package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
- package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
- package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
- package/core/shared/skills/do-flow/SKILL.md +4 -4
- package/core/shared/skills/do-flow/references/task_classes.md +5 -5
- package/core/shared/skills/do-implement/SKILL.md +9 -7
- package/core/shared/skills/do-plan/SKILL.md +9 -6
- package/core/shared/templates/doflow/data-model-template.md +136 -102
- package/core/shared/workflows/do-flow-chain.md +2 -0
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +54 -12
- package/src/adapters/claude/index.js +23 -2
- package/src/adapters/codex/agents.js +15 -1
- package/src/adapters/codex/config.js +5 -2
- package/src/adapters/codex/hooks.js +39 -3
- package/src/adapters/codex/index.js +13 -0
- package/src/adapters/copilot/index.js +11 -4
- package/src/adapters/copy-tree.js +8 -2
- package/src/adapters/created-file.js +41 -0
- package/src/adapters/mcp-entries.js +11 -1
- package/src/adapters/opencode/index.js +32 -5
- package/src/adapters/pi/index.js +15 -4
- package/src/adapters/pi/mcp.js +44 -18
- package/src/cli/commands/remove.js +3 -1
- package/src/cli/commands/status.js +1 -1
- package/src/cli/commands/update.js +7 -3
- package/src/cli/index.js +43 -19
- package/src/cli/runtime-commands.js +13 -5
- package/src/cli/shared.js +5 -3
- package/src/helper/marker-merge.js +11 -7
- package/src/install/backup-set.js +7 -3
- package/src/install/backup.js +13 -7
- package/src/install/context.js +2 -1
- package/src/install/manifest.js +35 -11
- package/src/install/paths.js +38 -0
- package/src/install/targets.js +11 -2
- package/src/lifecycle/coverage.js +1 -30
- package/src/lifecycle/hook-tier.js +2 -2
- package/src/lifecycle/index.js +80 -3
- package/src/lifecycle/retired-harnesses.js +16 -1
- package/src/lifecycle/view.js +3 -2
- package/src/registry/index.js +21 -4
- package/src/release/docs-blocks.js +37 -53
- package/src/runtime/advice/doctor.js +2 -2
- package/src/runtime/brief-tools.js +2 -1
- package/src/runtime/capability-router.js +26 -3
- package/src/runtime/context/pointer.js +84 -17
- package/src/runtime/context-pack.js +52 -9
- package/src/runtime/dispatch/integrate.js +27 -8
- package/src/runtime/dispatch/loop.js +7 -0
- package/src/runtime/dispatch/verify-run.js +9 -3
- package/src/runtime/evidence-ledger.js +18 -0
- package/src/runtime/feature-pointer.js +10 -1
- package/src/runtime/health.js +68 -5
- package/src/runtime/implementation-gate.js +14 -6
- package/src/runtime/intake/engine.js +679 -0
- package/src/runtime/intake/index.js +136 -0
- package/src/runtime/intake/manifest.js +140 -0
- package/src/runtime/intake/taxonomy.js +206 -0
- package/src/runtime/knowledge/code-retrieval.js +11 -5
- package/src/runtime/knowledge/semble-provider.js +12 -2
- package/src/runtime/lifecycle/cli.js +76 -6
- package/src/runtime/lifecycle/followup.js +32 -6
- package/src/runtime/lifecycle/overview.js +34 -3
- package/src/runtime/lifecycle/release.js +16 -3
- package/src/runtime/lifecycle/retention.js +12 -15
- package/src/runtime/lifecycle/store-upkeep.js +13 -3
- package/src/runtime/mask.js +3 -3
- package/src/runtime/mcp-consultations.js +11 -5
- package/src/runtime/packages/points.js +10 -0
- package/src/runtime/research-request.js +3 -1
- package/src/runtime/review/command.js +3 -7
- package/src/runtime/review/start.js +24 -10
- package/src/runtime/roadmap/attendance.js +137 -0
- package/src/runtime/roadmap/gates.js +6 -3
- package/src/runtime/roadmap/loop.js +127 -33
- package/src/runtime/roadmap/merge.js +17 -8
- package/src/runtime/roadmap/rows.js +15 -5
- package/src/runtime/roadmap/seed.js +153 -41
- package/src/runtime/roadmap/worktree-cleanup.js +147 -0
- package/src/runtime/settings/files.js +3 -1
- package/src/runtime/settings/prepared-run.js +12 -2
- package/src/runtime/settings/registry.js +1 -1
- package/src/runtime/settings/resolve.js +13 -1
- package/src/runtime/settings/write.js +73 -2
- package/src/runtime/slots.js +1 -1
- package/src/runtime/storage/registry.js +2 -2
- package/src/runtime/storage/roots.js +4 -18
- package/src/runtime/storage/rules.js +22 -46
- package/src/runtime/storage/work-status.js +14 -4
- package/src/runtime/task-classifier.js +39 -4
- package/src/runtime/task-scope.js +17 -2
- package/src/runtime/task-state.js +13 -7
- package/src/runtime/trace/analyze.js +4 -2
- package/src/runtime/trace/indicators.js +25 -20
- package/src/runtime/trace/spans.js +4 -5
- package/src/runtime/verb-view.js +268 -0
- package/src/runtime/verification/engine.js +0 -45
- package/src/runtime/worker/command.js +4 -2
- package/src/runtime/worker/launch.js +5 -37
- package/src/runtime/worker/readiness.js +10 -6
- package/src/runtime/worker/supervisor.js +18 -6
- package/src/runtime/workflow-orchestrator.js +39 -7
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
- package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -579
|
@@ -202,8 +202,11 @@ function planCodexConfig({ file, scope, managedResources = [], desiredResources
|
|
|
202
202
|
if (nextLines.length && nextLines.at(-1) !== '') nextLines.push('');
|
|
203
203
|
nextLines.push(`[${table}]`, ...lines);
|
|
204
204
|
}
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
// The file keeps its own line ending (the first one it uses) and its own final-newline state; only a file
|
|
206
|
+
// that was empty or ended in a newline gets one, so a remove can hand back the user's exact bytes.
|
|
207
|
+
const eol = /\r?\n/.exec(original)?.[0] ?? '\n';
|
|
208
|
+
let content = nextLines.join(eol);
|
|
209
|
+
if (content && !content.endsWith('\n') && (original === '' || original.endsWith('\n'))) content += eol;
|
|
207
210
|
const invalid = newViolation({ file, original, content, identities: changes.map((change ) => change.identity) });
|
|
208
211
|
if (invalid) return { ok: false, status: 'conflict', file, original, changes: [], conflicts: [invalid], managedResources };
|
|
209
212
|
const nextManagedResources = managedResources.filter((resource ) => !isOwnedRecord(resource, scope) || desired.has(resource.identity));
|
|
@@ -17,6 +17,41 @@ const SUPPORTED_EVENTS = new Set([
|
|
|
17
17
|
'UserPromptSubmit', 'SubagentStop', 'Stop', 'SessionStart', 'SubagentStart', 'SessionEnd',
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
|
+
// An earlier DoFlow wired a pre-bash-guard front door into hooks.json and deployed its script. Both
|
|
21
|
+
// are gone from the shipped source; a merge drops the entry from the live file so an updated install
|
|
22
|
+
// does not keep a hook that runs a script nothing refreshes, and the script goes with it. Only the
|
|
23
|
+
// exact command DoFlow shipped is dropped: a user's own hook that happens to name a script of the same
|
|
24
|
+
// file name stays.
|
|
25
|
+
const RETIRED_HOOK_SCRIPT = 'pre-bash-guard.sh';
|
|
26
|
+
const RETIRED_HOOK_COMMAND = 'bash "$(root=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f "$root/.codex/hooks/pre-bash-guard.sh" ] && printf \'%s/.codex/hooks/pre-bash-guard.sh\' "$root" || printf \'%s/hooks/pre-bash-guard.sh\' "${CODEX_HOME:-$HOME/.codex}")"';
|
|
27
|
+
|
|
28
|
+
function withoutRetiredHooks(hooks ) {
|
|
29
|
+
const result = {};
|
|
30
|
+
for (const [event, groups] of Object.entries (hooks || {})) {
|
|
31
|
+
if (!Array.isArray(groups)) { result[event] = groups; continue; }
|
|
32
|
+
const kept = groups.map((group ) => (Array.isArray(group?.hooks)
|
|
33
|
+
? { ...group, hooks: group.hooks.filter((handler ) => handler?.command !== RETIRED_HOOK_COMMAND) }
|
|
34
|
+
: group)).filter((group ) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
|
|
35
|
+
if (kept.length) result[event] = kept;
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether the retired front door is still on disk as DoFlow's own (it names the bridge call it was written with). */
|
|
41
|
+
function ownsRetiredHookScript(destinationHooksDir , fsImpl = fs) {
|
|
42
|
+
const target = path.join(destinationHooksDir, RETIRED_HOOK_SCRIPT);
|
|
43
|
+
try {
|
|
44
|
+
return fsImpl.lstatSync(target).isFile() && fsImpl.readFileSync(target, 'utf8').includes('doflow_bridge codex PreToolUse pre-bash-guard');
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Delete the retired front door when it is still DoFlow's own. */
|
|
49
|
+
function removeRetiredHookScript(destinationHooksDir , fsImpl = fs, dryRun = false) {
|
|
50
|
+
if (!ownsRetiredHookScript(destinationHooksDir, fsImpl)) return 0;
|
|
51
|
+
if (!dryRun) { try { fsImpl.unlinkSync(path.join(destinationHooksDir, RETIRED_HOOK_SCRIPT)); } catch { return 0; } }
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
function validationError(errors , message ) { errors.push(message); }
|
|
21
56
|
|
|
22
57
|
function validateHooksConfig(config ) {
|
|
@@ -148,7 +183,7 @@ function planCodexHooks({ config, sourceFile, sourceHooksDir, destinationContext
|
|
|
148
183
|
try {
|
|
149
184
|
const existing = JSON.parse(original);
|
|
150
185
|
if (existing?.hooks && desired?.hooks) {
|
|
151
|
-
mergedDesired = { ...existing, ...desired, hooks: mergeHooks(existing.hooks, desired.hooks) };
|
|
186
|
+
mergedDesired = { ...existing, ...desired, hooks: mergeHooks(withoutRetiredHooks(existing.hooks), desired.hooks) };
|
|
152
187
|
}
|
|
153
188
|
} catch {
|
|
154
189
|
// malformed existing will be flagged or left for schema validation
|
|
@@ -167,7 +202,7 @@ function planCodexHooks({ config, sourceFile, sourceHooksDir, destinationContext
|
|
|
167
202
|
return { ok: false, status: 'conflict', destination, original, changes: [], commands,
|
|
168
203
|
errors: scripts.edited.map((name ) => `Codex hook script '${name}' was modified outside DoFlow`) };
|
|
169
204
|
}
|
|
170
|
-
const scriptsOnly = !configChanged && scripts.stale.length > 0;
|
|
205
|
+
const scriptsOnly = !configChanged && (scripts.stale.length > 0 || ownsRetiredHookScript(destinationHooksDir, fsImpl));
|
|
171
206
|
const changed = configChanged || scriptsOnly;
|
|
172
207
|
// The scripts deployCodexHooks copies beside hooks.json, so a backup of this change holds them too.
|
|
173
208
|
const companionTargets = [...new Set([
|
|
@@ -204,6 +239,7 @@ function deployCodexHooks(plan , { dryRun = false, fsImpl = fs }
|
|
|
204
239
|
fsImpl.copyFileSync(source, target);
|
|
205
240
|
fsImpl.chmodSync(target, fsImpl.statSync(source).mode & 0o777);
|
|
206
241
|
}
|
|
242
|
+
removeRetiredHookScript(plan.destinationHooksDir, fsImpl);
|
|
207
243
|
return { ...plan, applied: true };
|
|
208
244
|
}
|
|
209
245
|
|
|
@@ -220,7 +256,7 @@ function shippedHookScripts(scriptsDir , fsImpl = fs) {
|
|
|
220
256
|
* user edited or added stays. The directory goes only once it is empty. */
|
|
221
257
|
function removeCodexHookScripts({ scriptsDir, destinationHooksDir } = {}, { dryRun = false, fsImpl = fs } = {}) {
|
|
222
258
|
if (!destinationHooksDir || !fsImpl.existsSync(destinationHooksDir)) return { removed: 0 };
|
|
223
|
-
let removed =
|
|
259
|
+
let removed = removeRetiredHookScript(destinationHooksDir, fsImpl, dryRun);
|
|
224
260
|
for (const name of shippedHookScripts(scriptsDir, fsImpl)) {
|
|
225
261
|
const target = path.join(destinationHooksDir, name);
|
|
226
262
|
// lstat, not stat: a symlink (even to a file with the shipped bytes) is not DoFlow's copy.
|
|
@@ -503,9 +503,22 @@ function createCodexAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
|
|
|
503
503
|
hooks = { ...hooks, ok: false, status: 'conflict', changes: [], errors: ['Codex hooks.json was modified outside DoFlow'] };
|
|
504
504
|
}
|
|
505
505
|
}
|
|
506
|
+
if (hooks.ok) hooks = withStaleScriptReleases(hooks, scriptRows(neutralResources, context));
|
|
506
507
|
return hooks;
|
|
507
508
|
}
|
|
508
509
|
|
|
510
|
+
/** A script row for a name the source no longer ships (a retired front door) is released with the
|
|
511
|
+
* plan, so the ledger does not keep a row for a file the update deletes. The script itself goes by
|
|
512
|
+
* its bytes (deployCodexHooks), so this only drops the row. */
|
|
513
|
+
function withStaleScriptReleases(hooks , rows ) {
|
|
514
|
+
const shipped = new Set(hooks.scriptNames);
|
|
515
|
+
const releases = rows.filter((row ) => !shipped.has(row.identity)).map((row ) => ({ type: 'remove', assetId: row.assetId, identity: row.identity,
|
|
516
|
+
target: row.target, ownershipIdentity: row.ownershipIdentity, kind: row.kind, release: true }));
|
|
517
|
+
if (!releases.length) return hooks;
|
|
518
|
+
return { ...hooks, changes: [...hooks.changes, ...releases],
|
|
519
|
+
...(hooks.status === 'unchanged' ? { status: 'change', scriptsOnly: true } : {}) };
|
|
520
|
+
}
|
|
521
|
+
|
|
509
522
|
/** Agents/hooks share one removing-vs-planning fork: removal always owns both via the neutral
|
|
510
523
|
* ledger's own records, regardless of what native input the caller provided this run. */
|
|
511
524
|
/** A removal drops the rows of the hook scripts too. The scripts themselves go by their bytes
|
|
@@ -36,6 +36,7 @@ const path = require('node:path');
|
|
|
36
36
|
const { MARKER_START, MARKER_END } = require('../../helper/marker-merge.js');
|
|
37
37
|
const { planTree, applyTree, removeTree, verifyTree, copyTreeDestDir, ledgerFileResources, ledgerSiblingFingerprints, siblingReplacedNotices, fingerprint, sourceDirFor, resolveTransform } = require('../copy-tree.js');
|
|
38
38
|
const { renderGuidancePointer } = require('../guidance-pointer.js');
|
|
39
|
+
const { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile } = require('../created-file.js');
|
|
39
40
|
const { readMcpFiles, planMcpEntries, verifyMcpEntries, ownedMcpIds, writeMcpEntries } = require('../mcp-entries.js');
|
|
40
41
|
const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
|
|
41
42
|
|
|
@@ -108,7 +109,8 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
|
|
|
108
109
|
* file it doesn't fully own is a decision for the file's owner, not a guess for the installer. Add
|
|
109
110
|
* an empty marker pair to opt in. */
|
|
110
111
|
function managedInstruction(existing , rendered ) {
|
|
111
|
-
|
|
112
|
+
// A file holding only whitespace has nothing to protect and counts as absent.
|
|
113
|
+
if (existing === null || isBlank(existing)) return { ok: true, operation: 'create', content: rendered };
|
|
112
114
|
const start = existing.indexOf(MARKER_START);
|
|
113
115
|
const end = existing.indexOf(MARKER_END);
|
|
114
116
|
if (start === -1 && end === -1) return { ok: false, conflict: `${INSTRUCTION_FILE} exists without a DoFlow managed section` };
|
|
@@ -132,7 +134,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
|
|
|
132
134
|
/** Plan the single instructions change (or none). Global scope always yields no change and no
|
|
133
135
|
* conflict — the user-level ~/.copilot/copilot-instructions.md is not projected, so there is no
|
|
134
136
|
* global target, the same way other adapters skip a capability they do not project at a scope. */
|
|
135
|
-
function planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, fsImpl } ) {
|
|
137
|
+
function planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, ledger, fsImpl } ) {
|
|
136
138
|
if (!found.paths.instruction) return { changes: [], conflicts: [] };
|
|
137
139
|
// The marker-managed instruction file is one specific asset: exclude tree-projected rule
|
|
138
140
|
// assets (copilot-rule-instructions), whose `source` is a directory, not a file.
|
|
@@ -148,6 +150,8 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
|
|
|
148
150
|
assetId: guidance.id, target: found.paths.instruction, operation: outcome.operation,
|
|
149
151
|
content: outcome.content, ownershipIdentity: `${HARNESS}:instructions:managed-section`,
|
|
150
152
|
fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'copilot-instructions' },
|
|
153
|
+
// A file DoFlow created and a removal that leaves only whitespace: the file goes (remove()).
|
|
154
|
+
...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:instructions:managed-section`, found.paths.instruction) ? { deleteWhenBlank: true } : {}),
|
|
151
155
|
}],
|
|
152
156
|
conflicts: [],
|
|
153
157
|
};
|
|
@@ -305,7 +309,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
|
|
|
305
309
|
const conflicts = [];
|
|
306
310
|
const removing = context.operation === 'remove';
|
|
307
311
|
|
|
308
|
-
const instructions = planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, fsImpl });
|
|
312
|
+
const instructions = planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, ledger, fsImpl });
|
|
309
313
|
changes.push(...instructions.changes);
|
|
310
314
|
conflicts.push(...instructions.conflicts);
|
|
311
315
|
|
|
@@ -365,6 +369,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
|
|
|
365
369
|
if (change.operation !== 'remove') continue;
|
|
366
370
|
if (change.projection?.renderer === 'copy-tree' || change.kind === 'mcp-server') continue;
|
|
367
371
|
if (typeof change.content !== 'string') continue;
|
|
372
|
+
if (change.deleteWhenBlank && isBlank(change.content) && removeCreatedFile(change.target, fsImpl)) { removed += 1; continue; }
|
|
368
373
|
writeChange(change, fsImpl);
|
|
369
374
|
removed += 1;
|
|
370
375
|
}
|
|
@@ -389,9 +394,11 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
|
|
|
389
394
|
status: hasSection ? 'managed' : 'absent', target: found.paths.instruction,
|
|
390
395
|
ownershipIdentity: `${HARNESS}:instructions:managed-section` });
|
|
391
396
|
if (hasSection) {
|
|
397
|
+
const origin = creationOrigin({ ledger, harness: HARNESS, ownershipIdentity: `${HARNESS}:instructions:managed-section`, target: found.paths.instruction, before: discovery?.instruction });
|
|
392
398
|
resources.push({ assetId, target: found.paths.instruction,
|
|
393
399
|
ownershipIdentity: `${HARNESS}:instructions:managed-section`, fingerprint: fingerprint(found.instruction),
|
|
394
|
-
sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'copilot-instructions' }
|
|
400
|
+
sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'copilot-instructions' },
|
|
401
|
+
...(origin ? { origin } : {}) });
|
|
395
402
|
}
|
|
396
403
|
}
|
|
397
404
|
|
|
@@ -371,14 +371,13 @@ function kiroHook({ name, trigger, matcher, script, timeout }
|
|
|
371
371
|
};
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
-
/** The
|
|
374
|
+
/** The two preToolUse hooks every DoFlow-rendered Kiro agent carries. The MCP guard uses the
|
|
375
375
|
* "*" matcher: Kiro documents "@<server>" (every tool of one server) and "*" but no all-MCP
|
|
376
376
|
* matcher, and mcp-tool-guard.sh exits 0 immediately for any non-MCP tool name, so "*" costs one
|
|
377
377
|
* fast no-op for built-in tools and still covers every MCP server. */
|
|
378
378
|
function kiroPreToolUseHooks() {
|
|
379
379
|
return [
|
|
380
380
|
kiroHook({ name: 'doflow-pre-implementation-gate', trigger: 'preToolUse', matcher: 'write', script: 'pre-implement-gate.sh', timeout: 10 }),
|
|
381
|
-
kiroHook({ name: 'doflow-pre-bash-guard', trigger: 'preToolUse', matcher: 'shell', script: 'pre-bash-guard.sh', timeout: 10 }),
|
|
382
381
|
kiroHook({ name: 'doflow-mcp-tool-guard', trigger: 'preToolUse', matcher: '*', script: 'mcp-tool-guard.sh', timeout: 10 }),
|
|
383
382
|
];
|
|
384
383
|
}
|
|
@@ -662,6 +661,13 @@ function applyTree({ changes = [], fsImpl = fs, transform } )
|
|
|
662
661
|
if (change.operation === 'remove') continue;
|
|
663
662
|
fsImpl.mkdirSync(path.dirname(change.target), { recursive: true });
|
|
664
663
|
const sourceStat = fsImpl.statSync(change.source);
|
|
664
|
+
// A destination symlink (`install -f` plans over one) is replaced by the regular file, never written through:
|
|
665
|
+
// its target, which may be a file outside the install, keeps its bytes and mode.
|
|
666
|
+
try {
|
|
667
|
+
if (fsImpl.lstatSync(change.target).isSymbolicLink()) fsImpl.unlinkSync(change.target);
|
|
668
|
+
} catch (error ) {
|
|
669
|
+
if (error.code !== 'ENOENT') throw error;
|
|
670
|
+
}
|
|
665
671
|
if (mapContent) {
|
|
666
672
|
// Re-derive the bytes from the source at write time instead of copying it: the transform,
|
|
667
673
|
// not the source file, is what the recorded fingerprint describes. Keeps recovery retries
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared by the adapters whose instruction file (and opencode.json) is a file DoFlow may have
|
|
4
|
+
// created: the ledger row records `origin: { created: 'file' }` when the file was absent before the
|
|
5
|
+
// install, so a removal that leaves nothing but whitespace can delete exactly the files DoFlow made
|
|
6
|
+
// and keep every file that existed beforehand. Same record Pi's mcp.json rows carry (pi/mcp.ts).
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
|
|
9
|
+
/** Whitespace is not content: a file holding only whitespace carries none of the user's text. */
|
|
10
|
+
function isBlank(text ) { return typeof text === 'string' && text.trim() === ''; }
|
|
11
|
+
|
|
12
|
+
function ownedRow(ledger , harness , ownershipIdentity , target ) {
|
|
13
|
+
return (ledger?.resources || []).find((row ) => row.harness === harness
|
|
14
|
+
&& row.ownershipIdentity === ownershipIdentity && row.target === target);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether the ledger row for `ownershipIdentity` at `target` says DoFlow created that file. */
|
|
18
|
+
function createdByDoFlow(ledger , harness , ownershipIdentity , target ) {
|
|
19
|
+
return ownedRow(ledger, harness, ownershipIdentity, target)?.origin?.created === 'file';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The origin to record on the row verify emits. A row already in the ledger passes its origin on
|
|
23
|
+
* (absent for rows an older version wrote, which are never guessed at). With no row, `before` is
|
|
24
|
+
* the file as plan read it: `null` means absent, so this install creates it. `undefined` means the
|
|
25
|
+
* caller has no pre-apply read (a status verify), and nothing is recorded. */
|
|
26
|
+
function creationOrigin({ ledger, harness, ownershipIdentity, target, before } ) {
|
|
27
|
+
const row = ownedRow(ledger, harness, ownershipIdentity, target);
|
|
28
|
+
if (row) return row.origin;
|
|
29
|
+
return before === null ? { created: 'file' } : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Delete `target` unless it is a symlink (the link is the user's, not DoFlow's). */
|
|
33
|
+
function removeCreatedFile(target , fsImpl = fs) {
|
|
34
|
+
try {
|
|
35
|
+
if (fsImpl.lstatSync(target).isSymbolicLink()) return false;
|
|
36
|
+
fsImpl.rmSync(target);
|
|
37
|
+
return true;
|
|
38
|
+
} catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile };
|
|
@@ -281,15 +281,25 @@ function writePath(file , fsImpl ) {
|
|
|
281
281
|
try { return fsImpl.realpathSync(file); } catch { return path.resolve(path.dirname(file), fsImpl.readlinkSync(file)); }
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/** The indent the file already uses (its first indented line, tabs included), else two spaces. */
|
|
285
|
+
function indentOf(file , fsImpl ) {
|
|
286
|
+
try {
|
|
287
|
+
const found = /^\s*[{[][ \t]*\r?\n([ \t]+)\S/.exec(fsImpl.readFileSync(file, 'utf8'));
|
|
288
|
+
if (found) return found[1].slice(0, 10);
|
|
289
|
+
} catch { /* absent or unreadable: the default */ }
|
|
290
|
+
return ' ';
|
|
291
|
+
}
|
|
292
|
+
|
|
284
293
|
/** Write a JSON document atomically, keeping the existing file's permission bits: an MCP file kept
|
|
285
294
|
* private for its tokens stays so. Creates missing directories; never deletes a file. */
|
|
286
295
|
function writeMcpJson(file , doc , { fsImpl = fs } = {}) {
|
|
287
296
|
const dest = writePath(file, fsImpl);
|
|
288
297
|
fsImpl.mkdirSync(path.dirname(dest), { recursive: true });
|
|
289
298
|
const mode = fsImpl.existsSync(dest) ? fsImpl.statSync(dest).mode & 0o7777 : null;
|
|
299
|
+
const indent = indentOf(dest, fsImpl);
|
|
290
300
|
const temp = path.join(path.dirname(dest), `.${path.basename(dest)}.${process.pid}.${Date.now()}.tmp`);
|
|
291
301
|
try {
|
|
292
|
-
fsImpl.writeFileSync(temp, `${JSON.stringify(doc, null,
|
|
302
|
+
fsImpl.writeFileSync(temp, `${JSON.stringify(doc, null, indent)}\n`, { encoding: 'utf8', flag: 'wx', ...(mode === null ? {} : { mode }) });
|
|
293
303
|
if (mode !== null) fsImpl.chmodSync(temp, mode);
|
|
294
304
|
fsImpl.renameSync(temp, dest);
|
|
295
305
|
} finally {
|
|
@@ -25,6 +25,7 @@ const { MARKER_START, MARKER_END } = require('../../helper/marker-merge.js');
|
|
|
25
25
|
const { destructiveCommandGlobs } = require('../../helper/guardrails.js');
|
|
26
26
|
const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
|
|
27
27
|
const { renderGuidancePointer } = require('../guidance-pointer.js');
|
|
28
|
+
const { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile } = require('../created-file.js');
|
|
28
29
|
|
|
29
30
|
const HARNESS = 'opencode';
|
|
30
31
|
/**
|
|
@@ -141,7 +142,8 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
function managedInstruction(existing , rendered ) {
|
|
144
|
-
|
|
145
|
+
// A file holding only whitespace has nothing to protect and counts as absent.
|
|
146
|
+
if (existing === null || isBlank(existing)) return { ok: true, operation: 'create', content: rendered };
|
|
145
147
|
const start = existing.indexOf(MARKER_START);
|
|
146
148
|
const end = existing.indexOf(MARKER_END);
|
|
147
149
|
if (start === -1 && end === -1) return { ok: false, conflict: `${INSTRUCTION_FILE} exists without a DoFlow managed section` };
|
|
@@ -316,7 +318,9 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
|
|
|
316
318
|
else if (outcome.operation !== 'none' && outcome.content !== null) {
|
|
317
319
|
changes.push({ assetId: guidance.id, target: found.paths.instruction, operation: outcome.operation,
|
|
318
320
|
content: outcome.content, ownershipIdentity: `${HARNESS}:instructions:managed-section`,
|
|
319
|
-
fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'opencode-instructions' }
|
|
321
|
+
fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'opencode-instructions' },
|
|
322
|
+
// A file DoFlow created and a removal that leaves only whitespace: the file goes (remove()).
|
|
323
|
+
...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:instructions:managed-section`, found.paths.instruction) ? { deleteWhenBlank: true } : {}) });
|
|
320
324
|
}
|
|
321
325
|
}
|
|
322
326
|
|
|
@@ -336,7 +340,9 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
|
|
|
336
340
|
// src/adapters/copilot/index.js's analogous mcp-merge change already carries).
|
|
337
341
|
operation: removing ? 'remove' : (found.config.exists ? 'update' : 'create'), content: `${JSON.stringify(next, null, 2)}\n`,
|
|
338
342
|
ownershipIdentity: `${HARNESS}:config:registration`, fingerprint: fingerprint(next),
|
|
339
|
-
harness: HARNESS, projection: { renderer: 'opencode-config' }
|
|
343
|
+
harness: HARNESS, projection: { renderer: 'opencode-config' },
|
|
344
|
+
// A file DoFlow created that a removal leaves empty goes too (remove()).
|
|
345
|
+
...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:config:registration`, found.paths.config) ? { deleteWhenEmpty: true } : {}) });
|
|
340
346
|
}
|
|
341
347
|
} else if (removing) {
|
|
342
348
|
changes.push(...releasedConfigRow({ assets, paths: found.paths, ledger }));
|
|
@@ -384,14 +390,29 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
|
|
|
384
390
|
if (change.operation !== 'remove') continue;
|
|
385
391
|
if (change.kind === 'copy-tree-file' || change.kind === 'mcp-server') continue; // routed below
|
|
386
392
|
if (typeof change.content !== 'string') continue;
|
|
393
|
+
if (change.deleteWhenBlank && isBlank(change.content) && removeCreatedFile(change.target, fsImpl)) { removed += 1; continue; }
|
|
387
394
|
writeChange(change, fsImpl);
|
|
388
395
|
removed += 1;
|
|
389
396
|
}
|
|
390
397
|
removed += writeMcpEntries(changes, { container: MCP_CONTAINER, fsImpl });
|
|
398
|
+
// After the entry removals, which edit the same file: what is left of a config DoFlow created.
|
|
399
|
+
for (const change of changes) {
|
|
400
|
+
if (change.deleteWhenEmpty && configIsEmpty(change.target, fsImpl) && removeCreatedFile(change.target, fsImpl)) removed += 1;
|
|
401
|
+
}
|
|
391
402
|
removed += removeCopyTreeAssets(changes, { fsImpl });
|
|
392
403
|
return { removed };
|
|
393
404
|
}
|
|
394
405
|
|
|
406
|
+
/** True when the file holds nothing but an empty JSON object (or only whitespace). */
|
|
407
|
+
function configIsEmpty(file , fsImpl ) {
|
|
408
|
+
try {
|
|
409
|
+
const text = fsImpl.readFileSync(file, 'utf8');
|
|
410
|
+
if (isBlank(text)) return true;
|
|
411
|
+
const value = JSON.parse(text);
|
|
412
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
413
|
+
} catch { return false; }
|
|
414
|
+
}
|
|
415
|
+
|
|
395
416
|
function verify({ scope, scopeRoot, assets = [], mcp = [], mcpAdoptable = [], discovery, ledger, operation, context = {}, fsImpl = fs } ) {
|
|
396
417
|
const found = discover({ scope, scopeRoot, mcp, mcpAdoptable, ledger, context, fsImpl });
|
|
397
418
|
const removing = (operation ?? context.operation) === 'remove';
|
|
@@ -399,6 +420,10 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
|
|
|
399
420
|
const resources = [];
|
|
400
421
|
const conflicts = [];
|
|
401
422
|
const instructionsAssetId = assets.find((asset ) => asset.capability === 'instructions')?.id ?? pseudoAssetId(assets);
|
|
423
|
+
const originOf = (ownershipIdentity , target , before ) => {
|
|
424
|
+
const origin = creationOrigin({ ledger, harness: HARNESS, ownershipIdentity, target, before });
|
|
425
|
+
return origin ? { origin } : {};
|
|
426
|
+
};
|
|
402
427
|
|
|
403
428
|
const hasSection = typeof found.instruction === 'string'
|
|
404
429
|
&& found.instruction.includes(MARKER_START) && found.instruction.includes(MARKER_END);
|
|
@@ -408,7 +433,8 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
|
|
|
408
433
|
if (hasSection) {
|
|
409
434
|
resources.push({ assetId: instructionsAssetId, target: found.paths.instruction,
|
|
410
435
|
ownershipIdentity: `${HARNESS}:instructions:managed-section`, fingerprint: fingerprint(found.instruction),
|
|
411
|
-
sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'opencode-instructions' }
|
|
436
|
+
sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'opencode-instructions' },
|
|
437
|
+
...originOf(`${HARNESS}:instructions:managed-section`, found.paths.instruction, discovery ? discovery.instruction : undefined) });
|
|
412
438
|
}
|
|
413
439
|
|
|
414
440
|
if (!found.mcpSnapshot[found.paths.config].ok) {
|
|
@@ -424,7 +450,8 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
|
|
|
424
450
|
if (registered) {
|
|
425
451
|
resources.push({ assetId: pseudoAssetId(assets), target: found.paths.config,
|
|
426
452
|
ownershipIdentity: `${HARNESS}:config:registration`, fingerprint: fingerprint(value),
|
|
427
|
-
sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'opencode-config' }
|
|
453
|
+
sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'opencode-config' },
|
|
454
|
+
...originOf(`${HARNESS}:config:registration`, found.paths.config, discovery ? (discovery.config.exists ? {} : null) : undefined) });
|
|
428
455
|
}
|
|
429
456
|
if (context.permissions) {
|
|
430
457
|
const ours = guardrailBashRules(context);
|
package/src/adapters/pi/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harn
|
|
|
27
27
|
const { mcpRows, discoverPiMcp, ownedPiMcpIds, planPiMcp, applyPiMcp, verifyPiMcp } = require('./mcp.js');
|
|
28
28
|
const { planPiHooks, verifyPiHooks, removePiHooks } = require('./hooks.js');
|
|
29
29
|
const { renderGuidancePointer } = require('../guidance-pointer.js');
|
|
30
|
+
const { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile } = require('../created-file.js');
|
|
30
31
|
|
|
31
32
|
const HARNESS = 'pi';
|
|
32
33
|
const AGENT_DIR_MCP_NOTICE = 'PI_CODING_AGENT_DIR is set: Pi reads its whole agent dir from it, but DoFlow moves only mcp.json there; skills and AGENTS.md stay in ~/.pi/agent.';
|
|
@@ -73,9 +74,10 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
|
|
|
73
74
|
|
|
74
75
|
/** Replace only the span between DoFlow's markers. An AGENTS.md with no DoFlow section is refused
|
|
75
76
|
* rather than appended to — Pi shares AGENTS.md with every other agent that reads it, so where
|
|
76
|
-
* DoFlow's content belongs is its owner's decision. Add an empty marker pair to opt in.
|
|
77
|
+
* DoFlow's content belongs is its owner's decision. Add an empty marker pair to opt in. A file
|
|
78
|
+
* holding only whitespace has nothing to protect and counts as absent. */
|
|
77
79
|
function managedInstruction(existing , rendered ) {
|
|
78
|
-
if (existing === null) return { ok: true, operation: 'create', content: rendered };
|
|
80
|
+
if (existing === null || isBlank(existing)) return { ok: true, operation: 'create', content: rendered };
|
|
79
81
|
const start = existing.indexOf(MARKER_START);
|
|
80
82
|
const end = existing.indexOf(MARKER_END);
|
|
81
83
|
if (start === -1 && end === -1) return { ok: false, conflict: 'AGENTS.md exists without a DoFlow managed section' };
|
|
@@ -190,7 +192,9 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
|
|
|
190
192
|
else if (outcome.operation !== 'none' && outcome.content !== null) {
|
|
191
193
|
changes.push({ assetId: guidance.id, target: found.paths.instruction, operation: outcome.operation,
|
|
192
194
|
content: outcome.content, ownershipIdentity: `${HARNESS}:instructions:managed-section`,
|
|
193
|
-
fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'pi-instructions' }
|
|
195
|
+
fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'pi-instructions' },
|
|
196
|
+
// A file DoFlow created and a removal that leaves only whitespace: the file goes (remove()).
|
|
197
|
+
...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:instructions:managed-section`, found.paths.instruction) ? { deleteWhenBlank: true } : {}) });
|
|
194
198
|
}
|
|
195
199
|
}
|
|
196
200
|
|
|
@@ -252,6 +256,7 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
|
|
|
252
256
|
if (change.operation !== 'remove' || change.projection?.renderer === 'copy-tree' || isPiMcp(change)) continue;
|
|
253
257
|
if (change.projection?.renderer === 'pi-guard-extension') continue;
|
|
254
258
|
if (change.content === null) continue;
|
|
259
|
+
if (change.deleteWhenBlank && isBlank(change.content) && removeCreatedFile(change.target, fsImpl)) { removed += 1; continue; }
|
|
255
260
|
writeChange(change, fsImpl);
|
|
256
261
|
removed += 1;
|
|
257
262
|
}
|
|
@@ -260,6 +265,11 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
|
|
|
260
265
|
return { removed };
|
|
261
266
|
}
|
|
262
267
|
|
|
268
|
+
function originField({ ledger, target, before } ) {
|
|
269
|
+
const origin = creationOrigin({ ledger, harness: HARNESS, ownershipIdentity: `${HARNESS}:instructions:managed-section`, target, before });
|
|
270
|
+
return origin ? { origin } : {};
|
|
271
|
+
}
|
|
272
|
+
|
|
263
273
|
function verify({ scope, scopeRoot, assets = [], mcp = [], discovery, ledger, context = {}, fsImpl = fs } ) {
|
|
264
274
|
const found = discover({ scope, scopeRoot, mcp, ledger, context, fsImpl });
|
|
265
275
|
const statuses = [];
|
|
@@ -275,7 +285,8 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
|
|
|
275
285
|
if (hasSection) {
|
|
276
286
|
resources.push({ assetId: instructionsAssetId, target: found.paths.instruction,
|
|
277
287
|
ownershipIdentity: `${HARNESS}:instructions:managed-section`, fingerprint: fingerprint(found.instruction),
|
|
278
|
-
sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'pi-instructions' }
|
|
288
|
+
sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'pi-instructions' },
|
|
289
|
+
...originField({ ledger, target: found.paths.instruction, before: discovery?.instruction }) });
|
|
279
290
|
}
|
|
280
291
|
|
|
281
292
|
const copyTree = verifyCopyTreeAssets({ assets, scope, scopeRoot, context, fsImpl });
|
package/src/adapters/pi/mcp.js
CHANGED
|
@@ -104,7 +104,8 @@ function twinOf(doc , key ) {
|
|
|
104
104
|
return doc.servers?.members.find((member ) => member.key !== key && piServerKey(member.key) === piServerKey(key)) ?? null;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/** Equal as JSON values, whatever order an object's keys sit in (an owned entry the user reordered is still ours). */
|
|
108
|
+
function sameValue(a , b ) { return fingerprint(a) === fingerprint(b); }
|
|
108
109
|
|
|
109
110
|
function baseChange({ target, operation, id, snapshot } ) {
|
|
110
111
|
return {
|
|
@@ -271,26 +272,51 @@ function applyPiMcp(changes , { fsImpl = fs } = {})
|
|
|
271
272
|
for (const [target, all] of groups) {
|
|
272
273
|
const group = all.filter((change ) => !change.release);
|
|
273
274
|
if (!group.length) continue;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const creates = group.filter((change ) => change.operation === 'create');
|
|
279
|
-
const origin = group.find((change ) => change.origin)?.origin;
|
|
280
|
-
let next = exists ? editedText(text, group) : renderNewDocument(creates.map((change ) => [change.identity, change.entry]));
|
|
281
|
-
// Only a file DoFlow created is deleted, and never through a symlink; a user's own file that
|
|
282
|
-
// happens to equal the skeleton stays.
|
|
283
|
-
if (exists && origin?.created === 'file' && next === SKELETON) {
|
|
284
|
-
if (!isSymlink(target, fsImpl)) { fsImpl.rmSync(target); applied += 1; continue; }
|
|
285
|
-
} else if (exists && origin && !creates.length) next = restoredText(next, origin);
|
|
286
|
-
if (next !== text) {
|
|
287
|
-
atomicWrite(target, next, fsImpl);
|
|
288
|
-
applied += 1;
|
|
289
|
-
}
|
|
275
|
+
// The hash check and the write are one critical section across the installers of this machine,
|
|
276
|
+
// keyed by the file a write lands in, so two links to one mcp.json share a lock.
|
|
277
|
+
const release = lockTarget(target, fsImpl);
|
|
278
|
+
try { applied += applyGroup(target, group, fsImpl); } finally { release(); }
|
|
290
279
|
}
|
|
291
280
|
return { applied };
|
|
292
281
|
}
|
|
293
282
|
|
|
283
|
+
/** The file a write to `target` lands in, as one name for every path that reaches it. */
|
|
284
|
+
function canonicalTarget(target , fsImpl ) {
|
|
285
|
+
const dest = writePath(target, fsImpl);
|
|
286
|
+
try { return fsImpl.realpathSync(dest); } catch { /* not there yet: its directory names it */ }
|
|
287
|
+
try { return path.join(fsImpl.realpathSync(path.dirname(dest)), path.basename(dest)); } catch { return path.resolve(dest); }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Waits for another installer's lock on the same mcp.json (`<file>.lock`, task-state's protocol: a
|
|
291
|
+
* crashed holder's lock is reclaimed once stale) and refuses with that lock named when it stays held. */
|
|
292
|
+
function lockTarget(target , fsImpl ) {
|
|
293
|
+
const { acquireLock } = require('../../runtime/task-state.js');
|
|
294
|
+
const canonical = canonicalTarget(target, fsImpl);
|
|
295
|
+
fsImpl.mkdirSync(path.dirname(canonical), { recursive: true });
|
|
296
|
+
try { return acquireLock(fsImpl, canonical); } catch (error ) {
|
|
297
|
+
throw new Error(`Pi MCP: ${target} is being changed by another DoFlow run; nothing was written, re-run the command (${error.message})`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** One file's changes, applied against the bytes read now. Returns 1 when the file changed. */
|
|
302
|
+
function applyGroup(target , group , fsImpl ) {
|
|
303
|
+
const exists = fsImpl.existsSync(target);
|
|
304
|
+
const bytes = exists ? fsImpl.readFileSync(target) : Buffer.alloc(0);
|
|
305
|
+
if (sha256(bytes) !== group[0].baseHash) throw new Error(`Pi MCP: ${target} changed after planning; nothing was written, re-run the command`);
|
|
306
|
+
const text = bytes.toString('utf8');
|
|
307
|
+
const creates = group.filter((change ) => change.operation === 'create');
|
|
308
|
+
const origin = group.find((change ) => change.origin)?.origin;
|
|
309
|
+
let next = exists ? editedText(text, group) : renderNewDocument(creates.map((change ) => [change.identity, change.entry]));
|
|
310
|
+
// Only a file DoFlow created is deleted, and never through a symlink; a user's own file that
|
|
311
|
+
// happens to equal the skeleton stays.
|
|
312
|
+
if (exists && origin?.created === 'file' && next === SKELETON) {
|
|
313
|
+
if (!isSymlink(target, fsImpl)) { fsImpl.rmSync(target); return 1; }
|
|
314
|
+
} else if (exists && origin && !creates.length) next = restoredText(next, origin);
|
|
315
|
+
if (next === text) return 0;
|
|
316
|
+
atomicWrite(target, next, fsImpl);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
294
320
|
function statusFor({ id, target, status } ) {
|
|
295
321
|
return { harness: HARNESS, assetId: ASSET_ID, capability: 'mcp', identity: id, target,
|
|
296
322
|
ownershipIdentity: `doflow:${HARNESS}:mcp-server:${id}`, status };
|
|
@@ -370,4 +396,4 @@ function verifyPiMcp({ selected = [], rows = [], file, snapshot = {}, removing =
|
|
|
370
396
|
return { statuses, resources, conflicts };
|
|
371
397
|
}
|
|
372
398
|
|
|
373
|
-
module.exports = { NOTICES, renderPiEntry, piServerKey, mcpRows, discoverPiMcp, ownedPiMcpIds, planPiMcp, applyPiMcp, verifyPiMcp };
|
|
399
|
+
module.exports = { NOTICES, renderPiEntry, piServerKey, mcpRows, discoverPiMcp, ownedPiMcpIds, planPiMcp, applyPiMcp, verifyPiMcp, canonicalTarget };
|
|
@@ -10,7 +10,7 @@ const { loadRegistry } = require('../../registry/index.js');
|
|
|
10
10
|
const { storageForgetScope } = require('../../runtime/storage/upkeep.js');
|
|
11
11
|
const { removeLifecycle, retentionSummary } = require('../../lifecycle/index.js');
|
|
12
12
|
const {
|
|
13
|
-
codexScope, registryLifecycleView, printRegistryLifecycle, LIFECYCLE_HARNESSES,
|
|
13
|
+
codexScope, registryLifecycleView, printRegistryLifecycle, printPlanNotices, LIFECYCLE_HARNESSES,
|
|
14
14
|
assertSafeRegistryPlan, lockDocument, recordLock,
|
|
15
15
|
} = require('../../lifecycle/view.js');
|
|
16
16
|
const { recordedMcpSelections } = require('../../install/mcp.js');
|
|
@@ -63,6 +63,8 @@ function cmdRemove(o ) {
|
|
|
63
63
|
// antigravity/copilot) mean a removal can legitimately leave files standing. Saying only "removed"
|
|
64
64
|
// would be half the truth, so what was kept and who still claims it is printed, not implied.
|
|
65
65
|
for (const line of retentionSummary(result.retained)) console.log(`[INFO] ${line}`);
|
|
66
|
+
// The plan's notices (a file left untouched, a kept edit) read the same on a real run as under --dry-run.
|
|
67
|
+
printPlanNotices(view);
|
|
66
68
|
console.log(`[OK] Removed ${result.ledger.resources.length === 0 ? 'all' : 'eligible'} native resource(s) for ${lifecycleTargets.join(', ')}; ${result.ledger.resources.length} owned record(s) remain.`);
|
|
67
69
|
|
|
68
70
|
// Re-pin what still stands: every harness the ledger still holds keeps its pin and its recorded
|
|
@@ -136,7 +136,7 @@ function cmdStatus(o ) {
|
|
|
136
136
|
if (line) console.log(line);
|
|
137
137
|
if (status.errors.length) console.log(` ${`${label} issues:`.padEnd(24)}${status.errors.join('; ')}`);
|
|
138
138
|
if (harness === 'codex') {
|
|
139
|
-
console.log(' Codex capability gaps: no Claude-only event emulation is installed
|
|
139
|
+
console.log(' Codex capability gaps: no Claude-only event emulation is installed');
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
if (ctx.registry) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const path = require('node:path');
|
|
7
|
-
const { resolveTargets, toolDirs } = require('../../install/targets.js');
|
|
7
|
+
const { resolveTargets, installedTargets, toolDirs } = require('../../install/targets.js');
|
|
8
8
|
const { resolveContext, printContext } = require('../../install/context.js');
|
|
9
9
|
const { DEFAULT_BACKUP_RETENTION, createFileBackup, applyRetention, retentionNote } = require('../../install/backup.js');
|
|
10
10
|
const { backupSetFromPlan } = require('../../install/backup-set.js');
|
|
@@ -29,12 +29,17 @@ const {
|
|
|
29
29
|
|
|
30
30
|
function cmdUpdate(o ) {
|
|
31
31
|
refuseHomeRootedProject(o, 'update');
|
|
32
|
-
const
|
|
32
|
+
const requested = resolveTargets(o.targets); // validates an explicit -t before anything is read
|
|
33
33
|
const scope = scopeOf(o);
|
|
34
34
|
const hold = holdRunLock(o, scope, 'update');
|
|
35
35
|
try {
|
|
36
36
|
const dirs = toolDirs(scope);
|
|
37
37
|
const lifecyclePaths = installPaths(scope);
|
|
38
|
+
const existingManifest = readInstallManifest({ scopeRoot: lifecyclePaths.scopeRoot });
|
|
39
|
+
// No -t: act on the harnesses this scope's manifest lists as installed, not on the install default.
|
|
40
|
+
// With no manifest (or none listed) the default stays, as before.
|
|
41
|
+
const listed = installedTargets(existingManifest);
|
|
42
|
+
const targets = o.targets?.length || !listed.length ? requested : listed;
|
|
38
43
|
const backupRoot = lifecyclePaths.backupRoot;
|
|
39
44
|
const commit = sourceCommit(SCRIPT_DIR);
|
|
40
45
|
printContext(resolveContext({ repoRoot: REPO_ROOT, targets, dirs, sourceCommit: commit, ...scope }));
|
|
@@ -42,7 +47,6 @@ function cmdUpdate(o ) {
|
|
|
42
47
|
// Never interactive: update reuses each harness's recorded selection, or applies an explicit --mcp
|
|
43
48
|
// override, without re-prompting.
|
|
44
49
|
const registry = loadRegistry({ repoRoot: REPO_ROOT });
|
|
45
|
-
const existingManifest = readInstallManifest({ scopeRoot: lifecyclePaths.scopeRoot });
|
|
46
50
|
const { lock, ledger } = scopeSelectionState(scope);
|
|
47
51
|
const selection = resolveMcpSelections({
|
|
48
52
|
cmd: 'update', requested: o.mcp, targets, registry, lock, ledger, manifestServers: existingManifest?.mcpServers ?? null,
|