@6reduk/workspace-pipeline 0.2.0 → 0.4.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 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
@@ -47,7 +52,9 @@ npm run pack:check
47
52
  npm run test:packed:providers
48
53
  ```
49
54
 
50
- The executable name is `workspace-pipeline`. Doctor prints JSON: exit 0 means
55
+ The executable name is `workspace-pipeline`. Output is readable by default;
56
+ add `--json` for complete machine output. Use `--json` when saving any preview
57
+ for a later apply, even when redirecting stdout to a file. Doctor exit 0 means
51
58
  observed configuration ready, 1 means not ready/incomplete, 2 means invocation or
52
59
  transport error. Unsupported commands exit with code 2. On Windows use an absolute
53
60
  path such as `C:\Projects\my-workspace`. See [doctor](docs/doctor.md) for limits.
package/docs/doctor.md CHANGED
@@ -5,7 +5,8 @@ The public CLI exposes it read-only:
5
5
 
6
6
  `workspace-pipeline doctor --workspace <absolute-path> [--recovery <relative-record>] [--json]`
7
7
 
8
- Output is JSON even without --json. Exit 0 means configuration ready, 1 not ready
8
+ Output is a readable status summary by default. `--json` returns the complete
9
+ machine report (including every target/hash). Exit 0 means configuration ready, 1 not ready
9
10
  or incomplete, 2 invalid invocation or output failure. This is not an install command.
10
11
 
11
12
  `ready` means observed **configuration readiness**, never a running harness,
package/docs/launch.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Scoped Grok launch (S11 development)
2
2
 
3
+ ## Configuration limitation (checked 2026-09-23)
4
+
5
+ The desired UX is ordinary `grok` with workspace-local compatibility settings,
6
+ not a mandatory launcher. This is currently blocked by the harness: installed
7
+ Grok 1.0.40 and the upstream configuration reference list only `mcp_servers`,
8
+ `plugins`, `permission`, and `mcp.max_output_bytes` as project config inputs.
9
+ `compat.claude` is not among them. Writing five false values locally must not be
10
+ represented as working import suppression. The CLI does not change user-wide
11
+ compat settings as a substitute. The legacy launch route below remains an
12
+ explicit workaround, not the approved long-term configuration model.
13
+
14
+ Source: [Grok configuration reference](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/26-config-reference.md),
15
+ configuration layers and `compat` fields. No new live-isolation claim is made.
16
+
3
17
  This command starts Grok from an already configured workspace with five Claude
4
18
  compatibility switches set to false in the child environment: skills, rules, agents,
5
19
  MCP and hooks. It does not install a pipeline. Configure Grok using the ordinary
@@ -23,12 +23,13 @@ workspace-pipeline setup --workspace <absolute-wrapper> --apply --preview <absol
23
23
  workspace-pipeline update --workspace <absolute-wrapper> --apply --preview <absolute-json-file>
24
24
  ```
25
25
 
26
- Preview returns the complete prepared JSON on stdout. It may acquire a Git
26
+ Preview displays a human-readable summary by default. Add `--json` to return
27
+ the complete prepared JSON on stdout for saving and subsequent apply. It may acquire a Git
27
28
  source into temporary storage outside the wrapper, but does not write provider
28
29
  configuration. Network acquisition requires explicit `--network`. When manifest
29
30
  is omitted, setup uses the standard workspace manifest; update uses the recorded
30
- manifest origin. Relocation does not silently rebind it. The separate source
31
- rebind workflow is not exposed by these commands yet.
31
+ manifest origin. Relocation does not silently rebind it. Use the explicit
32
+ [manifest rebind workflow](rebind.md) to change that origin.
32
33
 
33
34
  Save prepared JSON privately, inspect its operations and then explicitly invoke
34
35
  `--apply --preview`. It may contain configuration secrets and absolute local
