@khoavu882/doflow 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/session-end.sh +21 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
- package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +25 -1
- package/core/registry/contribution-points.json +1 -1
- package/core/registry/intake.json +3 -3
- package/core/registry/lifecycle.json +256 -5
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/settings.json +12 -12
- package/core/registry/storage.json +1 -2
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
- package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
- package/core/shared/scripts/doflow/bin/doflow-run +18 -0
- package/core/shared/skills/do-design/SKILL.md +6 -2
- package/core/shared/skills/do-design/references/data_model_example.md +167 -0
- package/core/shared/templates/doflow/data-model-template.md +136 -102
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +54 -12
- package/src/adapters/codex/agents.js +15 -1
- package/src/adapters/codex/config.js +5 -2
- package/src/adapters/copilot/index.js +11 -4
- package/src/adapters/copy-tree.js +7 -0
- package/src/adapters/created-file.js +41 -0
- package/src/adapters/mcp-entries.js +11 -1
- package/src/adapters/opencode/index.js +32 -5
- package/src/adapters/pi/index.js +15 -4
- package/src/adapters/pi/mcp.js +44 -18
- package/src/cli/commands/remove.js +3 -1
- package/src/cli/commands/update.js +7 -3
- package/src/cli/index.js +24 -12
- package/src/cli/runtime-commands.js +1 -1
- package/src/cli/shared.js +5 -3
- package/src/helper/marker-merge.js +11 -7
- package/src/install/backup-set.js +7 -3
- package/src/install/backup.js +13 -7
- package/src/install/context.js +2 -1
- package/src/install/manifest.js +35 -11
- package/src/install/paths.js +38 -0
- package/src/install/targets.js +11 -2
- package/src/lifecycle/index.js +80 -3
- package/src/lifecycle/retired-harnesses.js +16 -1
- package/src/lifecycle/view.js +3 -2
- package/src/registry/index.js +21 -4
- package/src/release/docs-blocks.js +5 -1
- package/src/runtime/advice/doctor.js +2 -2
- package/src/runtime/brief-tools.js +2 -1
- package/src/runtime/capability-router.js +26 -3
- package/src/runtime/context/pointer.js +17 -5
- package/src/runtime/context-pack.js +48 -8
- package/src/runtime/dispatch/integrate.js +27 -8
- package/src/runtime/dispatch/loop.js +7 -0
- package/src/runtime/dispatch/verify-run.js +9 -3
- package/src/runtime/feature-pointer.js +10 -1
- package/src/runtime/health.js +9 -4
- package/src/runtime/implementation-gate.js +10 -2
- package/src/runtime/intake/engine.js +11 -9
- package/src/runtime/knowledge/code-retrieval.js +11 -5
- package/src/runtime/knowledge/semble-provider.js +12 -2
- package/src/runtime/lifecycle/cli.js +7 -5
- package/src/runtime/lifecycle/overview.js +32 -2
- package/src/runtime/lifecycle/release.js +16 -3
- package/src/runtime/lifecycle/retention.js +12 -15
- package/src/runtime/lifecycle/store-upkeep.js +13 -3
- package/src/runtime/mask.js +3 -3
- package/src/runtime/mcp-consultations.js +11 -5
- package/src/runtime/research-request.js +3 -1
- package/src/runtime/review/command.js +3 -7
- package/src/runtime/review/start.js +24 -10
- package/src/runtime/roadmap/loop.js +14 -3
- package/src/runtime/roadmap/rows.js +2 -1
- package/src/runtime/settings/files.js +3 -1
- package/src/runtime/settings/prepared-run.js +12 -2
- package/src/runtime/settings/resolve.js +13 -1
- package/src/runtime/settings/write.js +73 -2
- package/src/runtime/storage/registry.js +2 -2
- package/src/runtime/storage/roots.js +4 -18
- package/src/runtime/storage/rules.js +22 -46
- package/src/runtime/storage/work-status.js +14 -4
- package/src/runtime/task-scope.js +17 -2
- package/src/runtime/task-state.js +11 -4
- package/src/runtime/trace/analyze.js +4 -2
- package/src/runtime/trace/indicators.js +25 -20
- package/src/runtime/trace/spans.js +4 -5
- package/src/runtime/verb-view.js +6 -5
- package/src/runtime/worker/command.js +4 -2
- package/src/runtime/worker/readiness.js +8 -4
- package/src/runtime/worker/supervisor.js +18 -6
- package/src/runtime/workflow-orchestrator.js +6 -2
|
@@ -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
|
|
@@ -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,
|
package/src/cli/index.js
CHANGED
|
@@ -53,6 +53,16 @@ function assertNoBackupRequiresForce(o ) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Reports an argument error and exits. `review` answers in its own JSON usage shape when `--json` is anywhere in the
|
|
58
|
+
* arguments (before or after the bad one); every other verb prints the plain line on stderr.
|
|
59
|
+
*/
|
|
60
|
+
function argError(o , argv , message , code ) {
|
|
61
|
+
if (o.cmd === 'review' && argv.includes('--json')) console.log(JSON.stringify({ ok: false, error: 'usage', message }));
|
|
62
|
+
else console.error(`doflow: ${message}`);
|
|
63
|
+
process.exit(code);
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
function parseArgs(argv ) {
|
|
57
67
|
const o = { cmd: null, positional: [], targets: [], mcp: null, dryRun: false, force: false, adopt: false,
|
|
58
68
|
otlpHttp: false,
|
|
@@ -68,7 +78,7 @@ function parseArgs(argv ) {
|
|
|
68
78
|
if (a.startsWith('-')) o.flagsGiven.push(a);
|
|
69
79
|
// `--commit` is the roadmap verb's own flag: anywhere else it must reach the default branch and get
|
|
70
80
|
// that verb's unknown-flag answer, as it did before it was added. '' matches no case.
|
|
71
|
-
switch (a === '--commit' && o.cmd !== 'roadmap' ? '' : a) {
|
|
81
|
+
switch (a === '--commit' && o.cmd !== 'roadmap' ? '' : a.startsWith('--action=') ? '--action' : a) {
|
|
72
82
|
case '-h': case '--help': o.help = true; break;
|
|
73
83
|
case '-v': o.version = true; break;
|
|
74
84
|
case '--version':
|
|
@@ -92,7 +102,7 @@ function parseArgs(argv ) {
|
|
|
92
102
|
case '--':
|
|
93
103
|
if (o.cmd === 'slot') { o.slotCommand = argv.slice(i + 1); i = argv.length; break; }
|
|
94
104
|
if (o.cmd === 'plan-check') break;
|
|
95
|
-
|
|
105
|
+
argError(o, argv, unknownFlagMessage(a), unknownFlagExit(o.cmd));
|
|
96
106
|
break;
|
|
97
107
|
// worker --action run|describe|select: the isolated-edit recipe and baseline (082 IC-014).
|
|
98
108
|
case '--edit': o.edit = true; break;
|
|
@@ -148,10 +158,12 @@ function parseArgs(argv ) {
|
|
|
148
158
|
o.tools = val.split(',').map((s) => s.trim()).filter(Boolean); i++; break;
|
|
149
159
|
}
|
|
150
160
|
case '--action': {
|
|
151
|
-
|
|
161
|
+
// `--action <value>` or `--action=<value>`.
|
|
162
|
+
const inline = a.startsWith('--action=');
|
|
163
|
+
const val = inline ? a.slice('--action='.length) : argv[i + 1];
|
|
152
164
|
// `review`, `protocol`, `config`, `primary` and `package` answer a missing value as usage, exit 2; other verbs keep exit 1.
|
|
153
|
-
if (val === undefined || val.startsWith('-'))
|
|
154
|
-
o.action = val; o.actionGiven = true; i++; break;
|
|
165
|
+
if (val === undefined || val === '' || val.startsWith('-')) argError(o, argv, '--action requires a value', o.cmd === 'review' || o.cmd === 'protocol' || o.cmd === 'config' || o.cmd === 'primary' || o.cmd === 'fleet' || o.cmd === 'transcript' || o.cmd === 'package' || o.cmd === 'worktree' ? 2 : 1);
|
|
166
|
+
o.action = val; o.actionGiven = true; if (!inline) i++; break;
|
|
155
167
|
}
|
|
156
168
|
case '--task-class': {
|
|
157
169
|
const val = argv[i + 1];
|
|
@@ -236,7 +248,7 @@ function parseArgs(argv ) {
|
|
|
236
248
|
if (runtime !== null) { i = runtime; break; }
|
|
237
249
|
// slot, primary, fleet and transcript report an unknown flag as their own usage error (exit 2, 115 IC-008, 120 IC-008).
|
|
238
250
|
if (a.startsWith('-') && (o.cmd === 'slot' || o.cmd === 'primary' || o.cmd === 'fleet' || o.cmd === 'transcript')) (o.extraFlags = o.extraFlags || []).push(a);
|
|
239
|
-
else if (a.startsWith('-') && o.cmd !== 'plan-check')
|
|
251
|
+
else if (a.startsWith('-') && o.cmd !== 'plan-check') argError(o, argv, unknownFlagMessage(a), unknownFlagExit(o.cmd));
|
|
240
252
|
else if (!o.cmd) o.cmd = a;
|
|
241
253
|
else o.positional.push(a);
|
|
242
254
|
}
|
|
@@ -254,12 +266,12 @@ function unknownFlagExit(cmd ) {
|
|
|
254
266
|
/** Longest part of an unrecognised argument echoed back; the rest is cut and marked. */
|
|
255
267
|
const UNKNOWN_FLAG_ECHO_CHARS = 80;
|
|
256
268
|
|
|
257
|
-
/** The unknown-flag
|
|
269
|
+
/** The unknown-flag message (without the `doflow: ` prefix): the argument is untrusted text, so it is made print-safe (no control
|
|
258
270
|
* characters, one line) and cut to a bounded length (FU-8eqytg). */
|
|
259
271
|
function unknownFlagMessage(arg ) {
|
|
260
272
|
const safe = printSafe(String(arg));
|
|
261
273
|
const shown = safe.length > UNKNOWN_FLAG_ECHO_CHARS ? `${safe.slice(0, UNKNOWN_FLAG_ECHO_CHARS)}...` : safe;
|
|
262
|
-
return `
|
|
274
|
+
return `unknown flag '${shown}'`;
|
|
263
275
|
}
|
|
264
276
|
|
|
265
277
|
/** Single-value arguments of the runtime verbs → the option key each one writes. */
|
|
@@ -498,14 +510,14 @@ function parseRuntimeFlag(arg , argv , i , o )
|
|
|
498
510
|
// `--batch <file|->`. Rejecting it made the documented spelling exit 2 while only `--batch=-`
|
|
499
511
|
// worked — a help text endorsing a form the parser refuses.
|
|
500
512
|
const isStdin = value === '-';
|
|
501
|
-
if (value === undefined || (value.startsWith('-') && !isStdin))
|
|
513
|
+
if (value === undefined || (value.startsWith('-') && !isStdin)) argError(o, argv, `${name} requires a value`, 2);
|
|
502
514
|
next = i + 1;
|
|
503
515
|
}
|
|
504
|
-
if (value === '')
|
|
516
|
+
if (value === '') argError(o, argv, `${name} requires a value`, 2);
|
|
505
517
|
|
|
506
518
|
if (RUNTIME_INT_FLAGS.has(name)) {
|
|
507
519
|
const parsed = parseInt(value, 10);
|
|
508
|
-
if (!Number.isFinite(parsed) || parsed < 0)
|
|
520
|
+
if (!Number.isFinite(parsed) || parsed < 0) argError(o, argv, `${name} expects a non-negative integer, got '${value}'`, 2);
|
|
509
521
|
o[key] = parsed;
|
|
510
522
|
} else if (RUNTIME_LIST_FLAGS.has(name)) {
|
|
511
523
|
// --exclude and --exclude-family both accept comma lists or repeated flags. Keep --exclude's
|
|
@@ -710,7 +722,7 @@ Runtime verb arguments (accept --flag value or --flag=value):
|
|
|
710
722
|
--request <text>, --request-file <file> classify (mutually exclusive; optional second opinion)
|
|
711
723
|
--view brief|full followup --action list (default brief), lifecycle --action overview, classify (default full); brief drops detail, full is the whole answer
|
|
712
724
|
--cursor <c>, --limit <n> followup --action list (the cursor is opaque: pass back a page's nextCursor); also transcript --action read
|
|
713
|
-
--intent, --query, --check
|
|
725
|
+
--intent, --query, --check, --harness route
|
|
714
726
|
--statement, --claim-id,
|
|
715
727
|
--evidence-id, --relation claim
|
|
716
728
|
--kind, --provenance, --provider,
|
|
@@ -415,7 +415,7 @@ function dispatchRuntimeCommandInner(o , moment ) {
|
|
|
415
415
|
case 'brief-tools': return handleBriefToolsCommand({ owner: o.owner, tier: o.tier, harness: o.harness, slug: o.slug, headingLevel: o.headingLevel, tasks: o.tasks, json: o.json, repoRoot: REPO_ROOT, projectRoot: storeRoot, buildAdapters: buildAdapterRegistry });
|
|
416
416
|
case 'advise': return handleAdviseCommand({ action: o.action, actionGiven: o.actionGiven, kind: o.kind, server: o.server, state: o.state, stateFile: o.stateFile, staticAnswer: o.staticAnswer, questionsFile: o.questionsFile, taskId: o.taskId, pickId: o.pickId, actual: o.actual, model: o.model, endpoint: o.endpoint, off: o.off, project: o.project, force: o.force, global: o.global, json: o.json, projectRoot: evidenceRoot(o) });
|
|
417
417
|
case 'model-role': return handleModelRoleCommand({ role: o.role, exclude: o.exclude, excludeFamilies: o.excludeFamily, json: o.json, repoRoot: REPO_ROOT });
|
|
418
|
-
case 'route': return handleRouteCommand({ intent: o.intent, query: o.query, check: o.check, json: o.json, projectRoot: evidenceRoot(o) });
|
|
418
|
+
case 'route': return handleRouteCommand({ intent: o.intent, query: o.query, harness: o.harness, check: o.check, json: o.json, projectRoot: evidenceRoot(o) });
|
|
419
419
|
case 'claim': return handleClaimCommand({ slug: storeSlug, taskId: requireTaskId(o), action: o.action, statement: o.statement, claimId: o.claimId, evidenceId: o.evidenceId, replacedBy: o.replacedBy, relation: o.relation, role: o.role, json: o.json, stateRoot: storeRoot });
|
|
420
420
|
case 'context-pack': return handleContextPackCommand({ taskId: requireTaskId(o), taskClass: o.taskClass, objective: o.objective, json: o.json, stateRoot: storeRoot, sourceRoot: evidenceRoot(o), slug: storeSlug, explicitSlug: o.slug || null, mandatoryFile: o.mandatoryFile, harness: o.harness, maxBytes: o.maxBytes, bare: o.bare, noRecord: o.noRecord, reason: o.reason, since: o.since, expand: o.expand });
|
|
421
421
|
// 078 IC-005 and IC-013: identity is the process environment's alone, never a flag.
|
package/src/cli/shared.js
CHANGED
|
@@ -10,7 +10,7 @@ const fs = require('node:fs');
|
|
|
10
10
|
const os = require('node:os');
|
|
11
11
|
const path = require('node:path');
|
|
12
12
|
const { REPO_ROOT } = require('../helper/repo-root.js');
|
|
13
|
-
const { doflowPaths } = require('../install/paths.js');
|
|
13
|
+
const { doflowPaths, canonicalPath, canonicalLedger } = require('../install/paths.js');
|
|
14
14
|
const { readInstallManifest } = require('../install/manifest.js');
|
|
15
15
|
const { DEFAULT_BACKUP_RETENTION, formatBytes } = require('../install/backup.js');
|
|
16
16
|
const { stateRoot, readLedger } = require('../state/index.js');
|
|
@@ -64,7 +64,9 @@ function buildAdapterRegistry() {
|
|
|
64
64
|
|
|
65
65
|
/** Resolve {global, projectRoot} scope options for src/install/targets.js#toolDirs from parsed args. */
|
|
66
66
|
function scopeOf(o ) {
|
|
67
|
-
|
|
67
|
+
// The single place a project path enters the verbs: canonical, so an aliased spelling (macOS
|
|
68
|
+
// /var -> /private/var) and the real one name the same scope, ledger rows and lock.
|
|
69
|
+
return { global: o.global, projectRoot: o.global ? (o.positional[0] || '.') : canonicalPath(o.positional[0] || '.') };
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
function installPaths(scope ) {
|
|
@@ -141,7 +143,7 @@ function scopeSelectionState(scope ) {
|
|
|
141
143
|
const scopeRoot = scope.global ? os.homedir() : path.resolve(scope.projectRoot);
|
|
142
144
|
return {
|
|
143
145
|
lock: readLock(scope.global ? { scope: 'global', homeDir: scopeRoot } : { scope: 'project', projectRoot: scopeRoot }),
|
|
144
|
-
ledger: readLedger(stateRoot({ scope: scope.global ? 'global' : 'project', projectRoot: scopeRoot, homeDir: scopeRoot })),
|
|
146
|
+
ledger: canonicalLedger(readLedger(stateRoot({ scope: scope.global ? 'global' : 'project', projectRoot: scopeRoot, homeDir: scopeRoot })), scopeRoot),
|
|
145
147
|
manifestServers: readInstallManifest({ scopeRoot })?.mcpServers ?? null,
|
|
146
148
|
};
|
|
147
149
|
}
|
|
@@ -70,8 +70,7 @@ function mergeMarkedSection(srcAbs , dstAbs , { dryRun = false, co
|
|
|
70
70
|
// original file's matching trailing "\n" (if present) must be consumed here —
|
|
71
71
|
// otherwise a replace with no real change grows the file by one newline
|
|
72
72
|
// every time it runs (breaks idempotency).
|
|
73
|
-
|
|
74
|
-
if (existing[afterEnd] === '\n') afterEnd += 1;
|
|
73
|
+
const afterEnd = afterEndMarker(existing, endIdx + MARKER_END.length);
|
|
75
74
|
newContent = existing.slice(0, startIdx) + section + existing.slice(afterEnd);
|
|
76
75
|
}
|
|
77
76
|
}
|
|
@@ -84,10 +83,16 @@ function mergeMarkedSection(srcAbs , dstAbs , { dryRun = false, co
|
|
|
84
83
|
return { changed: true };
|
|
85
84
|
}
|
|
86
85
|
|
|
86
|
+
/** `at` moved past the one line break (LF or CRLF) that follows an end marker, if there is one. */
|
|
87
|
+
function afterEndMarker(text , at ) {
|
|
88
|
+
if (text[at] === '\n') return at + 1;
|
|
89
|
+
return text[at] === '\r' && text[at + 1] === '\n' ? at + 2 : at;
|
|
90
|
+
}
|
|
91
|
+
|
|
87
92
|
/**
|
|
88
93
|
* Where `text`'s doflow span is: the first of `startMarkers`, the first MARKER_END after it, and no
|
|
89
94
|
* start marker after that end (a second span is `malformed`). `after` is past the end marker and the
|
|
90
|
-
* one
|
|
95
|
+
* one line break (LF or CRLF) that follows it.
|
|
91
96
|
*/
|
|
92
97
|
function markedSpan(text , startMarkers = [MARKER_START]) {
|
|
93
98
|
const found = startMarkers.map((marker) => ({ at: text.indexOf(marker), length: marker.length })).filter((item) => item.at !== -1);
|
|
@@ -95,10 +100,9 @@ function markedSpan(text , startMarkers = [MARKER_START])
|
|
|
95
100
|
const first = found.reduce((a, b) => (b.at < a.at ? b : a));
|
|
96
101
|
const end = text.indexOf(MARKER_END, first.at + first.length);
|
|
97
102
|
if (end === -1) return { state: 'malformed' };
|
|
98
|
-
|
|
99
|
-
if (startMarkers.some((marker) => text.indexOf(marker,
|
|
100
|
-
|
|
101
|
-
return { state: 'ok', start: first.at, after };
|
|
103
|
+
const closed = end + MARKER_END.length;
|
|
104
|
+
if (startMarkers.some((marker) => text.indexOf(marker, closed) !== -1)) return { state: 'malformed' };
|
|
105
|
+
return { state: 'ok', start: first.at, after: afterEndMarker(text, closed) };
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
/**
|
|
@@ -89,8 +89,9 @@ function addCandidate(candidates , file , harness
|
|
|
89
89
|
* lock); an entry at or under one of them is reported in `excluded`, not backed up.
|
|
90
90
|
* `records` lists DoFlow record files backed up beside those files although an `exclude` root holds
|
|
91
91
|
* them (the ownership ledger), with no harness; a rollback restores only the ledger rows of the files
|
|
92
|
-
* it writes back (backup.ts mergeRestoredLedger). They join
|
|
93
|
-
*
|
|
92
|
+
* it writes back (backup.ts mergeRestoredLedger). They join a set that holds some file already, or one whose
|
|
93
|
+
* plan gives up a retired harness's ledger row (only the ledger is then snapshotted); a run that changes
|
|
94
|
+
* neither backs up nothing.
|
|
94
95
|
*/
|
|
95
96
|
function backupSetFromPlan({ plan, scope, scopeRoot, exclude = [], records = [], fsImpl = fs } ) {
|
|
96
97
|
const candidates = new Map();
|
|
@@ -131,7 +132,10 @@ function backupSetFromPlan({ plan, scope, scopeRoot, exclude = [], records = [],
|
|
|
131
132
|
const excluded = [...mcpOnly]
|
|
132
133
|
.filter(([target, onlyMcp]) => onlyMcp && !candidates.has(target))
|
|
133
134
|
.map(([target]) => ({ path: target, reason: 'mcp-entries-only' }));
|
|
134
|
-
|
|
135
|
+
// A retired harness's row given up (every sweep action but `defer`) is a ledger change even when no file
|
|
136
|
+
// changes: the ledger alone is snapshotted then, so a rollback restores the row. Deferred rows stay.
|
|
137
|
+
const givesUpRow = plan.changes.some((change ) => change.retired === true && change.action !== 'defer');
|
|
138
|
+
if (candidates.size || givesUpRow) for (const file of records) addCandidate(candidates, file, null, 'record');
|
|
135
139
|
return toBackupSet(candidates, { scope, scopeRoot, exclude, excluded, records, fsImpl });
|
|
136
140
|
}
|
|
137
141
|
|
package/src/install/backup.js
CHANGED
|
@@ -591,11 +591,12 @@ function planRestore({ bid, backupRoot, scope, scopeRoot, targets, explicitTarge
|
|
|
591
591
|
* staler than the current one; a current row of it the backup lacks stays. A restored row whose file
|
|
592
592
|
* a live harness now claims is released by the next sweep (claim rule 2), file untouched.
|
|
593
593
|
*/
|
|
594
|
-
function mergeRestoredLedger(backup , current , restored , retired = new Set()) {
|
|
594
|
+
function mergeRestoredLedger(backup , current , restored , retired = new Set(), lost = new Set()) {
|
|
595
595
|
if (!current) return backup;
|
|
596
596
|
const at = (target ) => typeof target === 'string' && restored.has(path.resolve(target));
|
|
597
597
|
const whole = (row ) => retired.has(row?.harness);
|
|
598
|
-
|
|
598
|
+
// A retired row whose file this restore tried and could not write back stays out: it would name a file the next sweep drops as absent.
|
|
599
|
+
const back = backup.resources.filter((r ) => at(r.target) || (whole(r) && !(typeof r.target === 'string' && lost.has(path.resolve(r.target)))));
|
|
599
600
|
const backKeys = new Set(back.filter(whole).map(ownershipKey));
|
|
600
601
|
const targets = Object.entries(backup.targets ?? {}).filter(([id]) => retired.has(id));
|
|
601
602
|
return {
|
|
@@ -610,20 +611,23 @@ function mergeRestoredLedger(backup , current , restored , r
|
|
|
610
611
|
}
|
|
611
612
|
|
|
612
613
|
/** Ids the registry retires (row 127 IC-001): `registry` when a caller passes one, else the shipped
|
|
613
|
-
* registry. One that cannot be read
|
|
614
|
+
* registry. One that cannot be read throws, so a restore stops before it writes anything rather than
|
|
615
|
+
* silently skipping the retired rows and targets. */
|
|
614
616
|
function retiredIds(registry ) {
|
|
615
617
|
if (registry === undefined) {
|
|
616
618
|
const { REPO_ROOT } = require('../helper/repo-root.js');
|
|
617
|
-
try { registry = require('../registry/index.js').loadRegistry({ repoRoot: REPO_ROOT }); } catch {
|
|
619
|
+
try { registry = require('../registry/index.js').loadRegistry({ repoRoot: REPO_ROOT }); } catch (err ) {
|
|
620
|
+
throw new BackupError(`cannot read the registry to find retired harnesses: ${err?.message ?? err}`);
|
|
621
|
+
}
|
|
618
622
|
}
|
|
619
623
|
return new Set((Array.isArray(registry?.retired) ? registry.retired : []).map((entry ) => entry?.id).filter((id ) => typeof id === 'string'));
|
|
620
624
|
}
|
|
621
625
|
|
|
622
626
|
/** The bytes a ledger restore item writes: the backup's ledger merged onto the current one. */
|
|
623
|
-
function restoredLedgerBytes(item , restored , retired , fsImpl ) {
|
|
627
|
+
function restoredLedgerBytes(item , restored , retired , lost , fsImpl ) {
|
|
624
628
|
const backup = validateLedger(JSON.parse(fsImpl.readFileSync(item.stored, 'utf8')));
|
|
625
629
|
const current = fsImpl.existsSync(item.path) ? validateLedger(JSON.parse(fsImpl.readFileSync(item.path, 'utf8'))) : null;
|
|
626
|
-
return Buffer.from(`${JSON.stringify(validateLedger(mergeRestoredLedger(backup, current, restored, retired)), null, 2)}\n`);
|
|
630
|
+
return Buffer.from(`${JSON.stringify(validateLedger(mergeRestoredLedger(backup, current, restored, retired, lost)), null, 2)}\n`);
|
|
627
631
|
}
|
|
628
632
|
|
|
629
633
|
/**
|
|
@@ -641,6 +645,8 @@ function executeRestore(plan , { backupRoot, dryRun = false, registry, fsImp
|
|
|
641
645
|
return { restored: plan.v1.tools.map((t ) => t.tool), failed: [], absent: [], refused: [], untargeted: 0, legacy: true };
|
|
642
646
|
}
|
|
643
647
|
|
|
648
|
+
// Read before the first write: an unreadable registry stops the restore with nothing changed.
|
|
649
|
+
const retired = dryRun || plan.narrowed || !plan.restore.some((item ) => item.ledger) ? new Set () : retiredIds(registry);
|
|
644
650
|
const restored = [];
|
|
645
651
|
const failed = [];
|
|
646
652
|
const files = new Set ();
|
|
@@ -654,7 +660,7 @@ function executeRestore(plan , { backupRoot, dryRun = false, registry, fsImp
|
|
|
654
660
|
try {
|
|
655
661
|
const dir = path.dirname(item.path);
|
|
656
662
|
fsImpl.mkdirSync(dir, { recursive: true });
|
|
657
|
-
const bytes = item.ledger ? restoredLedgerBytes(item, files, plan.
|
|
663
|
+
const bytes = item.ledger ? restoredLedgerBytes(item, files, retired, new Set([...failed.map((f ) => f.path), ...plan.refused.map((f ) => f.path)].filter((p ) => typeof p === 'string').map((p ) => path.resolve(p))), fsImpl) : fsImpl.readFileSync(item.stored);
|
|
658
664
|
let isLink = false;
|
|
659
665
|
try {
|
|
660
666
|
isLink = fsImpl.lstatSync(item.path).isSymbolicLink();
|
package/src/install/context.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// native type-stripping — no build step, no runtime dependency. Body stays CommonJS so a `.js`
|
|
8
8
|
// caller's `require('../install/context.js')` keeps working while the tree is still `.js`.
|
|
9
9
|
const path = require('node:path');
|
|
10
|
+
const { canonicalPath } = require('./paths.js');
|
|
10
11
|
const { sourceCommit: gitSourceCommit } = require('../helper/git.js');
|
|
11
12
|
|
|
12
13
|
|
|
@@ -27,7 +28,7 @@ const { sourceCommit: gitSourceCommit } = require('../helper/git.js');
|
|
|
27
28
|
function resolveContext({ repoRoot, global, projectRoot, targets, dirs, sourceCommit, managedResources } ) {
|
|
28
29
|
const context = {
|
|
29
30
|
scope: global ? 'global' : 'project',
|
|
30
|
-
scopeRoot: global ? path.dirname(dirs.claude) :
|
|
31
|
+
scopeRoot: global ? path.dirname(dirs.claude) : canonicalPath(projectRoot),
|
|
31
32
|
targets,
|
|
32
33
|
dirs: Object.fromEntries(targets.map((t) => [t, dirs[t]])),
|
|
33
34
|
repoRoot,
|