@6reduk/workspace-pipeline 0.1.0 → 0.3.0
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 +5 -0
- package/docs/lifecycle-cli.md +26 -2
- package/docs/rebind.md +48 -0
- package/docs/reset.md +96 -0
- package/package.json +1 -1
- package/schemas/operation.schema.json +1 -0
- package/src/commands/dispatch.js +20 -2
- package/src/commands/rebind.js +48 -0
- package/src/commands/reset.js +63 -0
- package/src/contracts/semantic.js +3 -1
- package/src/operations/apply.js +43 -10
- package/src/operations/bundle-update.js +2 -1
- package/src/operations/plan.js +3 -1
- package/src/operations/reconciliation.js +14 -6
- package/src/operations/repair.js +2 -2
- package/src/operations/reset-scope.js +82 -0
- package/src/operations/reset.js +127 -0
- package/src/operations/skill-retirement.js +14 -0
- package/src/operations/toml-fields.js +35 -2
package/README.md
CHANGED
|
@@ -10,6 +10,11 @@ observed in disposable sessions. Kimi native verification is deferred; its
|
|
|
10
10
|
configuration renderer remains experimental. Real-project migration requires an
|
|
11
11
|
explicit preview and approval; installation is not blanket runtime certification.
|
|
12
12
|
|
|
13
|
+
Version 0.3.0 adds [rebind](docs/rebind.md) (explicit
|
|
14
|
+
manifest/source binding changes) and [reset](docs/reset.md) (backed-up restoration
|
|
15
|
+
or clearing of selected local adapter configuration). Both require a separate
|
|
16
|
+
preview and explicit apply for changes; these commands are not in registry 0.2.0.
|
|
17
|
+
|
|
13
18
|
Grok's Claude-import suppression requires the [scoped launch command](docs/launch.md).
|
|
14
19
|
File installation does not establish native session discovery or runtime isolation;
|
|
15
20
|
Kimi/Grok checks and known generic-skill discovery limits are documented in
|
package/docs/lifecycle-cli.md
CHANGED
|
@@ -27,8 +27,8 @@ Preview returns the complete prepared JSON on stdout. It may acquire a Git
|
|
|
27
27
|
source into temporary storage outside the wrapper, but does not write provider
|
|
28
28
|
configuration. Network acquisition requires explicit `--network`. When manifest
|
|
29
29
|
is omitted, setup uses the standard workspace manifest; update uses the recorded
|
|
30
|
-
manifest origin. Relocation does not silently rebind it.
|
|
31
|
-
rebind workflow
|
|
30
|
+
manifest origin. Relocation does not silently rebind it. Use the explicit
|
|
31
|
+
[manifest rebind workflow](rebind.md) to change that origin.
|
|
32
32
|
|
|
33
33
|
Save prepared JSON privately, inspect its operations and then explicitly invoke
|
|
34
34
|
`--apply --preview`. It may contain configuration secrets and absolute local
|
|
@@ -42,6 +42,30 @@ existing locked approval, registry, ownership, history, snapshot and drift check
|
|
|
42
42
|
from preview generation. Existing local opt-in retention is handled by the
|
|
43
43
|
internal lifecycle coordinator, not a second cleanup pass in the dispatcher.
|
|
44
44
|
|
|
45
|
+
### Renamed or removed skills during update
|
|
46
|
+
|
|
47
|
+
For a provider that remains selected, update retires an old managed
|
|
48
|
+
`skills/<name>/SKILL.md` when the new source no longer supplies that path.
|
|
49
|
+
The exact deletion appears in the preview; if installation took over an existing
|
|
50
|
+
file, update restores its verified original backup instead. This supports names
|
|
51
|
+
such as `unity-review` changing to `sdx-review` without leaving duplicate skills.
|
|
52
|
+
|
|
53
|
+
Only recorded, unchanged native skill entry files qualify. A user edit before
|
|
54
|
+
preview or after approval blocks the operation. User-added files (including notes
|
|
55
|
+
beside a retired skill), other skills, agent definitions, configuration files and
|
|
56
|
+
root instructions are not cleaned up by this rule. Directories are not recursively
|
|
57
|
+
deleted. Provider/bundle removal remains a separate selection operation, except
|
|
58
|
+
for the existing bundle-member update behavior.
|
|
59
|
+
|
|
60
|
+
This is not a reset or a force-update option. Missing owned files should be
|
|
61
|
+
repaired from the installed snapshot first; conflicting local customizations
|
|
62
|
+
must not be silently discarded. Rebind only approves a new manifest origin;
|
|
63
|
+
it does not relax these ownership checks.
|
|
64
|
+
|
|
65
|
+
To deliberately discard local adapter customizations, use the separate
|
|
66
|
+
[reset preview/backup/apply workflow](reset.md). Its default is installed-snapshot
|
|
67
|
+
restoration; empty reset must be explicitly selected.
|
|
68
|
+
|
|
45
69
|
## Output and failures
|
|
46
70
|
|
|
47
71
|
- stdout: one prepared JSON or operation result.
|
package/docs/rebind.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Move a workspace manifest or change its source binding
|
|
2
|
+
|
|
3
|
+
The installer remembers the location of your workspace manifest and the repository
|
|
4
|
+
it resolves to. Editing its selected Git ref in place is an ordinary update.
|
|
5
|
+
Moving the file or changing its repository needs explicit rebind approval.
|
|
6
|
+
This is installation metadata, not a version requirement for project documents.
|
|
7
|
+
|
|
8
|
+
## Three steps
|
|
9
|
+
|
|
10
|
+
Prepare the new manifest yourself, preferably as `workspace.json` in the wrapper.
|
|
11
|
+
Keep the old file until the update succeeds. Relative local Git paths are resolved
|
|
12
|
+
from the **new manifest's directory**, not from the shell or the wrapper.
|
|
13
|
+
|
|
14
|
+
The following PowerShell example uses illustrative absolute paths; replace them.
|
|
15
|
+
Use a private directory for preview files: they can contain configuration values.
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
workspace-pipeline rebind --workspace 'C:\Work\Game' --manifest 'C:\Work\Game\workspace.json' > 'C:\Private\rebind.json'
|
|
19
|
+
workspace-pipeline update --workspace 'C:\Work\Game' --accept-rebind 'C:\Private\rebind.json' > 'C:\Private\update.json'
|
|
20
|
+
workspace-pipeline update --workspace 'C:\Work\Game' --apply --preview 'C:\Private\update.json'
|
|
21
|
+
workspace-pipeline doctor --workspace 'C:\Work\Game'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Check the exit status after each command. Never apply an empty or failed preview.
|
|
25
|
+
Between the first and second commands inspect `previousOrigin`, `proposed.origin`
|
|
26
|
+
and `proposed.manifest`. The first command only reads local state/history and the
|
|
27
|
+
new manifest. It does not fetch Git, move files, or activate the new origin.
|
|
28
|
+
|
|
29
|
+
`--accept-rebind` explicitly approves the saved proposal for source acquisition.
|
|
30
|
+
For a remote source, add `--network` to this second command. Inspect the resulting
|
|
31
|
+
update operations before the third command, which separately approves file writes.
|
|
32
|
+
The new origin becomes active as part of that normal, locked update, not as a
|
|
33
|
+
standalone write to `.pipeline/state.json`.
|
|
34
|
+
|
|
35
|
+
## Boundaries and failures
|
|
36
|
+
|
|
37
|
+
- Rebind requires an installed workspace without pending operations or broken history.
|
|
38
|
+
- Moving the workspace itself is not supported by this command.
|
|
39
|
+
- A changed state or manifest invalidates the proposal: regenerate and review it.
|
|
40
|
+
- `--accept-rebind` is update-preview-only; do not combine it with `--manifest`,
|
|
41
|
+
`--apply`, or another lifecycle command. Apply uses the saved update preview only.
|
|
42
|
+
- Rebind does not approve conflicts with user-edited files, credentials, MCP execution,
|
|
43
|
+
trust grants, cleanup or deletion of the old manifest.
|
|
44
|
+
- `repair` still uses the installed snapshot, not a newer Git revision.
|
|
45
|
+
- A failed/interrupted apply may have partial effects. Inspect its reported recovery
|
|
46
|
+
record and doctor; do not edit state manually or blindly repeat the update.
|
|
47
|
+
|
|
48
|
+
Availability: introduced in 0.3.0; these commands are not present in 0.2.0.
|
package/docs/reset.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Reset a local adapter environment
|
|
2
|
+
|
|
3
|
+
Use reset when you deliberately want to discard local pipeline customizations.
|
|
4
|
+
Ordinary `update`, `repair` and `remove` continue to protect conflicting edits.
|
|
5
|
+
Reset is an offline operation on the **installed snapshot**, not an upgrade.
|
|
6
|
+
It does not reset Git repositories or delete the workspace.
|
|
7
|
+
|
|
8
|
+
Availability: introduced in 0.3.0; not present in 0.2.0.
|
|
9
|
+
|
|
10
|
+
## Preview first
|
|
11
|
+
|
|
12
|
+
Close running harnesses/configuration editors. Keep preview files private: they
|
|
13
|
+
contain before/after bytes, potentially including MCP credentials.
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
workspace-pipeline reset --workspace 'C:\Work\Game' --all > 'C:\Private\reset.json'
|
|
17
|
+
# Check $LASTEXITCODE and review reset.scope, reset.backup and preview.plan.targets.
|
|
18
|
+
workspace-pipeline reset --workspace 'C:\Work\Game' --apply --preview 'C:\Private\reset.json'
|
|
19
|
+
workspace-pipeline doctor --workspace 'C:\Work\Game'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The default is `--to installed`: clean the selected scope and restore its supplied
|
|
23
|
+
configuration from the installed snapshot. Use `--to empty` explicitly to remove
|
|
24
|
+
the selected adapter configuration instead. After full empty reset, `doctor`
|
|
25
|
+
reports `not-installed` (exit 1), not an installed/ready pipeline. Then use setup
|
|
26
|
+
with a chosen manifest. For an upgrade after customization, reset to installed,
|
|
27
|
+
then perform a normal update from the source you selected.
|
|
28
|
+
|
|
29
|
+
Selection is mandatory: `--all`, or `--providers codex`, or
|
|
30
|
+
`--bundles claude-grok` (use the actual installed bundle ID). Provider and bundle
|
|
31
|
+
lists can be combined, but not with `--all`. Claude/Grok bundle members cannot be
|
|
32
|
+
selected individually. Apply accepts only the saved preview, no mode/selection
|
|
33
|
+
overrides. Preview generation alone changes no provider files.
|
|
34
|
+
|
|
35
|
+
## Exactly what is reset
|
|
36
|
+
|
|
37
|
+
| Selected provider | File trees, including user additions | Configuration sections |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| Codex | `.agents/skills`, `.codex/agents` | Named agents and MCP servers in `.codex/config.toml` |
|
|
40
|
+
| Claude | `.claude/skills`, `.claude/agents` | `mcpServers` in `.mcp.json` |
|
|
41
|
+
| Grok | `.grok/skills`, `.grok/agents` | `mcp_servers` in `.grok/config.toml` |
|
|
42
|
+
| Kimi | `.kimi-code/skills`, `.kimi-code/agents` | `mcpServers` in `.kimi-code/mcp.json` |
|
|
43
|
+
|
|
44
|
+
Only recorded `AGENTS.md` / `CLAUDE.md` entry files associated with the selection
|
|
45
|
+
are included. Shared entries remain when unselected providers need them. Conflicts
|
|
46
|
+
in unselected owned files block reset rather than being repaired implicitly.
|
|
47
|
+
Kimi configuration tests do not imply native Kimi runtime support is verified.
|
|
48
|
+
|
|
49
|
+
This is **not** recursive deletion of `.codex`, `.claude`, `.grok`, `.kimi-code`,
|
|
50
|
+
or the wrapper. Repositories, documents, assets, `.git`, manifests, snapshots,
|
|
51
|
+
history, globals, account/auth files, model/permission/trust settings and unrelated
|
|
52
|
+
configuration sections remain. Codex agent runtime/model controls also remain,
|
|
53
|
+
as do unknown non-table values under `[agents]` (scalars, dates and arrays).
|
|
54
|
+
Non-control table entries under `[agents]` are treated as named agent definitions.
|
|
55
|
+
Unknown files outside the listed trees are not guessed to be pipeline files.
|
|
56
|
+
Files under listed trees are included regardless of ignore rules. Empty directories
|
|
57
|
+
and empty containing configuration files may remain; they are not an active skill
|
|
58
|
+
or MCP declaration. Grok still uses its scoped launcher for import suppression.
|
|
59
|
+
|
|
60
|
+
Malformed configurations, unsupported TOML representations, unsafe paths/links,
|
|
61
|
+
hardlinks, repository overlap, unavailable snapshots, incompatible renderer replay,
|
|
62
|
+
broken history and pending operations cause a refusal. Reset is not a force option
|
|
63
|
+
for corrupted installer metadata. No Git download, harness or MCP is started.
|
|
64
|
+
|
|
65
|
+
The portable path policy currently rejects non-ASCII names inside scoped trees,
|
|
66
|
+
including Cyrillic user filenames. Reset refuses the whole operation rather than
|
|
67
|
+
skipping those files. Move such additions to a safe location outside the reset
|
|
68
|
+
trees or rename them yourself, then generate a new preview; do not reuse an old
|
|
69
|
+
approval after changing the file inventory.
|
|
70
|
+
|
|
71
|
+
## Backups and interruptions
|
|
72
|
+
|
|
73
|
+
Before target changes, the CLI saves and verifies complete original bytes at
|
|
74
|
+
`.pipeline/backups/reset/<digest>/`. It prints that location and the transaction's
|
|
75
|
+
journal/recovery paths. `manifest.json` maps each binary backup to its original
|
|
76
|
+
workspace-relative path and SHA-256. Backups can contain secrets: keep them local,
|
|
77
|
+
do not commit or publish them. Logs cleanup does not delete these backups.
|
|
78
|
+
|
|
79
|
+
If backup verification fails, no provider target is changed. A later failure can
|
|
80
|
+
leave partial effects; do not treat a failure as rollback. Inspect the reported
|
|
81
|
+
recovery and use the normal `continue` preview/apply workflow. It rechecks the
|
|
82
|
+
exact approved scope and preserves the original records and backups. Added or
|
|
83
|
+
changed files invalidate the applicable preview; they are never silently included
|
|
84
|
+
in a previous destructive approval. Stop other writers while applying.
|
|
85
|
+
|
|
86
|
+
To recover an individual discarded customization after successful reset, read
|
|
87
|
+
the backup manifest, verify the selected `.bin` hash, and copy only those bytes
|
|
88
|
+
to its listed destination after checking the current file and saving any newer
|
|
89
|
+
edits. This is a deliberate customization: doctor/update may then report drift.
|
|
90
|
+
Do not copy an old `.pipeline/state.json`, overwrite an entire provider directory,
|
|
91
|
+
or restore a whole config file without reviewing newer settings/credentials.
|
|
92
|
+
Reset does not provide automatic rollback of account settings or Git work.
|
|
93
|
+
|
|
94
|
+
Reset creates a new clean ownership baseline for its selected supplied entries;
|
|
95
|
+
ordinary removal will not resurrect discarded pre-reset custom configuration.
|
|
96
|
+
Historical backups remain available for explicit recovery.
|
package/package.json
CHANGED
package/src/commands/dispatch.js
CHANGED
|
@@ -24,6 +24,8 @@ import {listRepositoryHistory} from '../operations/repository-history.js';
|
|
|
24
24
|
import {runRepositoryCommand} from './init.js';
|
|
25
25
|
import {parseMigrationCommand,runMigrationCommand} from './migration.js';
|
|
26
26
|
import {parseLaunch,runLaunch} from './launch.js';
|
|
27
|
+
import {parseRebind,runRebind,acceptedRebind} from './rebind.js';
|
|
28
|
+
import {parseReset,runReset} from './reset.js';
|
|
27
29
|
|
|
28
30
|
export const help=`Workspace Pipeline CLI — development preview
|
|
29
31
|
Usage: workspace-pipeline doctor --workspace <absolute-directory> [--recovery <relative-record>] [--json]
|
|
@@ -32,6 +34,11 @@ Usage: workspace-pipeline doctor --workspace <absolute-directory> [--recovery <r
|
|
|
32
34
|
workspace-pipeline <init|adopt|wrap> --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
33
35
|
workspace-pipeline <setup|update> --workspace <absolute-directory> [--manifest <absolute-file>] [--network]
|
|
34
36
|
workspace-pipeline <setup|update> --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
37
|
+
workspace-pipeline rebind --workspace <absolute-directory> --manifest <absolute-file>
|
|
38
|
+
workspace-pipeline update --workspace <absolute-directory> --accept-rebind <absolute-json-file> [--network]
|
|
39
|
+
workspace-pipeline reset --workspace <absolute-directory> --all [--to installed|empty]
|
|
40
|
+
workspace-pipeline reset --workspace <absolute-directory> [--providers <ids>] [--bundles <ids>] [--to installed|empty]
|
|
41
|
+
workspace-pipeline reset --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
35
42
|
workspace-pipeline <repair|remove> --workspace <absolute-directory> [--providers <comma-separated-ids>] [--bundles <comma-separated-ids>]
|
|
36
43
|
workspace-pipeline <repair|remove> --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
37
44
|
workspace-pipeline switch --workspace <absolute-directory> --manifest <absolute-file> [--network]
|
|
@@ -79,6 +86,11 @@ select a new source/manifest or download a replacement for missing staged data.
|
|
|
79
86
|
No native plugins are installed. Source packages cannot supply executable adapters.
|
|
80
87
|
Repair/remove are offline installed-snapshot operations. --providers and --bundles are remove-only,
|
|
81
88
|
preview-only; omission previews full removal. No automatic history cleanup on these commands.
|
|
89
|
+
Reset is separate and destructive within its explicit local configuration scope,
|
|
90
|
+
including user additions: default --to installed, --to empty explicit. Selection
|
|
91
|
+
is mandatory. Preview first; apply backs up affected bytes before target writes.
|
|
92
|
+
No repository/global/auth/model/permissions cleanup. Malformed configuration fails
|
|
93
|
+
closed. Recovery uses the existing continue command; backups remain local/private.
|
|
82
94
|
Switch requires an explicit incoming manifest in preview, preserves two separate
|
|
83
95
|
phases and activates only after both pass. Failure is not rolled back or retried.
|
|
84
96
|
Continue creates a new approved operation from pending evidence; it does not replay old journals.
|
|
@@ -131,6 +143,8 @@ export function parseCommand(args) {
|
|
|
131
143
|
if(!Array.isArray(args) || args.some(a=>typeof a!=='string')) fail('cli.arguments');
|
|
132
144
|
if(args.length===0 || (args.length===1 && ['--help','-h'].includes(args[0])))return {command:'help'};
|
|
133
145
|
if(args[0]==='launch')return parseLaunch(args);
|
|
146
|
+
if(args[0]==='rebind')return parseRebind(args);
|
|
147
|
+
if(args[0]==='reset')return parseReset(args);
|
|
134
148
|
if(['setup','update','repair','remove','switch','continue'].includes(args[0]))return parseLifecycle(args);
|
|
135
149
|
if(args[0]==='migration')return parseMigrationCommand(args);
|
|
136
150
|
if(['init','adopt','wrap'].includes(args[0]))return parseRepositories(args);
|
|
@@ -214,7 +228,7 @@ function parseLifecycle(args) {
|
|
|
214
228
|
const result={command:args[0]},seen=new Set(),maintenance=['repair','remove'].includes(args[0]);
|
|
215
229
|
for(let i=1;i<args.length;i++) {
|
|
216
230
|
const flag=args[i];
|
|
217
|
-
const allowed=['--workspace','--apply','--preview','--json',...(result.command==='continue'?['--recovery']:maintenance?result.command==='remove'?['--providers','--bundles']:[]:['--manifest','--network'])];
|
|
231
|
+
const allowed=['--workspace','--apply','--preview','--json',...(result.command==='update'?['--accept-rebind']:[]),...(result.command==='continue'?['--recovery']:maintenance?result.command==='remove'?['--providers','--bundles']:[]:['--manifest','--network'])];
|
|
218
232
|
if(!allowed.includes(flag) || seen.has(flag))fail('cli.arguments');
|
|
219
233
|
seen.add(flag);
|
|
220
234
|
if(flag==='--json')continue;
|
|
@@ -235,9 +249,10 @@ function parseLifecycle(args) {
|
|
|
235
249
|
if(providers.some(p=>!['codex','claude','kimi','grok'].includes(p)) || new Set(providers).size!==providers.length)fail('cli.arguments');
|
|
236
250
|
result.providers=providers.sort();continue;
|
|
237
251
|
}
|
|
238
|
-
result[{'--workspace':'workspace','--manifest':'manifestPath','--preview':'previewFile'}[flag]]=absoluteRoot(value);
|
|
252
|
+
result[{'--workspace':'workspace','--manifest':'manifestPath','--preview':'previewFile','--accept-rebind':'rebindFile'}[flag]]=absoluteRoot(value);
|
|
239
253
|
}
|
|
240
254
|
if(!result.workspace)fail('cli.workspace-required');
|
|
255
|
+
if(result.rebindFile && (result.apply || result.manifestPath))fail('cli.arguments');
|
|
241
256
|
if(result.apply?(!result.previewFile || result.manifestPath || result.network || result.providers || result.bundles):result.previewFile)fail('cli.arguments');
|
|
242
257
|
if(result.command==='switch' && !result.apply && !result.manifestPath)fail('switch.manifest-required');
|
|
243
258
|
if(result.command==='continue' && (result.apply?result.recoveryPath:!result.recoveryPath))fail('continuation.recovery-required');
|
|
@@ -259,6 +274,7 @@ async function runLifecycle(command,registry,stdout,stderr) {
|
|
|
259
274
|
if(command.providers)input.providers=command.providers;
|
|
260
275
|
if(command.bundles)input.bundles=command.bundles;
|
|
261
276
|
if(command.recoveryPath)input.recoveryPath=command.recoveryPath;
|
|
277
|
+
if(command.rebindFile)Object.assign(input,await acceptedRebind(command.workspace,command.rebindFile));
|
|
262
278
|
await stdout(JSON.stringify(await prepare(input,registry))+'\n');return 0;
|
|
263
279
|
}
|
|
264
280
|
const prepared=(await readRecord(command.previewFile)).value;
|
|
@@ -374,6 +390,8 @@ export async function runCli(args,{stdout,stderr,registry=null}) {
|
|
|
374
390
|
const command=parseCommand(args);
|
|
375
391
|
if(command.command==='help') {await stdout(help+'\n');return 0;}
|
|
376
392
|
if(command.command==='launch')return await runLaunch(command,stdout,stderr);
|
|
393
|
+
if(command.command==='rebind')return await runRebind(command,stdout);
|
|
394
|
+
if(command.command==='reset')return await runReset(command,registry,stdout,stderr);
|
|
377
395
|
if(command.command==='migration')return await runMigrationCommand(command,stdout,stderr);
|
|
378
396
|
if(command.command==='logs')return await runLogs(command,stdout,stderr);
|
|
379
397
|
if(command.command==='policy')return await runPolicy(command,stdout);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {absoluteRoot,resolveChild} from '../workspace/paths.js';
|
|
2
|
+
import {fail} from '../contracts/parse.js';
|
|
3
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
4
|
+
import {readRecord,readState,previewRebind,resolveApprovedRebind} from '../operations/state.js';
|
|
5
|
+
import {assertNoRepositoryPending} from '../operations/repository-pending.js';
|
|
6
|
+
import {inspectHistory} from '../operations/history.js';
|
|
7
|
+
|
|
8
|
+
export function parseRebind(args) {
|
|
9
|
+
const result={command:'rebind'},seen=new Set();
|
|
10
|
+
for(let i=1;i<args.length;i++) {
|
|
11
|
+
const flag=args[i];
|
|
12
|
+
if(!['--workspace','--manifest'].includes(flag) || seen.has(flag))fail('cli.arguments');
|
|
13
|
+
seen.add(flag);
|
|
14
|
+
const value=args[++i];if(!value || value.startsWith('--'))fail('cli.arguments');
|
|
15
|
+
result[flag==='--workspace'?'workspace':'manifestPath']=absoluteRoot(value);
|
|
16
|
+
}
|
|
17
|
+
if(!result.workspace || !result.manifestPath)fail('cli.arguments');
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function current(wrapper) {
|
|
22
|
+
await assertNoRepositoryPending(wrapper);
|
|
23
|
+
const history=await inspectHistory(wrapper);
|
|
24
|
+
if(!history.complete || history.diagnostics.length)fail('lifecycle.history');
|
|
25
|
+
return (await readState(resolveChild(wrapper,'.pipeline/state.json'))).value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Read manifests and installed history only. Never acquire Git, update state,
|
|
29
|
+
// move the supplied manifest, or manufacture source/apply approval here.
|
|
30
|
+
export async function runRebind(command,stdout) {
|
|
31
|
+
const previous=await current(command.workspace);
|
|
32
|
+
const proposal=await previewRebind({wrapper:command.workspace,previous,manifestPath:command.manifestPath});
|
|
33
|
+
if(proposal.previousWorkspace!==proposal.workspace)fail('rebind.workspace-move');
|
|
34
|
+
await stdout(JSON.stringify(proposal)+'\n');
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Called only for the explicit update --accept-rebind flag. The resulting
|
|
39
|
+
// prepared update still needs a separate --apply --preview approval.
|
|
40
|
+
export async function acceptedRebind(wrapper,filename) {
|
|
41
|
+
const proposal=(await readRecord(filename)).value;
|
|
42
|
+
if(proposal?.kind!=='rebind-preview' || typeof proposal?.proposed?.origin?.path!=='string')fail('rebind.proposal');
|
|
43
|
+
const manifestPath=absoluteRoot(proposal.proposed.origin.path);
|
|
44
|
+
const previous=await current(wrapper);
|
|
45
|
+
const approval={decision:'approve',proposalDigest:contractDigest(proposal)};
|
|
46
|
+
await resolveApprovedRebind({wrapper,previous,manifestPath,proposal,approval});
|
|
47
|
+
return {manifestPath,rebind:{proposal,approval}};
|
|
48
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {absoluteRoot,resolveChild} from '../workspace/paths.js';
|
|
2
|
+
import {ContractError,fail} from '../contracts/parse.js';
|
|
3
|
+
import {readRecord} from '../operations/state.js';
|
|
4
|
+
import {resetOptions,prepareReset,validateResetRecord} from '../operations/reset.js';
|
|
5
|
+
import {applyReset} from '../operations/apply.js';
|
|
6
|
+
import {acquireWorkspaceLock} from '../operations/lock.js';
|
|
7
|
+
import {inspectHistory} from '../operations/history.js';
|
|
8
|
+
import {assertNoRepositoryPending} from '../operations/repository-pending.js';
|
|
9
|
+
|
|
10
|
+
export function parseReset(args){
|
|
11
|
+
const result={command:'reset'},seen=new Set();
|
|
12
|
+
for(let i=1;i<args.length;i++){
|
|
13
|
+
const flag=args[i];
|
|
14
|
+
if(!['--workspace','--to','--all','--providers','--bundles','--apply','--preview'].includes(flag) || seen.has(flag))fail('cli.arguments');
|
|
15
|
+
seen.add(flag);
|
|
16
|
+
if(flag==='--apply'){result.apply=true;continue;}
|
|
17
|
+
if(flag==='--all'){(result.options??={}).all=true;continue;}
|
|
18
|
+
const value=args[++i];if(!value || value.startsWith('--'))fail('cli.arguments');
|
|
19
|
+
if(flag==='--workspace')result.workspace=absoluteRoot(value);
|
|
20
|
+
else if(flag==='--preview')result.previewFile=absoluteRoot(value);
|
|
21
|
+
else if(flag==='--to')(result.options??={}).to=value;
|
|
22
|
+
else{
|
|
23
|
+
const ids=value.split(',');
|
|
24
|
+
if(ids.some(id=>!/^[a-z][a-z0-9-]{0,62}$/.test(id)) || new Set(ids).size!==ids.length)fail('cli.arguments');
|
|
25
|
+
(result.options??={})[flag==='--providers'?'providers':'bundles']=ids.sort();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if(!result.workspace)fail('cli.workspace-required');
|
|
29
|
+
if(result.apply){if(!result.previewFile || result.options)fail('cli.arguments');}
|
|
30
|
+
else {if(result.previewFile)fail('cli.arguments');result.options=resetOptions(result.options??{});}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
async function history(workspace){
|
|
34
|
+
await assertNoRepositoryPending(workspace);
|
|
35
|
+
const h=await inspectHistory(workspace);if(!h.complete || h.diagnostics.length)fail('reset.history');
|
|
36
|
+
}
|
|
37
|
+
export async function runReset(command,registry,stdout,stderr){
|
|
38
|
+
if(!command.apply){
|
|
39
|
+
await history(command.workspace);
|
|
40
|
+
await stdout(JSON.stringify(await prepareReset(command.workspace,registry,command.options))+'\n');return 0;
|
|
41
|
+
}
|
|
42
|
+
const raw=(await readRecord(command.previewFile)).value;
|
|
43
|
+
const approval={decision:'approve',preparedDigest:raw.digest};
|
|
44
|
+
const prepared=validateResetRecord(raw,approval);
|
|
45
|
+
if(prepared.preview.plan.workspace!==command.workspace)fail('reset.workspace');
|
|
46
|
+
const result={command:'reset',mode:prepared.reset.selection.to,workspace:command.workspace,status:'failed',
|
|
47
|
+
backupDirectory:resolveChild(command.workspace,prepared.reset.backup.directory),
|
|
48
|
+
journal:null,recovery:null,runtime:'not-run',lockRelease:'not-acquired'};
|
|
49
|
+
let lock;
|
|
50
|
+
try{
|
|
51
|
+
lock=await acquireWorkspaceLock(command.workspace);result.lockRelease='pending';await history(command.workspace);
|
|
52
|
+
await stderr(JSON.stringify({kind:'reset-backup-location',path:result.backupDirectory,status:'not-yet-verified'})+'\n');
|
|
53
|
+
const applied=await applyReset(lock,prepared,approval,registry,{onJournal:async location=>{
|
|
54
|
+
result.journal=location.directory;
|
|
55
|
+
result.recovery=resolveChild(command.workspace,location.relative.replace('/journals/','/transactions/')+'/recovery.json');
|
|
56
|
+
await stderr(JSON.stringify({kind:'journal-location',path:result.journal,recovery:result.recovery,backupDirectory:result.backupDirectory})+'\n');
|
|
57
|
+
}});
|
|
58
|
+
result.status=applied.status;
|
|
59
|
+
}catch(error){result.error=error instanceof ContractError?error.code:'reset.io';}
|
|
60
|
+
finally{if(lock)try{await lock.release();result.lockRelease='released';}catch{result.lockRelease='failed';}}
|
|
61
|
+
await stdout(JSON.stringify(result)+'\n');
|
|
62
|
+
return ['ready','not-installed'].includes(result.status) && result.lockRelease==='released'?0:1;
|
|
63
|
+
}
|
|
@@ -5,6 +5,7 @@ import { fail } from './parse.js';
|
|
|
5
5
|
import { validateStructure } from './validate.js';
|
|
6
6
|
import { requiredCapabilities, assertAdapter } from '../providers/interface.js';
|
|
7
7
|
import { resolveProviders, validateInstalledBundles } from '../providers/bundles.js';
|
|
8
|
+
import {retiredSkillOwnership} from '../operations/skill-retirement.js';
|
|
8
9
|
|
|
9
10
|
const has = (o, k) => Object.hasOwn(o, k);
|
|
10
11
|
export function portablePath(value) {
|
|
@@ -173,7 +174,8 @@ export function validateOperation(op, previous) {
|
|
|
173
174
|
o.path===t.path && o.owner===t.owner && o.kind==='file') &&
|
|
174
175
|
Object.entries(previous.active.bundles??{}).some(([id,b])=>op.desired?.bundles?.[id] &&
|
|
175
176
|
b.providers.includes(t.owner) && !op.desired.providers.includes(t.owner));
|
|
176
|
-
|
|
177
|
+
const retiredSkillFile=op.command==='update' && retiredSkillOwnership(previous,op.desired).some(o=>o.path===t.path && o.owner===t.owner);
|
|
178
|
+
if (t.action === 'verify-absent' && ((!['remove','reset'].includes(op.command) && !retiredBundleFile && !retiredSkillFile) || t.beforeHash !== null || t.desiredHash !== null)) fail('operation.hash');
|
|
177
179
|
if (['replace', 'edit-fields'].includes(t.action) && (t.beforeHash === null || t.desiredHash === null)) fail('operation.hash');
|
|
178
180
|
if ((t.action === 'edit-fields') !== (t.fields.length > 0)) fail('operation.fields');
|
|
179
181
|
const fieldPointers = [];
|
package/src/operations/apply.js
CHANGED
|
@@ -9,6 +9,7 @@ import { checkPreview, composePlan } from './plan.js';
|
|
|
9
9
|
import { assertLockHeld } from './lock.js';
|
|
10
10
|
import { requestShape } from './ownership.js';
|
|
11
11
|
import {planObservationPaths,retiredBundleOwnership} from './bundle-update.js';
|
|
12
|
+
import {retiredSkillOwnership} from './skill-retirement.js';
|
|
12
13
|
import {readConfigField} from './config-fields.js';
|
|
13
14
|
import { planLayout } from '../workspace/resolve.js';
|
|
14
15
|
import { cap, LIMITS, sha256 } from '../source/inventory.js';
|
|
@@ -18,6 +19,8 @@ import {verifyRepairApproval,validateRepairRecord} from './repair.js';
|
|
|
18
19
|
import {verifyContinuationApproval,validateContinuationRecord,continuationTargetAction,continuationCommand} from './reconciliation.js';
|
|
19
20
|
import {createLineageGuard} from './lineage-guard.js';
|
|
20
21
|
import {verifyRemovalApproval,validateRemovalRecord} from './remove.js';
|
|
22
|
+
import {verifyResetApproval,validateResetRecord} from './reset.js';
|
|
23
|
+
import {assertResetTreeScope,resetBackupBytes} from './reset-scope.js';
|
|
21
24
|
|
|
22
25
|
const fileHash=bytes=>bytes===null?null:sha256(bytes);
|
|
23
26
|
const recordBytes=value=>{
|
|
@@ -59,26 +62,32 @@ export async function inspectRecovery(workspace,recoveryPath,options={}) {
|
|
|
59
62
|
const repair=recovery.prepared?.kind==='prepared-repair';
|
|
60
63
|
const continuation=recovery.prepared?.kind==='prepared-continuation';
|
|
61
64
|
const removal=recovery.prepared?.kind==='prepared-removal';
|
|
62
|
-
const
|
|
65
|
+
const reset=recovery.prepared?.kind==='prepared-reset';
|
|
66
|
+
const prepared=reset?validateResetRecord(recovery.prepared,recovery.approval):removal?validateRemovalRecord(recovery.prepared,recovery.approval):continuation?validateContinuationRecord(recovery.prepared,recovery.approval):repair?validateRepairRecord(recovery.prepared,recovery.approval):validatePreparedRecord(recovery.prepared,recovery.approval),previous=structuredClone(recovery.previous);
|
|
63
67
|
const declared=prepared.preview.observations;
|
|
64
68
|
if(!Array.isArray(declared))fail('recovery.record');
|
|
65
69
|
const before=declared.map(o=>({path:o.path,bytes:o.bytes===null?null:Buffer.from(o.bytes,'base64')}));
|
|
66
70
|
checkPreview(prepared.preview,previous,before);
|
|
67
71
|
const plan=prepared.preview.plan;
|
|
68
|
-
const removalFlow=removal || (continuation && plan.command==='remove');
|
|
72
|
+
const removalFlow=removal || plan.command==='reset' || (continuation && plan.command==='remove');
|
|
69
73
|
const deployment=removalFlow?previous?.active:plan.desired;
|
|
70
|
-
if(plan.workspace!==workspace || !(removalFlow?['remove']:continuation?['repair','update']:repair?['repair']:['setup','update']).includes(plan.command) || !deployment || !plan.source ||
|
|
74
|
+
if(plan.workspace!==workspace || !(plan.command==='reset' && (reset || continuation)?['reset']:removalFlow?['remove']:continuation?['repair','update']:repair?['repair']:['setup','update']).includes(plan.command) || !deployment || !plan.source ||
|
|
71
75
|
contractDigest(plan.source)!==contractDigest(deployment.snapshot) ||
|
|
72
76
|
plan.source.path!=='.pipeline/snapshots/'+plan.source.digest.slice(7))fail('recovery.binding');
|
|
73
77
|
if(repair && (!previous?.active || contractDigest(plan.desired)!==contractDigest(previous.active) ||
|
|
74
78
|
plan.targets.some(t=>!['create','edit-fields'].includes(t.action) || t.fields.some(f=>f.beforeHash!==null))))fail('recovery.binding');
|
|
75
79
|
const lineage=continuation?await verifyContinuationLineage(workspace,prepared,previous):[];
|
|
76
80
|
const expectedBackups=new Set(deployment.owned.filter(o=>o.backup!==null).map(o=>o.backup)),seen=new Set();
|
|
81
|
+
if(plan.command==='reset')for(const b of [...prepared.reset.backup.files,prepared.reset.backup.manifest])expectedBackups.add(b.path);
|
|
77
82
|
for(const backup of recovery.backups) {
|
|
78
83
|
requestShape(backup,['path','hash','existing'],[],'recovery.backup');
|
|
79
84
|
if(!expectedBackups.has(backup.path) || seen.has(backup.path) || typeof backup.existing!=='boolean' ||
|
|
80
85
|
!/^sha256:[a-f0-9]{64}$/.test(backup.hash))fail('recovery.backup');
|
|
81
86
|
seen.add(backup.path);
|
|
87
|
+
if(plan.command==='reset'){
|
|
88
|
+
const expected=[...prepared.reset.backup.files,prepared.reset.backup.manifest].find(b=>b.path===backup.path);
|
|
89
|
+
if(expected && expected.hash!==backup.hash)fail('recovery.backup');
|
|
90
|
+
}
|
|
82
91
|
}
|
|
83
92
|
if(seen.size!==expectedBackups.size)fail('recovery.backup');
|
|
84
93
|
const journalPath='.pipeline/journals/'+match[1];
|
|
@@ -258,6 +267,24 @@ export async function applyRepair(lock,prepared,approval,registry,options={}) {
|
|
|
258
267
|
()=>verifyRepairApproval(lock,copy,approval,registry),options);
|
|
259
268
|
}
|
|
260
269
|
|
|
270
|
+
export async function applyReset(lock,prepared,approval,registry,options={}) {
|
|
271
|
+
const copy=await verifyResetApproval(lock,prepared,approval,registry);
|
|
272
|
+
const previous=(await readState(resolveChild(lock.workspace,'.pipeline/state.json'))).value;
|
|
273
|
+
await preflightPreview(lock,copy.preview,previous,copy.stateFileHash);
|
|
274
|
+
const active=previous.active,source=active.snapshot;
|
|
275
|
+
const snapshot={snapshotPath:resolveChild(lock.workspace,source.path),manifest:{id:active.pipelineId,version:active.version},
|
|
276
|
+
digest:source.digest,inventoryDigest:source.inventoryDigest};
|
|
277
|
+
const backups=await planBackups(lock,{plan:{desired:active}},previous);
|
|
278
|
+
for(const item of copy.reset.backup.files){
|
|
279
|
+
const observed=copy.preview.observations.find(o=>o.path===item.source);
|
|
280
|
+
if(!observed || observed.hash!==item.hash || observed.bytes===null)fail('reset.backup-binding');
|
|
281
|
+
backups.push({path:item.path,hash:item.hash,existing:false,bytes:Buffer.from(observed.bytes,'base64')});
|
|
282
|
+
}
|
|
283
|
+
backups.push({...copy.reset.backup.manifest,existing:false,bytes:resetBackupBytes(copy.reset.backup)});
|
|
284
|
+
return executeChecked(lock,{prepared:copy,previous,snapshot,backups},approval,
|
|
285
|
+
()=>verifyResetApproval(lock,copy,approval,registry),options);
|
|
286
|
+
}
|
|
287
|
+
|
|
261
288
|
// A new transaction records readback outcomes without changing the old journal.
|
|
262
289
|
// Source/adapter replay was authorized by the original immutable operation;
|
|
263
290
|
// current bytes and exact old evidence require a separate fresh approval here.
|
|
@@ -274,7 +301,7 @@ export async function applyContinuation(lock,prepared,approval,options={}) {
|
|
|
274
301
|
const [{bytes}]=await observeTargets(lock.workspace,[backup.path]);
|
|
275
302
|
backups.push({...backup,bytes,existing:true});
|
|
276
303
|
}
|
|
277
|
-
const deployment=copy.preview.plan.command
|
|
304
|
+
const deployment=['remove','reset'].includes(copy.preview.plan.command)?previous.active:copy.desired;
|
|
278
305
|
const source=copy.preview.plan.source,snapshot={snapshotPath:resolveChild(lock.workspace,source.path),
|
|
279
306
|
manifest:{id:deployment.pipelineId,version:deployment.version},digest:source.digest,inventoryDigest:source.inventoryDigest};
|
|
280
307
|
return executeChecked(lock,{prepared:copy,previous,snapshot,backups},approval,
|
|
@@ -290,20 +317,23 @@ async function verifyContinuationLineage(workspace,prepared,previous) {
|
|
|
290
317
|
guard.visit(evidence.recoveryPath);
|
|
291
318
|
const old=await readRecord(resolveChild(workspace,evidence.recoveryPath));
|
|
292
319
|
if(old.digest!==evidence.recoveryHash)fail('reconciliation.lineage');
|
|
293
|
-
const original=old.value.prepared?.kind==='prepared-removal'?validateRemovalRecord(old.value.prepared,old.value.approval):old.value.prepared?.kind==='prepared-continuation'?validateContinuationRecord(old.value.prepared,old.value.approval):old.value.prepared?.kind==='prepared-repair'?validateRepairRecord(old.value.prepared,old.value.approval):validatePreparedRecord(old.value.prepared,old.value.approval);
|
|
320
|
+
const original=old.value.prepared?.kind==='prepared-reset'?validateResetRecord(old.value.prepared,old.value.approval):old.value.prepared?.kind==='prepared-removal'?validateRemovalRecord(old.value.prepared,old.value.approval):old.value.prepared?.kind==='prepared-continuation'?validateContinuationRecord(old.value.prepared,old.value.approval):old.value.prepared?.kind==='prepared-repair'?validateRepairRecord(old.value.prepared,old.value.approval):validatePreparedRecord(old.value.prepared,old.value.approval);
|
|
321
|
+
if(contractDigest(prepared.reset??null)!==contractDigest(original.reset??null))fail('reconciliation.lineage');
|
|
294
322
|
const plan=original.preview.plan;
|
|
295
323
|
if(prepared.preview.plan.command!==continuationCommand(plan) ||
|
|
296
324
|
contractDigest(prepared.preview.plan.source)!==contractDigest(plan.source))fail('reconciliation.lineage');
|
|
297
325
|
checkPreview(original.preview,old.value.previous,original.preview.observations.map(o=>({path:o.path,bytes:o.bytes===null?null:Buffer.from(o.bytes,'base64')})));
|
|
298
326
|
const beforeSetup=evidence.statePhase==='before';
|
|
299
|
-
|
|
300
|
-
|
|
327
|
+
const beforeReset=beforeSetup && original.kind==='prepared-reset' && plan.command==='reset' && previous?.active &&
|
|
328
|
+
previous.pending===null && contractDigest(previous)===contractDigest(old.value.previous) && original.stateFileHash===evidence.stateHash;
|
|
329
|
+
if(beforeSetup ? (!beforeReset && (previous!==null || old.value.previous!==null || evidence.stateHash!==null ||
|
|
330
|
+
original.kind!=='prepared-plan' || original.stateFileHash!==null || plan.command!=='setup')) :
|
|
301
331
|
(previous?.pending!==contractDigest(plan) || contractDigest(previous?.active)!==contractDigest(old.value.previous?.active??null)))fail('reconciliation.lineage');
|
|
302
332
|
if(contractDigest(prepared.desired)!==contractDigest(plan.desired))fail('reconciliation.lineage');
|
|
303
333
|
const expectedJournal=evidence.recoveryPath.replace('/transactions/','/journals/').replace('/recovery.json','');
|
|
304
334
|
if(evidence.journal!==expectedJournal)fail('reconciliation.lineage');
|
|
305
335
|
const head=await readJournal(workspace,expectedJournal,plan,old.value.previous);
|
|
306
|
-
if(beforeSetup && (head.nextIndex!==0 || head.pending!==null || head.receipt!==null ||
|
|
336
|
+
if(beforeSetup && (head.nextIndex!==0 || head.pending!==null || ((!beforeReset || plan.targets.length>0) && head.receipt!==null) ||
|
|
307
337
|
prepared.actions.some(a=>a.action!=='write-desired' || a.recorded!=='skipped')))fail('reconciliation.lineage');
|
|
308
338
|
if(head.sequence!==evidence.journalHead.sequence || head.lastHash!==evidence.journalHead.hash)fail('reconciliation.lineage');
|
|
309
339
|
if(prepared.stateFileHash!==evidence.stateHash || evidence.workspace!==workspace ||
|
|
@@ -336,8 +366,9 @@ async function executeChecked(lock,checked,approval,recheck,{boundary=async()=>{
|
|
|
336
366
|
let prepared,previous;
|
|
337
367
|
prepared=checked.prepared;previous=checked.previous;approval=structuredClone(approval);
|
|
338
368
|
const plan=prepared.preview.plan,digest=contractDigest(plan);
|
|
339
|
-
const retired=new Set(plan.command==='update'?retiredBundleOwnership(previous,plan.desired)
|
|
340
|
-
|
|
369
|
+
const retired=new Set(plan.command==='update'?[...retiredBundleOwnership(previous,plan.desired),
|
|
370
|
+
...retiredSkillOwnership(previous,plan.desired)].map(o=>o.path):[]);
|
|
371
|
+
if(plan.targets.some(t=>!['create','replace','edit-fields',...((['remove','reset'].includes(plan.command) || retired.has(t.path))?['delete','verify-absent']:[])].includes(t.action)))fail('apply.unsupported-action');
|
|
341
372
|
if(plan.targets.some(t=>t.action==='verify-absent' && !readbackOnly.has(t.path)))fail('apply.unsupported-action');
|
|
342
373
|
const pending={schemaVersion:1,workspace:lock.workspace,status:'needs-reconciliation',runtime:'not-run',
|
|
343
374
|
active:previous?.active??null,pending:digest,...(previous?.activation?{activation:structuredClone(previous.activation)}:{})};
|
|
@@ -358,6 +389,7 @@ async function executeChecked(lock,checked,approval,recheck,{boundary=async()=>{
|
|
|
358
389
|
// No contents, secrets or open file handles are passed to it.
|
|
359
390
|
const io=purpose=>(phase,detail)=>ioBoundary({phase,purpose,...detail});
|
|
360
391
|
await boundary('preflight');
|
|
392
|
+
if(plan.command==='reset')await assertResetTreeScope(lock.workspace,prepared.reset,plan);
|
|
361
393
|
await copySnapshot(lock,checked.snapshot);
|
|
362
394
|
for(const backup of checked.backups)await saveBackup(lock,backup.path,backup.bytes,backup.hash);
|
|
363
395
|
await boundary('backups');
|
|
@@ -398,6 +430,7 @@ async function executeChecked(lock,checked,approval,recheck,{boundary=async()=>{
|
|
|
398
430
|
}
|
|
399
431
|
}
|
|
400
432
|
await boundary('before-active');
|
|
433
|
+
if(plan.command==='reset')await assertResetTreeScope(lock.workspace,prepared.reset,plan,true);
|
|
401
434
|
const final=await readJournal(lock.workspace,journal.relative,plan,previous);
|
|
402
435
|
if(final.receipt?.status!=='completed')fail('apply.journal-incomplete');
|
|
403
436
|
const expected=new Map(prepared.preview.observations.map(o=>[o.path,o.hash]));
|
|
@@ -2,6 +2,7 @@ import { fail } from '../contracts/parse.js';
|
|
|
2
2
|
import { contractDigest } from '../contracts/semantic.js';
|
|
3
3
|
import { sha256 } from '../source/inventory.js';
|
|
4
4
|
import { readConfigField, reconcileConfigFields } from './config-fields.js';
|
|
5
|
+
import {retiredSkillOwnership} from './skill-retirement.js';
|
|
5
6
|
|
|
6
7
|
// Only a member removed from a still-selected bundle may retire during update.
|
|
7
8
|
// Standalone removal and removal of an entire bundle remain explicit operations.
|
|
@@ -14,7 +15,7 @@ export function retiredBundleOwnership(previous, selection) {
|
|
|
14
15
|
return (previous?.active?.owned??[]).filter(o=>owners.has(o.owner));
|
|
15
16
|
}
|
|
16
17
|
export function planObservationPaths(requests, previous, selection) {
|
|
17
|
-
const retired=retiredBundleOwnership(previous,selection);
|
|
18
|
+
const retired=[...retiredBundleOwnership(previous,selection),...retiredSkillOwnership(previous,selection,requests)];
|
|
18
19
|
return [...new Set([...requests.map(r=>r.path),...retired.flatMap(o=>[o.path,...(o.backup?[o.backup]:[])])])].sort();
|
|
19
20
|
}
|
|
20
21
|
export function restoreRetired(entries, observations) {
|
package/src/operations/plan.js
CHANGED
|
@@ -12,6 +12,7 @@ import { acquire } from '../source/git.js';
|
|
|
12
12
|
import { readState, resolveOrigin, resolveApprovedRebind, verifyPreparedSnapshot, observeTargets } from './state.js';
|
|
13
13
|
import { commonEntryPath, commonEntryText, needsCommonEntry } from '../providers/common-entry.js';
|
|
14
14
|
import {planObservationPaths,retiredBundleOwnership,restoreRetired} from './bundle-update.js';
|
|
15
|
+
import {retiredSkillOwnership} from './skill-retirement.js';
|
|
15
16
|
|
|
16
17
|
// Trusted CLI destination policy, not a claim about tested harness discovery.
|
|
17
18
|
const roots = {shared:['AGENTS.md','CLAUDE.md'],codex:['.codex/','.agents/skills/'],
|
|
@@ -193,7 +194,8 @@ export function composePlan({pipeline, workspace, wrapper, previous = null, snap
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
// Removal/provider switch is S7. Never silently abandon existing ownership.
|
|
196
|
-
for(const restored of restoreRetired(retiredBundleOwnership(previous,layout),
|
|
197
|
+
for(const restored of restoreRetired([...retiredBundleOwnership(previous,layout),
|
|
198
|
+
...retiredSkillOwnership(previous,layout,ordered)],observed)) {
|
|
197
199
|
if([...used].some(name=>overlaps(name,restored.path)))fail('plan.overlap');
|
|
198
200
|
// Retirement cannot reach a newly selected repository destination.
|
|
199
201
|
planLayout(pipeline,workspace,wrapper,{adapters,managedPaths:[...used,restored.path]});
|
|
@@ -8,8 +8,10 @@ import {assertLockHeld} from './lock.js';
|
|
|
8
8
|
import {bindPreview} from './plan.js';
|
|
9
9
|
import {assertContinuationCapacity} from './lineage-guard.js';
|
|
10
10
|
import {retiredBundleOwnership} from './bundle-update.js';
|
|
11
|
+
import {retiredSkillOwnership} from './skill-retirement.js';
|
|
12
|
+
import {assertResetTreeScope} from './reset-scope.js';
|
|
11
13
|
|
|
12
|
-
export const continuationCommand=plan=>
|
|
14
|
+
export const continuationCommand=plan=>['remove','reset'].includes(plan.command)?plan.command:
|
|
13
15
|
plan.command==='update' && plan.targets.some(t=>t.desiredHash===null)?'update':'repair';
|
|
14
16
|
|
|
15
17
|
// Current evidence and decisions still required, never a runnable retry/rollback.
|
|
@@ -42,13 +44,17 @@ export async function prepareContinuation(workspace,recoveryPath) {
|
|
|
42
44
|
if(record.digest!==evidence.recoveryHash)fail('recovery.observation-drift');
|
|
43
45
|
const previousPlan=record.value.prepared.preview,actions=[];
|
|
44
46
|
const beforeSetup=evidence.statePhase==='before';
|
|
45
|
-
|
|
47
|
+
const beforeReset=beforeSetup && record.value.prepared.kind==='prepared-reset' && previousPlan.plan.command==='reset' &&
|
|
48
|
+
record.value.previous?.active && record.value.previous.pending===null && record.value.prepared.stateFileHash===evidence.stateHash;
|
|
49
|
+
if(beforeSetup && !beforeReset && (evidence.stateHash!==null || record.value.previous!==null ||
|
|
46
50
|
record.value.prepared.kind!=='prepared-plan' || record.value.prepared.stateFileHash!==null ||
|
|
47
51
|
previousPlan.plan.command!=='setup'))fail('reconciliation.not-pending');
|
|
48
|
-
if(beforeSetup && ((await inspectRecovery(workspace,recoveryPath)).receipt!==null ||
|
|
52
|
+
if(beforeSetup && ((!beforeReset || previousPlan.plan.targets.length>0) && (await inspectRecovery(workspace,recoveryPath)).receipt!==null ||
|
|
49
53
|
evidence.targets.some(t=>t.position!=='before' || t.recorded!=='skipped')))fail('reconciliation.conflict');
|
|
50
|
-
const removal=previousPlan.plan.command
|
|
51
|
-
|
|
54
|
+
const removal=['remove','reset'].includes(previousPlan.plan.command);
|
|
55
|
+
if(previousPlan.plan.command==='reset')await assertResetTreeScope(workspace,record.value.prepared.reset,previousPlan.plan);
|
|
56
|
+
const retired=new Set([...retiredBundleOwnership(record.value.previous,previousPlan.plan.desired??{}),
|
|
57
|
+
...(previousPlan.plan.command==='update'?retiredSkillOwnership(record.value.previous,previousPlan.plan.desired):[])].map(o=>o.path));
|
|
52
58
|
assertContinuationCapacity(evidence.lineage.length);
|
|
53
59
|
for(const target of previousPlan.plan.targets) {
|
|
54
60
|
const observed=evidence.targets.find(t=>t.id===target.id);
|
|
@@ -78,6 +84,7 @@ export async function prepareContinuation(workspace,recoveryPath) {
|
|
|
78
84
|
action:continuationTargetAction(a),beforeHash:a.beforeHash,desiredHash:a.desiredHash,fields:[]}))};
|
|
79
85
|
const preview=bindPreview(plan,state?.value??null,{observations,outputs});
|
|
80
86
|
const body={kind:'prepared-continuation',evidence,actions,preview,stateFileHash:evidence.stateHash,
|
|
87
|
+
...(previousPlan.plan.command==='reset'?{reset:structuredClone(record.value.prepared.reset)}:{}),
|
|
81
88
|
dependencies:previousPlan.observations.filter(o=>!actions.some(a=>a.path===o.path)),
|
|
82
89
|
desired:structuredClone(previousPlan.plan.desired),
|
|
83
90
|
applySupported:true,requiresFreshApproval:true,automaticActions:false,runtime:'not-run'};
|
|
@@ -91,13 +98,14 @@ export function continuationTargetAction(action) {
|
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
export function validateContinuationRecord(prepared,approval) {
|
|
94
|
-
requestShape(prepared,['kind','evidence','actions','preview','stateFileHash','dependencies','desired','applySupported','requiresFreshApproval','automaticActions','runtime','digest'],[],'reconciliation.prepared');
|
|
101
|
+
requestShape(prepared,['kind','evidence','actions','preview','stateFileHash','dependencies','desired','applySupported','requiresFreshApproval','automaticActions','runtime','digest'],['reset'],'reconciliation.prepared');
|
|
95
102
|
requestShape(approval,['decision','preparedDigest'],[],'reconciliation.approval');
|
|
96
103
|
const copy=structuredClone(parse(JSON.stringify(prepared),'json')),decision=structuredClone(approval);
|
|
97
104
|
const {digest,...body}=copy;
|
|
98
105
|
if(copy.kind!=='prepared-continuation' || copy.applySupported!==true || copy.requiresFreshApproval!==true ||
|
|
99
106
|
copy.automaticActions!==false || copy.runtime!=='not-run' || contractDigest(body)!==digest)fail('reconciliation.prepared');
|
|
100
107
|
if(decision.decision!=='approve' || decision.preparedDigest!==digest)fail('reconciliation.approval');
|
|
108
|
+
if((copy.preview?.plan?.command==='reset')!==Object.hasOwn(copy,'reset'))fail('reconciliation.prepared');
|
|
101
109
|
return copy;
|
|
102
110
|
}
|
|
103
111
|
|
package/src/operations/repair.js
CHANGED
|
@@ -25,7 +25,7 @@ function at(root,pointer) {
|
|
|
25
25
|
// Read-only inspection, NOT a prepared repair/apply envelope. All requests are
|
|
26
26
|
// replayed from installed bytes by trusted adapters; owned value hashes must
|
|
27
27
|
// match state. User changes become conflicts, never rewritten ownership records.
|
|
28
|
-
async function observeRepair(workspace,registry) {
|
|
28
|
+
export async function observeRepair(workspace,registry) {
|
|
29
29
|
requestShape(registry,['adapters','sharedAdapter'],[],'provider.interface');
|
|
30
30
|
if(!registry.adapters || typeof registry.adapters!=='object' || Array.isArray(registry.adapters) ||
|
|
31
31
|
typeof registry.sharedAdapter?.plan!=='function')fail('provider.interface');
|
|
@@ -96,7 +96,7 @@ async function observeRepair(workspace,registry) {
|
|
|
96
96
|
if(hash(observation.bytes)!==hash(current.get(observation.path)))fail('repair.observation-drift');
|
|
97
97
|
const body={kind:'repair-inspection',workspace,stateHash:record.digest,snapshot:active.snapshot.digest,entries,
|
|
98
98
|
runtime:'not-run',automaticActions:false,applySupported:false,requiresFreshApproval:true};
|
|
99
|
-
return {inspection:{...body,digest:contractDigest(body)},current,desired};
|
|
99
|
+
return {inspection:{...body,digest:contractDigest(body)},current,desired,requests};
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export async function inspectRepair(workspace,registry) {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {opendir,lstat} from 'node:fs/promises';
|
|
2
|
+
import {resolveChild,inspectDirectory} from '../workspace/paths.js';
|
|
3
|
+
import {portablePath,contractDigest} from '../contracts/semantic.js';
|
|
4
|
+
import {fail} from '../contracts/parse.js';
|
|
5
|
+
import {cap,LIMITS,sha256} from '../source/inventory.js';
|
|
6
|
+
import {removedWithProviders} from '../providers/common-entry.js';
|
|
7
|
+
|
|
8
|
+
// Installer policy, never paths supplied by a pipeline package. Entire provider
|
|
9
|
+
// dot-directories, authentication, permissions and models are NOT reset surfaces.
|
|
10
|
+
const surfaces={
|
|
11
|
+
codex:{trees:['.agents/skills','.codex/agents'],fields:{'.codex/config.toml':['/agents','/mcp_servers']}},
|
|
12
|
+
claude:{trees:['.claude/skills','.claude/agents'],fields:{'.mcp.json':['/mcpServers']}},
|
|
13
|
+
grok:{trees:['.grok/skills','.grok/agents'],fields:{'.grok/config.toml':['/mcp_servers']}},
|
|
14
|
+
kimi:{trees:['.kimi-code/skills','.kimi-code/agents'],fields:{'.kimi-code/mcp.json':['/mcpServers']}}
|
|
15
|
+
};
|
|
16
|
+
export function resetScope(active,selection) {
|
|
17
|
+
const trees=[],fields={};
|
|
18
|
+
for(const id of selection.providers){
|
|
19
|
+
if(!Object.hasOwn(surfaces,id))fail('reset.provider');
|
|
20
|
+
trees.push(...surfaces[id].trees);Object.assign(fields,surfaces[id].fields);
|
|
21
|
+
}
|
|
22
|
+
if(active.owned.some(o=>o.kind==='file' && o.owner==='shared' && removedWithProviders(o,selection) && !['AGENTS.md','CLAUDE.md'].includes(o.path)))fail('reset.shared-scope');
|
|
23
|
+
const files=active.owned.filter(o=>o.kind==='file' && ['AGENTS.md','CLAUDE.md'].includes(o.path) && removedWithProviders(o,selection)).map(o=>o.path);
|
|
24
|
+
return {trees:trees.sort(),fields,files:[...new Set(files)].sort()};
|
|
25
|
+
}
|
|
26
|
+
export function scopeOwns(scope,name){
|
|
27
|
+
return scope.files.includes(name) || Object.hasOwn(scope.fields,name) || scope.trees.some(t=>name.startsWith(t+'/'));
|
|
28
|
+
}
|
|
29
|
+
export function scopeOwnsRecord(scope,owned){
|
|
30
|
+
if(!scopeOwns(scope,owned.path))return false;
|
|
31
|
+
return owned.kind==='file'?!Object.hasOwn(scope.fields,owned.path):
|
|
32
|
+
(scope.fields[owned.path]??[]).some(p=>owned.pointer===p || owned.pointer.startsWith(p+'/'));
|
|
33
|
+
}
|
|
34
|
+
export async function scanResetTrees(workspace,scope){
|
|
35
|
+
const files=[],directories=[],seen=new Set();let count=0;
|
|
36
|
+
async function walk(relative){
|
|
37
|
+
cap(++count,LIMITS.files,'reset.count');
|
|
38
|
+
const full=resolveChild(workspace,relative);
|
|
39
|
+
if(!(await inspectDirectory(full)).exists)return;
|
|
40
|
+
directories.push(relative);
|
|
41
|
+
for await(const entry of await opendir(full)){
|
|
42
|
+
cap(++count,LIMITS.files,'reset.count');
|
|
43
|
+
const name=relative+'/'+entry.name;portablePath(name);
|
|
44
|
+
if(['.git','.pipeline'].includes(entry.name.toLowerCase()))fail('reset.protected-tree');
|
|
45
|
+
const folded=name.toLowerCase();if(seen.has(folded))fail('reset.case-alias');seen.add(folded);
|
|
46
|
+
const stat=await lstat(resolveChild(workspace,name));
|
|
47
|
+
if(stat.isSymbolicLink())fail('reset.link');
|
|
48
|
+
if(stat.isDirectory())await walk(name);
|
|
49
|
+
else if(stat.isFile() && stat.nlink===1)files.push(name);
|
|
50
|
+
else fail('reset.file-type');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for(const tree of scope.trees)await walk(tree);
|
|
54
|
+
return {files:files.sort(),directories:directories.sort()};
|
|
55
|
+
}
|
|
56
|
+
// During recovery allow only the approved before/result pathname union. Unknown
|
|
57
|
+
// files require a new decision; reset never expands deletion authority silently.
|
|
58
|
+
export async function assertResetTreeScope(workspace,reset,plan,final=false){
|
|
59
|
+
const actual=await scanResetTrees(workspace,reset.scope);
|
|
60
|
+
const allowed=new Set(reset.tree.files);
|
|
61
|
+
for(const t of plan.targets)if(reset.scope.trees.some(p=>t.path.startsWith(p+'/'))){
|
|
62
|
+
if(t.desiredHash!==null)allowed.add(t.path);else if(final)allowed.delete(t.path);
|
|
63
|
+
}
|
|
64
|
+
if(actual.files.some(p=>!allowed.has(p)) || (final && (actual.files.length!==allowed.size || [...allowed].some(p=>!actual.files.includes(p)))))fail('reset.tree-drift');
|
|
65
|
+
const allowedDirs=new Set(reset.tree.directories);
|
|
66
|
+
for(const name of allowed){
|
|
67
|
+
for(const tree of reset.scope.trees)if(name.startsWith(tree+'/')){
|
|
68
|
+
let parent=name.slice(0,name.lastIndexOf('/'));
|
|
69
|
+
while(parent.length>=tree.length){allowedDirs.add(parent);parent=parent.slice(0,parent.lastIndexOf('/'));}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if(actual.directories.some(p=>!allowedDirs.has(p)))fail('reset.tree-drift');
|
|
73
|
+
}
|
|
74
|
+
export function resetBackupManifest(preview,selection,mode){
|
|
75
|
+
const seed=contractDigest({plan:preview.plan,selection,mode});
|
|
76
|
+
const directory='.pipeline/backups/reset/'+seed.slice(7);
|
|
77
|
+
const backup={directory,files:preview.plan.targets.filter(t=>t.beforeHash!==null).map((t,i)=>({
|
|
78
|
+
source:t.path,path:directory+'/'+i+'.bin',hash:t.beforeHash
|
|
79
|
+
}))};
|
|
80
|
+
return {...backup,manifest:{path:directory+'/manifest.json',hash:sha256(resetBackupBytes(backup))}};
|
|
81
|
+
}
|
|
82
|
+
export const resetBackupBytes=backup=>Buffer.from(JSON.stringify({schemaVersion:1,files:backup.files},null,2)+'\n');
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {observeRepair} from './repair.js';
|
|
2
|
+
import {readState,observeTargets,verifyInstalledSnapshot} from './state.js';
|
|
3
|
+
import {selectBundleRemoval} from './remove.js';
|
|
4
|
+
import {removedWithProviders} from '../providers/common-entry.js';
|
|
5
|
+
import {absoluteRoot,resolveChild} from '../workspace/paths.js';
|
|
6
|
+
import {readConfigField,reconcileConfigFields} from './config-fields.js';
|
|
7
|
+
import {clearResetTOMLSections} from './toml-fields.js';
|
|
8
|
+
import {requestShape} from './ownership.js';
|
|
9
|
+
import {bindPreview} from './plan.js';
|
|
10
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
11
|
+
import {fail,parse} from '../contracts/parse.js';
|
|
12
|
+
import {sha256} from '../source/inventory.js';
|
|
13
|
+
import {assertLockHeld} from './lock.js';
|
|
14
|
+
import {resetScope,scopeOwns,scopeOwnsRecord,scanResetTrees,resetBackupManifest} from './reset-scope.js';
|
|
15
|
+
|
|
16
|
+
const hash=b=>b===null?null:sha256(b);
|
|
17
|
+
const same=(a,b)=>contractDigest(a)===contractDigest(b);
|
|
18
|
+
export function resetOptions(options){
|
|
19
|
+
requestShape(options,[],['to','all','providers','bundles'],'reset.options');
|
|
20
|
+
const to=options.to??'installed';if(!['installed','empty'].includes(to))fail('reset.mode');
|
|
21
|
+
if(options.all!==undefined && options.all!==true)fail('reset.selection');
|
|
22
|
+
if(options.all?(options.providers!==undefined || options.bundles!==undefined):
|
|
23
|
+
!(options.providers?.length || options.bundles?.length))fail('reset.selection');
|
|
24
|
+
return {to,...options.all?{all:true}:{...(options.providers?{providers:[...options.providers]}:{}),...(options.bundles?{bundles:[...options.bundles]}:{})}};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function prepareReset(workspace,registry,options){
|
|
28
|
+
const selectionRequest=resetOptions(options);workspace=absoluteRoot(workspace);
|
|
29
|
+
const {inspection,requests}=await observeRepair(workspace,registry);
|
|
30
|
+
const record=await readState(resolveChild(workspace,'.pipeline/state.json'));
|
|
31
|
+
if(record.digest!==inspection.stateHash)fail('reset.observation-drift');
|
|
32
|
+
const previous=record.value,active=previous.active;
|
|
33
|
+
const selection=selectBundleRemoval(active,selectionRequest.all?{}:selectionRequest);
|
|
34
|
+
const scope=resetScope(active,selection);
|
|
35
|
+
// Protect repository destinations, including absent repositories and docs roots.
|
|
36
|
+
const protectedPaths=[...Object.values(active.layout.repositories).map(r=>r.path)];
|
|
37
|
+
const docs=active.layout.documentation;
|
|
38
|
+
protectedPaths.push(active.layout.repositories[docs.repository].path+'/'+docs.path);
|
|
39
|
+
const overlap=(a,b)=>a.toLowerCase()===b.toLowerCase() || a.toLowerCase().startsWith(b.toLowerCase()+'/') || b.toLowerCase().startsWith(a.toLowerCase()+'/');
|
|
40
|
+
if([...scope.trees,...scope.files,...Object.keys(scope.fields)].some(p=>protectedPaths.some(r=>overlap(p,r))))fail('reset.repository-overlap');
|
|
41
|
+
const tree=await scanResetTrees(workspace,scope);
|
|
42
|
+
const selected=o=>removedWithProviders(o,selection);
|
|
43
|
+
if(active.owned.some(o=>selected(o) && !scopeOwnsRecord(scope,o)))fail('reset.unsupported-owned-scope');
|
|
44
|
+
if(active.owned.some(o=>!selected(o) && scopeOwnsRecord(scope,o)))fail('reset.shared-conflict');
|
|
45
|
+
if(inspection.entries.some(e=>!selected(e) && e.disposition!=='intact'))fail('reset.unselected-drift');
|
|
46
|
+
const names=[...new Set([...tree.files,...scope.files,...Object.keys(scope.fields),...active.owned.map(o=>o.path),
|
|
47
|
+
...requests.filter(selected).map(r=>r.path)])].sort();
|
|
48
|
+
const observations=await observeTargets(workspace,names),current=new Map(observations.map(o=>[o.path,o.bytes]));
|
|
49
|
+
const desiredBytes=new Map(),owned=active.owned.filter(o=>!selected(o)).map(o=>structuredClone(o));
|
|
50
|
+
for(const name of [...tree.files,...scope.files])desiredBytes.set(name,null);
|
|
51
|
+
// Remove only known provider sections; model/auth/permissions and foreign
|
|
52
|
+
// sections survive. Malformed documents fail rather than getting wiped whole.
|
|
53
|
+
for(const [name,pointers] of Object.entries(scope.fields)){
|
|
54
|
+
const before=current.get(name);let bytes=before;
|
|
55
|
+
if(name.endsWith('.toml')){
|
|
56
|
+
desiredBytes.set(name,clearResetTOMLSections(before,pointers.map(p=>p.slice(1))));continue;
|
|
57
|
+
}
|
|
58
|
+
for(const pointer of pointers){
|
|
59
|
+
const found=readConfigField(name,bytes,pointer);
|
|
60
|
+
if(found.present)bytes=reconcileConfigFields(name,bytes,[{pointer,present:false,managedHash:contractDigest(found.value)}]).bytes;
|
|
61
|
+
}
|
|
62
|
+
desiredBytes.set(name,bytes);
|
|
63
|
+
}
|
|
64
|
+
if(selectionRequest.to==='installed')for(const request of requests.filter(selected)){
|
|
65
|
+
if(!scopeOwns(scope,request.path))fail('reset.unsupported-owned-scope');
|
|
66
|
+
if(request.kind==='file'){
|
|
67
|
+
desiredBytes.set(request.path,request.bytes);
|
|
68
|
+
owned.push({path:request.path,owner:request.owner,kind:'file',pointer:null,beforeHash:null,managedHash:sha256(request.bytes),backup:null});
|
|
69
|
+
}else{
|
|
70
|
+
const fields=request.fields.map(f=>f.present?{pointer:f.pointer,present:true,value:f.value}:{pointer:f.pointer,present:false});
|
|
71
|
+
if(fields.some(f=>!(scope.fields[request.path]??[]).some(p=>f.pointer===p || f.pointer.startsWith(p+'/'))))fail('reset.unsupported-owned-scope');
|
|
72
|
+
const result=reconcileConfigFields(request.path,desiredBytes.get(request.path)??null,fields);
|
|
73
|
+
desiredBytes.set(request.path,result.bytes);
|
|
74
|
+
for(const f of result.decisions)if(f.desiredHash!==null)owned.push({path:request.path,owner:request.owner,kind:'field',pointer:f.pointer,beforeHash:null,managedHash:f.desiredHash,backup:null});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const targets=[],outputs=[];
|
|
78
|
+
for(const [name,bytes] of [...desiredBytes].sort(([a],[b])=>a.localeCompare(b))){
|
|
79
|
+
const before=current.get(name)??null;if(hash(before)===hash(bytes))continue;
|
|
80
|
+
const owner=active.owned.find(o=>o.path===name && selected(o))?.owner??selection.providers.find(id=>scopeOwns(resetScope(active,{providers:[id],remaining:active.providers.filter(p=>p!==id),owners:[id]}),name));
|
|
81
|
+
if(!owner)fail('reset.owner');
|
|
82
|
+
targets.push({id:'target-'+(targets.length+1),path:name,owner,action:bytes===null?'delete':before===null?'create':'replace',
|
|
83
|
+
beforeHash:hash(before),desiredHash:hash(bytes),fields:[]});
|
|
84
|
+
if(bytes!==null)outputs.push({path:name,bytes});
|
|
85
|
+
}
|
|
86
|
+
let desired=structuredClone(active);
|
|
87
|
+
if(selectionRequest.to==='empty'){
|
|
88
|
+
desired.providers=selection.remaining;
|
|
89
|
+
desired.adapterVersions=Object.fromEntries(selection.remaining.map(p=>[p,active.adapterVersions[p]]));
|
|
90
|
+
if(desired.bundles){desired.bundles=Object.fromEntries(Object.entries(desired.bundles).filter(([id])=>!selection.bundles.includes(id)));if(!Object.keys(desired.bundles).length)delete desired.bundles;}
|
|
91
|
+
if(!selection.remaining.length)desired=null;
|
|
92
|
+
}
|
|
93
|
+
if(desired){desired.owned=owned;desired.id='deployment-'+contractDigest({resetFrom:record.digest,deployment:{...desired,id:null}}).slice(7);}
|
|
94
|
+
const preview=bindPreview({schemaVersion:1,kind:'plan',command:'reset',workspace,beforeStateHash:contractDigest(previous),
|
|
95
|
+
source:structuredClone(active.snapshot),desired,targets},previous,{observations,outputs});
|
|
96
|
+
const backup=resetBackupManifest(preview,selectionRequest,selectionRequest.to);
|
|
97
|
+
const body={kind:'prepared-reset',preview,stateFileHash:record.digest,reset:{selection:selectionRequest,scope,tree,backup},
|
|
98
|
+
applySupported:true,requiresFreshApproval:true,automaticActions:false,runtime:'not-run'};
|
|
99
|
+
if(!same(tree,await scanResetTrees(workspace,scope)))fail('reset.tree-drift');
|
|
100
|
+
for(const fresh of await observeTargets(workspace,names))if(hash(fresh.bytes)!==hash(current.get(fresh.path)))fail('reset.observation-drift');
|
|
101
|
+
await verifyInstalledSnapshot(previous);
|
|
102
|
+
if((await readState(resolveChild(workspace,'.pipeline/state.json'))).digest!==record.digest)fail('reset.observation-drift');
|
|
103
|
+
parse(JSON.stringify(body),'json');return {...body,digest:contractDigest(body)};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function validateResetRecord(prepared,approval){
|
|
107
|
+
requestShape(prepared,['kind','preview','stateFileHash','reset','applySupported','requiresFreshApproval','automaticActions','runtime','digest'],[],'reset.prepared');
|
|
108
|
+
requestShape(approval,['decision','preparedDigest'],[],'reset.approval');
|
|
109
|
+
const copy=parse(JSON.stringify(prepared),'json'),{digest,...body}=copy;
|
|
110
|
+
if(copy.kind!=='prepared-reset' || copy.preview?.plan?.command!=='reset' || copy.runtime!=='not-run' ||
|
|
111
|
+
copy.applySupported!==true || copy.requiresFreshApproval!==true || copy.automaticActions!==false || contractDigest(body)!==digest)fail('reset.prepared');
|
|
112
|
+
if(approval.decision!=='approve' || approval.preparedDigest!==digest)fail('reset.approval');
|
|
113
|
+
validateResetContext(copy.reset,copy.preview);
|
|
114
|
+
return structuredClone(copy);
|
|
115
|
+
}
|
|
116
|
+
export function validateResetContext(reset,preview){
|
|
117
|
+
requestShape(reset,['selection','scope','tree','backup'],[],'reset.context');
|
|
118
|
+
resetOptions(reset.selection);
|
|
119
|
+
if(!same(reset.backup,resetBackupManifest(preview,reset.selection,reset.selection.to)))fail('reset.backup-binding');
|
|
120
|
+
}
|
|
121
|
+
export async function verifyResetApproval(lock,prepared,approval,registry){
|
|
122
|
+
const copy=validateResetRecord(prepared,approval);await assertLockHeld(lock);
|
|
123
|
+
if(copy.preview.plan.workspace!==lock.workspace)fail('reset.workspace');
|
|
124
|
+
const fresh=await prepareReset(lock.workspace,registry,copy.reset.selection);
|
|
125
|
+
if(!same(fresh,copy))fail('reset.plan-drift');
|
|
126
|
+
await assertLockHeld(lock);return fresh;
|
|
127
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Only native skill routing files of still-selected providers are eligible.
|
|
2
|
+
// No config, root instruction, agent, unknown file or removed provider cleanup.
|
|
3
|
+
// Eligibility alone is not deletion authority: replay, before hashes and preview
|
|
4
|
+
// approval remain mandatory in the plan/apply lifecycle.
|
|
5
|
+
const roots={codex:'.agents',claude:'.claude',grok:'.grok',kimi:'.kimi-code'};
|
|
6
|
+
export function retiredSkillOwnership(previous,selection,requests=selection.owned??[]) {
|
|
7
|
+
const paths=new Set(requests.map(r=>r.path));
|
|
8
|
+
return (previous?.active?.owned??[]).filter(o=>{
|
|
9
|
+
const root=roots[o.owner],prefix=root+'/skills/';
|
|
10
|
+
if(!root || o.kind!=='file' || !selection.providers.includes(o.owner) ||
|
|
11
|
+
paths.has(o.path) || !o.path.startsWith(prefix))return false;
|
|
12
|
+
return /^[a-z][a-z0-9-]{0,62}\/SKILL\.md$/.test(o.path.slice(prefix.length));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -4,14 +4,16 @@ import {fail, MAX_INPUT_BYTES} from '../contracts/parse.js';
|
|
|
4
4
|
import {utf8} from '../source/inventory.js';
|
|
5
5
|
import {reconcileFields,requestShape} from './ownership.js';
|
|
6
6
|
|
|
7
|
+
export const AGENT_CONTROL_KEYS=Object.freeze(['enabled','max_threads','max_depth','max_concurrent_threads_per_session',
|
|
8
|
+
'default_subagent_model','default_subagent_reasoning_effort','job_max_runtime_seconds','interrupt_message']);
|
|
9
|
+
|
|
7
10
|
// Only complete named Codex agent/MCP entries. No model, permission, auth or
|
|
8
11
|
// trust setting may be addressed through this editor. No I/O or authorization.
|
|
9
12
|
function parts(pointer) {
|
|
10
13
|
if(typeof pointer!=='string' || !/^\/(agents|mcp_servers)\/[a-z][a-z0-9_-]{0,99}$/.test(pointer))fail('toml.scope');
|
|
11
14
|
const result=pointer.slice(1).split('/');
|
|
12
15
|
if(['__proto__','prototype','constructor'].includes(result[1]))fail('toml.scope');
|
|
13
|
-
if(result[0]==='agents' && ['
|
|
14
|
-
'default_subagent_model','default_subagent_reasoning_effort','job_max_runtime_seconds','interrupt_message'].includes(result[1]))fail('toml.scope');
|
|
16
|
+
if(result[0]==='agents' && AGENT_CONTROL_KEYS.includes(result[1]))fail('toml.scope');
|
|
15
17
|
return result;
|
|
16
18
|
}
|
|
17
19
|
const prefix=(a,b)=>a.length<=b.length && a.every((x,i)=>x===b[i]);
|
|
@@ -94,6 +96,37 @@ function normalizeEmptyRoots(value) {
|
|
|
94
96
|
return value;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// Reset-only editor: never widen the ordinary ownership editor's pointer scope.
|
|
100
|
+
// Keep agent runtime/model controls; clear named definitions and MCP declarations.
|
|
101
|
+
export function clearResetTOMLSections(bytes,roots) {
|
|
102
|
+
if(!Array.isArray(roots) || roots.some(r=>!['agents','mcp_servers'].includes(r)))fail('toml.scope');
|
|
103
|
+
if(bytes===null)return null;
|
|
104
|
+
const before=document(bytes),expected=structuredClone(before.value),ranges=[];
|
|
105
|
+
for(const root of roots){
|
|
106
|
+
if(!Object.hasOwn(before.value,root))continue;
|
|
107
|
+
if(root==='agents'){
|
|
108
|
+
const entries=before.value[root];
|
|
109
|
+
if(!entries || typeof entries!=='object' || Array.isArray(entries) || entries instanceof Date)fail('toml.ancestor');
|
|
110
|
+
for(const name of Object.keys(entries).filter(n=>!AGENT_CONTROL_KEYS.includes(n))){
|
|
111
|
+
// Unknown non-table settings are not named agent definitions. Preserve
|
|
112
|
+
// their original bytes rather than guessing future harness controls.
|
|
113
|
+
const entry=entries[name];
|
|
114
|
+
if(!entry || typeof entry!=='object' || Array.isArray(entry) || entry instanceof Date)continue;
|
|
115
|
+
ranges.push(...deletionRanges(before.ast,[root,name]));delete expected[root][name];
|
|
116
|
+
}
|
|
117
|
+
}else{ranges.push(...deletionRanges(before.ast,[root]));delete expected[root];}
|
|
118
|
+
}
|
|
119
|
+
let result=before.text;
|
|
120
|
+
const ordered=ranges.map(([s,e])=>[s,e+(before.text.startsWith('\r\n',e)?2:before.text[e]==='\n'?1:0)]).sort((a,b)=>b[0]-a[0]);
|
|
121
|
+
for(let i=0;i<ordered.length;i++){
|
|
122
|
+
const [s,e]=ordered[i];if(i && e>ordered[i-1][0])fail('toml.overlap');
|
|
123
|
+
result=result.slice(0,s)+result.slice(e);
|
|
124
|
+
}
|
|
125
|
+
const output=Buffer.from(result);
|
|
126
|
+
if(!isDeepStrictEqual(normalizeEmptyRoots(document(output).value),normalizeEmptyRoots(expected)))fail('toml.postcondition');
|
|
127
|
+
return output;
|
|
128
|
+
}
|
|
129
|
+
|
|
97
130
|
export function reconcileTOMLFields(bytes,requests) {
|
|
98
131
|
const before=document(bytes),projected=Object.create(null);
|
|
99
132
|
if(!Array.isArray(requests) || !requests.length || requests.length>1000)fail('toml.requests');
|