@@ -42,9 +43,33 @@ existing locked approval, registry, ownership, history, snapshot and drift check
42
43
  from preview generation. Existing local opt-in retention is handled by the
43
44
  internal lifecycle coordinator, not a second cleanup pass in the dispatcher.
44
45
 
46
+ ### Renamed or removed skills during update
47
+
48
+ For a provider that remains selected, update retires an old managed
49
+ `skills/<name>/SKILL.md` when the new source no longer supplies that path.
50
+ The exact deletion appears in the preview; if installation took over an existing
51
+ file, update restores its verified original backup instead. This supports names
52
+ such as `unity-review` changing to `sdx-review` without leaving duplicate skills.
53
+
54
+ Only recorded, unchanged native skill entry files qualify. A user edit before
55
+ preview or after approval blocks the operation. User-added files (including notes
56
+ beside a retired skill), other skills, agent definitions, configuration files and
57
+ root instructions are not cleaned up by this rule. Directories are not recursively
58
+ deleted. Provider/bundle removal remains a separate selection operation, except
59
+ for the existing bundle-member update behavior.
60
+
61
+ This is not a reset or a force-update option. Missing owned files should be
62
+ repaired from the installed snapshot first; conflicting local customizations
63
+ must not be silently discarded. Rebind only approves a new manifest origin;
64
+ it does not relax these ownership checks.
65
+
66
+ To deliberately discard local adapter customizations, use the separate
67
+ [reset preview/backup/apply workflow](reset.md). Its default is installed-snapshot
68
+ restoration; empty reset must be explicitly selected.
69
+
45
70
  ## Output and failures
46
71
 
47
- - stdout: one prepared JSON or operation result.
72
+ - stdout: readable result by default; with `--json`, one complete prepared JSON or operation result.
48
73
  - stderr during apply: structured journal/recovery locations and operation events.
49
74
  - exit 0: preview created, or observed configuration ready with successful lock
50
75
  release and reporting. It never certifies harness/MCP runtime.
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' --json > 'C:\Private\rebind.json'
19
+ workspace-pipeline update --workspace 'C:\Work\Game' --accept-rebind 'C:\Private\rebind.json' --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.
@@ -27,7 +27,7 @@ commit, not uncommitted files of its source.
27
27
  workspace-pipeline wrap --workspace "C:\Work\GameWorkspace" --manifest "C:\Work\workspace.json" --choices "C:\Work\choices.json"
28
28
  ```
29
29
 
30
- Save the exact JSON stdout as UTF-8 `preview.json` outside the affected repository
30
+ Use `--json` and save the exact JSON stdout as UTF-8 `preview.json` outside the affected repository
31
31
  and wrapper. Read its operations and blockers. Preview may acquire a temporary
32
32
  Git snapshot, but does not perform repository effects. Remote access is explicit
33
33
  with `--network`; local Git does not need that flag. Do not put credentials into
@@ -39,8 +39,8 @@ delete pending markers or lock directories to make `doctor` report success.
39
39
 
40
40
  `no-pending-marker` means exactly that; it does not certify historical completion,
41
41
  provider readiness or game runtime. A blocker means further recovery is needed;
42
- this command never grants itself authority to resolve it. `--json` is redundant
43
- but accepted because JSON is already the default output.
42
+ this command never grants itself authority to resolve it. Add `--json` for
43
+ machine-readable output and whenever saving a preview for apply.
44
44
 
45
45
  ## Local recovery ownership
46
46
 
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 --json > '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/docs/retention.md CHANGED
@@ -75,7 +75,8 @@ check has cost proportional to selected files times retained records, bounded by
75
75
  scan limits and the user deletion cap. It is not a linear-cost guarantee or an
76
76
  OS-wide defense against hostile concurrent filesystem writers.
77
77
 
78
- Progress is emitted as JSON on stderr; final JSON is on stdout. The receipt path
78
+ With `--json`, progress is emitted as JSON on stderr and final JSON on stdout;
79
+ otherwise both are formatted for reading. Save apply previews using `--json`. The receipt path
79
80
  is under `.pipeline/cleanup/<run-id>.json`. Receipts record selected file hashes,
80
81
  confirmed deletions, reclaimed bytes, current intent and failures. These receipts
81
82
  describe attempted/deleted paths; unlike operational recovery records, they are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@6reduk/workspace-pipeline",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Workspace-local Git pipeline configuration dispatcher",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "update",
24
24
  "repair",
25
25
  "remove",
26
+ "reset",
26
27
  "switch"
27
28
  ]
28
29
  },
package/src/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {runCli} from './commands/dispatch.js';
3
3
  import {providerRegistry} from './providers/registry.js';
4
+ import {outputWriter} from './commands/output.js';
4
5
  const write=stream=>text=>new Promise((resolve,reject)=>stream.write(text,error=>error?reject(error):resolve()));
5
6
  // Prevent an unhandled pipe error; write callbacks report delivery failures.
6
7
  process.stdout.on('error',()=>{});process.stderr.on('error',()=>{});
7
- process.exitCode=await runCli(process.argv.slice(2),{stdout:write(process.stdout),stderr:write(process.stderr),registry:providerRegistry});
8
+ const args=process.argv.slice(2),json=args.includes('--json');
9
+ process.exitCode=await runCli(args,{stdout:outputWriter(write(process.stdout),json),stderr:outputWriter(write(process.stderr),json),registry:providerRegistry});
@@ -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]
@@ -55,7 +62,9 @@ Preview stages Git externally; inspect is read-only/offline. JSON can contain
55
62
  private config bytes. Only explicit apply writes or clears a validated pending marker.
56
63
  Apply requires the saved installer-bound envelope and accepts no source overrides.
57
64
 
58
- Doctor is read-only and prints JSON. Exit 0 means observed configuration ready;
65
+ Output is human-readable by default; use --json for complete machine output.
66
+ Save previews for apply with --json (including when redirecting stdout).
67
+ Doctor is read-only. Exit 0 means observed configuration ready;
59
68
  exit 1 means not ready/incomplete; exit 2 means invalid invocation or unavailable command.
60
69
  Runtime, MCP/harness discovery and provider compatibility are NOT verified.
61
70
  No source access, automatic repair or lock removal. Read-only/configuration commands never clean history.
@@ -79,6 +88,11 @@ select a new source/manifest or download a replacement for missing staged data.
79
88
  No native plugins are installed. Source packages cannot supply executable adapters.
80
89
  Repair/remove are offline installed-snapshot operations. --providers and --bundles are remove-only,
81
90
  preview-only; omission previews full removal. No automatic history cleanup on these commands.
91
+ Reset is separate and destructive within its explicit local configuration scope,
92
+ including user additions: default --to installed, --to empty explicit. Selection
93
+ is mandatory. Preview first; apply backs up affected bytes before target writes.
94
+ No repository/global/auth/model/permissions cleanup. Malformed configuration fails
95
+ closed. Recovery uses the existing continue command; backups remain local/private.
82
96
  Switch requires an explicit incoming manifest in preview, preserves two separate
83
97
  phases and activates only after both pass. Failure is not rolled back or retried.
84
98
  Continue creates a new approved operation from pending evidence; it does not replay old journals.
@@ -122,15 +136,17 @@ logs clean --repositories --workspace <absolute-directory> --max-age-days <N>
122
136
  Apply with --repositories --apply --preview <file>; this is a separate cleanup
123
137
  domain, never an implicit increase of the ordinary journal deletion budget.
124
138
  Execution inputs are retained privately under .pipeline/repository-inputs;
125
- --json is optional because JSON is the default.
139
+ --json selects the complete machine-readable result without presentation changes.
126
140
  This package is not ready to replace an existing installation.`;
127
141
 
128
142
  // Parse strictly before any filesystem observation. Never echo unknown arguments
129
143
  // (which can contain credentials). Output transport is trusted CLI code.
130
144
  export function parseCommand(args) {
131
145
  if(!Array.isArray(args) || args.some(a=>typeof a!=='string')) fail('cli.arguments');
132
- if(args.length===0 || (args.length===1 && ['--help','-h'].includes(args[0])))return {command:'help'};
146
+ if(args.length===0 || (['--help','-h'].includes(args[0]) && (args.length===1 || (args.length===2 && args[1]==='--json'))))return {command:'help'};
133
147
  if(args[0]==='launch')return parseLaunch(args);
148
+ if(args[0]==='rebind')return parseRebind(args);
149
+ if(args[0]==='reset')return parseReset(args);
134
150
  if(['setup','update','repair','remove','switch','continue'].includes(args[0]))return parseLifecycle(args);
135
151
  if(args[0]==='migration')return parseMigrationCommand(args);
136
152
  if(['init','adopt','wrap'].includes(args[0]))return parseRepositories(args);
@@ -214,7 +230,7 @@ function parseLifecycle(args) {
214
230
  const result={command:args[0]},seen=new Set(),maintenance=['repair','remove'].includes(args[0]);
215
231
  for(let i=1;i<args.length;i++) {
216
232
  const flag=args[i];
217
- const allowed=['--workspace','--apply','--preview','--json',...(result.command==='continue'?['--recovery']:maintenance?result.command==='remove'?['--providers','--bundles']:[]:['--manifest','--network'])];
233
+ const allowed=['--workspace','--apply','--preview','--json',...(result.command==='update'?['--accept-rebind']:[]),...(result.command==='continue'?['--recovery']:maintenance?result.command==='remove'?['--providers','--bundles']:[]:['--manifest','--network'])];
218
234
  if(!allowed.includes(flag) || seen.has(flag))fail('cli.arguments');
219
235
  seen.add(flag);
220
236
  if(flag==='--json')continue;
@@ -235,9 +251,10 @@ function parseLifecycle(args) {
235
251
  if(providers.some(p=>!['codex','claude','kimi','grok'].includes(p)) || new Set(providers).size!==providers.length)fail('cli.arguments');
236
252
  result.providers=providers.sort();continue;
237
253
  }
238
- result[{'--workspace':'workspace','--manifest':'manifestPath','--preview':'previewFile'}[flag]]=absoluteRoot(value);
254
+ result[{'--workspace':'workspace','--manifest':'manifestPath','--preview':'previewFile','--accept-rebind':'rebindFile'}[flag]]=absoluteRoot(value);
239
255
  }
240
256
  if(!result.workspace)fail('cli.workspace-required');
257
+ if(result.rebindFile && (result.apply || result.manifestPath))fail('cli.arguments');
241
258
  if(result.apply?(!result.previewFile || result.manifestPath || result.network || result.providers || result.bundles):result.previewFile)fail('cli.arguments');
242
259
  if(result.command==='switch' && !result.apply && !result.manifestPath)fail('switch.manifest-required');
243
260
  if(result.command==='continue' && (result.apply?result.recoveryPath:!result.recoveryPath))fail('continuation.recovery-required');
@@ -259,6 +276,7 @@ async function runLifecycle(command,registry,stdout,stderr) {
259
276
  if(command.providers)input.providers=command.providers;
260
277
  if(command.bundles)input.bundles=command.bundles;
261
278
  if(command.recoveryPath)input.recoveryPath=command.recoveryPath;
279
+ if(command.rebindFile)Object.assign(input,await acceptedRebind(command.workspace,command.rebindFile));
262
280
  await stdout(JSON.stringify(await prepare(input,registry))+'\n');return 0;
263
281
  }
264
282
  const prepared=(await readRecord(command.previewFile)).value;
@@ -374,6 +392,8 @@ export async function runCli(args,{stdout,stderr,registry=null}) {
374
392
  const command=parseCommand(args);
375
393
  if(command.command==='help') {await stdout(help+'\n');return 0;}
376
394
  if(command.command==='launch')return await runLaunch(command,stdout,stderr);
395
+ if(command.command==='rebind')return await runRebind(command,stdout);
396
+ if(command.command==='reset')return await runReset(command,registry,stdout,stderr);
377
397
  if(command.command==='migration')return await runMigrationCommand(command,stdout,stderr);
378
398
  if(command.command==='logs')return await runLogs(command,stdout,stderr);
379
399
  if(command.command==='policy')return await runPolicy(command,stdout);
@@ -11,8 +11,9 @@ export function parseLaunch(args) {
11
11
  const result = { command: 'launch', provider: 'grok', execute: false, inspect: false }, seen = new Set();
12
12
  for (let i = 2; i < args.length; i++) {
13
13
  const flag = args[i];
14
- if (!['--workspace', '--executable', '--execute', '--inspect'].includes(flag) || seen.has(flag)) fail('cli.arguments');
14
+ if (!['--workspace', '--executable', '--execute', '--inspect', '--json'].includes(flag) || seen.has(flag)) fail('cli.arguments');
15
15
  seen.add(flag);
16
+ if (flag === '--json') continue;
16
17
  if (flag === '--execute') { result.execute = true; continue; }
17
18
  if (flag === '--inspect') { result.inspect = true; continue; }
18
19
  const value = args[++i];
@@ -0,0 +1,46 @@
1
+ // Presentation boundary only: internal command results and saved JSON contracts
2
+ // remain unchanged. Never infer success from the shape of a rendered result.
3
+ const safe = value => String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
4
+ const privateKey = /bytes|base64|content|secret|token|password/i;
5
+
6
+ export function formatResult(value) {
7
+ if (value && typeof value.ready === 'boolean' && Array.isArray(value.diagnostics)) {
8
+ const lines = ['Workspace Pipeline — doctor', '',
9
+ `Workspace: ${safe(value.workspace ?? '(not reported)')}`,
10
+ `Status: ${value.ready ? 'READY' : 'NOT READY'} (${safe(value.status ?? 'unknown')})`];
11
+ if (value.pipeline) lines.push(`Pipeline: ${safe(value.pipeline.id)} @ ${safe(value.pipeline.version)}`,
12
+ `Providers: ${value.pipeline.providers.map(safe).join(', ')}`);
13
+ for (const key of ['configuration', 'transactionEvidence']) if (value[key] !== undefined) lines.push(`${key}: ${safe(value[key])}`);
14
+ lines.push('', `Diagnostics: ${value.diagnostics.length}`);
15
+ for (const item of value.diagnostics) lines.push(` - ${safe(item.code)}${item.subject ? ': ' + safe(item.subject) : ''}${item.pointer ? ' ' + safe(item.pointer) : ''}`);
16
+ lines.push('', 'Runtime / model-visible skills / MCP: not verified by doctor.',
17
+ 'Full report: repeat with --json.');
18
+ return lines.join('\n') + '\n';
19
+ }
20
+ const lines = ['Workspace Pipeline', ''];
21
+ function render(node, indent = '', depth = 0) {
22
+ if (node === null || typeof node !== 'object') { lines.push(indent + safe(node)); return; }
23
+ if (depth > 5) { lines.push(indent + '(details omitted; use --json)'); return; }
24
+ const entries = Object.entries(node);
25
+ for (const [key, item] of entries.slice(0, 40)) {
26
+ const label = indent + safe(key) + ':';
27
+ if (privateKey.test(key)) lines.push(label + ' (private payload; use --json)');
28
+ else if (item !== null && typeof item === 'object') { lines.push(label); render(item, indent + ' ', depth + 1); }
29
+ else { const text=safe(item); lines.push(label + ' ' + (text.length>500?text.slice(0,500)+' (truncated; use --json)':text)); }
30
+ }
31
+ if (entries.length > 40) lines.push(indent + `(${entries.length - 40} more entries; use --json)`);
32
+ }
33
+ render(value);
34
+ lines.push('', 'For complete machine output or a saved apply preview, repeat with --json.',
35
+ 'This display is not an apply preview. JSON may contain private configuration.');
36
+ return lines.join('\n') + '\n';
37
+ }
38
+
39
+ export function outputWriter(write, json) {
40
+ if (json) return write;
41
+ return text => {
42
+ let value;
43
+ try { value = JSON.parse(text); } catch { return write(text); }
44
+ return write(formatResult(value));
45
+ };
46
+ }
@@ -0,0 +1,49 @@
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','--json'].includes(flag) || seen.has(flag))fail('cli.arguments');
13
+ seen.add(flag);
14
+ if(flag==='--json')continue;
15
+ const value=args[++i];if(!value || value.startsWith('--'))fail('cli.arguments');
16
+ result[flag==='--workspace'?'workspace':'manifestPath']=absoluteRoot(value);
17
+ }
18
+ if(!result.workspace || !result.manifestPath)fail('cli.arguments');
19
+ return result;
20
+ }
21
+
22
+ async function current(wrapper) {
23
+ await assertNoRepositoryPending(wrapper);
24
+ const history=await inspectHistory(wrapper);
25
+ if(!history.complete || history.diagnostics.length)fail('lifecycle.history');
26
+ return (await readState(resolveChild(wrapper,'.pipeline/state.json'))).value;
27
+ }
28
+
29
+ // Read manifests and installed history only. Never acquire Git, update state,
30
+ // move the supplied manifest, or manufacture source/apply approval here.
31
+ export async function runRebind(command,stdout) {
32
+ const previous=await current(command.workspace);
33
+ const proposal=await previewRebind({wrapper:command.workspace,previous,manifestPath:command.manifestPath});
34
+ if(proposal.previousWorkspace!==proposal.workspace)fail('rebind.workspace-move');
35
+ await stdout(JSON.stringify(proposal)+'\n');
36
+ return 0;
37
+ }
38
+
39
+ // Called only for the explicit update --accept-rebind flag. The resulting
40
+ // prepared update still needs a separate --apply --preview approval.
41
+ export async function acceptedRebind(wrapper,filename) {
42
+ const proposal=(await readRecord(filename)).value;
43
+ if(proposal?.kind!=='rebind-preview' || typeof proposal?.proposed?.origin?.path!=='string')fail('rebind.proposal');
44
+ const manifestPath=absoluteRoot(proposal.proposed.origin.path);
45
+ const previous=await current(wrapper);
46
+ const approval={decision:'approve',proposalDigest:contractDigest(proposal)};
47
+ await resolveApprovedRebind({wrapper,previous,manifestPath,proposal,approval});
48
+ return {manifestPath,rebind:{proposal,approval}};
49
+ }
@@ -0,0 +1,64 @@
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','--json'].includes(flag) || seen.has(flag))fail('cli.arguments');
15
+ seen.add(flag);
16
+ if(flag==='--json')continue;
17
+ if(flag==='--apply'){result.apply=true;continue;}
18
+ if(flag==='--all'){(result.options??={}).all=true;continue;}
19
+ const value=args[++i];if(!value || value.startsWith('--'))fail('cli.arguments');
20
+ if(flag==='--workspace')result.workspace=absoluteRoot(value);
21
+ else if(flag==='--preview')result.previewFile=absoluteRoot(value);
22
+ else if(flag==='--to')(result.options??={}).to=value;
23
+ else{
24
+ const ids=value.split(',');
25
+ if(ids.some(id=>!/^[a-z][a-z0-9-]{0,62}$/.test(id)) || new Set(ids).size!==ids.length)fail('cli.arguments');
26
+ (result.options??={})[flag==='--providers'?'providers':'bundles']=ids.sort();
27
+ }
28
+ }
29
+ if(!result.workspace)fail('cli.workspace-required');
30
+ if(result.apply){if(!result.previewFile || result.options)fail('cli.arguments');}
31
+ else {if(result.previewFile)fail('cli.arguments');result.options=resetOptions(result.options??{});}
32
+ return result;
33
+ }
34
+ async function history(workspace){
35
+ await assertNoRepositoryPending(workspace);
36
+ const h=await inspectHistory(workspace);if(!h.complete || h.diagnostics.length)fail('reset.history');
37
+ }
38
+ export async function runReset(command,registry,stdout,stderr){
39
+ if(!command.apply){
40
+ await history(command.workspace);
41
+ await stdout(JSON.stringify(await prepareReset(command.workspace,registry,command.options))+'\n');return 0;
42
+ }
43
+ const raw=(await readRecord(command.previewFile)).value;
44
+ const approval={decision:'approve',preparedDigest:raw.digest};
45
+ const prepared=validateResetRecord(raw,approval);
46
+ if(prepared.preview.plan.workspace!==command.workspace)fail('reset.workspace');
47
+ const result={command:'reset',mode:prepared.reset.selection.to,workspace:command.workspace,status:'failed',
48
+ backupDirectory:resolveChild(command.workspace,prepared.reset.backup.directory),
49
+ journal:null,recovery:null,runtime:'not-run',lockRelease:'not-acquired'};
50
+ let lock;
51
+ try{
52
+ lock=await acquireWorkspaceLock(command.workspace);result.lockRelease='pending';await history(command.workspace);
53
+ await stderr(JSON.stringify({kind:'reset-backup-location',path:result.backupDirectory,status:'not-yet-verified'})+'\n');
54
+ const applied=await applyReset(lock,prepared,approval,registry,{onJournal:async location=>{
55
+ result.journal=location.directory;
56
+ result.recovery=resolveChild(command.workspace,location.relative.replace('/journals/','/transactions/')+'/recovery.json');
57
+ await stderr(JSON.stringify({kind:'journal-location',path:result.journal,recovery:result.recovery,backupDirectory:result.backupDirectory})+'\n');
58
+ }});
59
+ result.status=applied.status;
60
+ }catch(error){result.error=error instanceof ContractError?error.code:'reset.io';}
61
+ finally{if(lock)try{await lock.release();result.lockRelease='released';}catch{result.lockRelease='failed';}}
62
+ await stdout(JSON.stringify(result)+'\n');
63
+ return ['ready','not-installed'].includes(result.status) && result.lockRelease==='released'?0:1;
64
+ }
@@ -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
- if (t.action === 'verify-absent' && ((op.command !== 'remove' && !retiredBundleFile) || t.beforeHash !== null || t.desiredHash !== null)) fail('operation.hash');
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 = [];
@@ -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 prepared=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);
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==='remove'?previous.active:copy.desired;
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
- if(beforeSetup ? (previous!==null || old.value.previous!==null || evidence.stateHash!==null ||
300
- original.kind!=='prepared-plan' || original.stateFileHash!==null || plan.command!=='setup') :
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).map(o=>o.path):[]);
340
- if(plan.targets.some(t=>!['create','replace','edit-fields',...((plan.command==='remove' || retired.has(t.path))?['delete','verify-absent']:[])].includes(t.action)))fail('apply.unsupported-action');
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) {
@@ -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),observed)) {
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=>plan.command==='remove'?'remove':
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
- if(beforeSetup && (evidence.stateHash!==null || record.value.previous!==null ||
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==='remove';
51
- const retired=new Set(retiredBundleOwnership(record.value.previous,previousPlan.plan.desired??{}).map(o=>o.path));
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
 
@@ -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' && ['enabled','max_threads','max_depth','max_concurrent_threads_per_session',
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');