@6reduk/workspace-pipeline 0.3.0 → 0.5.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
@@ -1,11 +1,15 @@
1
1
  # Workspace Pipeline CLI
2
2
 
3
+ Claude/Grok shared delivery: [one Claude configuration, plain Grok launch,
4
+ global compatibility preview and recovery](docs/claude-grok.md).
5
+
3
6
  `@6reduk/workspace-pipeline` — workspace-local pipeline delivery. Licensed under MIT.
4
7
 
5
8
  **`--help`, read-only `doctor`, and `logs list/clean/policy` are implemented.**
6
9
  Setup/update/repair/remove/switch/continue use compiled workspace-local adapters
7
- for Codex, Claude, Kimi and Grok. No native plugin installation
8
- or global activation is performed. Claude/Grok shared-entry and skill routing were
10
+ for Codex, Claude, Kimi and legacy native Grok. No native plugin installation
11
+ is performed. Claude-only delivery additionally offers an explicitly approved
12
+ user-wide Grok compatibility prerequisite. Claude/Grok shared-entry and skill routing were
9
13
  observed in disposable sessions. Kimi native verification is deferred; its
10
14
  configuration renderer remains experimental. Real-project migration requires an
11
15
  explicit preview and approval; installation is not blanket runtime certification.
@@ -15,7 +19,8 @@ manifest/source binding changes) and [reset](docs/reset.md) (backed-up restorati
15
19
  or clearing of selected local adapter configuration). Both require a separate
16
20
  preview and explicit apply for changes; these commands are not in registry 0.2.0.
17
21
 
18
- Grok's Claude-import suppression requires the [scoped launch command](docs/launch.md).
22
+ Legacy native Grok's Claude-import suppression uses the [scoped launch command](docs/launch.md).
23
+ New Claude-only delivery uses plain `grok` and deliberately enables those imports.
19
24
  File installation does not establish native session discovery or runtime isolation;
20
25
  Kimi/Grok checks and known generic-skill discovery limits are documented in
21
26
  [native component formats](docs/native-provider-format.md).
@@ -52,7 +57,9 @@ npm run pack:check
52
57
  npm run test:packed:providers
53
58
  ```
54
59
 
55
- The executable name is `workspace-pipeline`. Doctor prints JSON: exit 0 means
60
+ The executable name is `workspace-pipeline`. Output is readable by default;
61
+ add `--json` for complete machine output. Use `--json` when saving any preview
62
+ for a later apply, even when redirecting stdout to a file. Doctor exit 0 means
56
63
  observed configuration ready, 1 means not ready/incomplete, 2 means invocation or
57
64
  transport error. Unsupported commands exit with code 2. On Windows use an absolute
58
65
  path such as `C:\Projects\my-workspace`. See [doctor](docs/doctor.md) for limits.
@@ -0,0 +1,122 @@
1
+ # One Claude delivery for Claude Code and Grok
2
+
3
+ Select a Claude-only source bundle, such as the new Unity `claude-grok` bundle.
4
+ The name describes its consumers; its provider list contains only `claude`.
5
+ The source supplies one full `CLAUDE.md`, `.claude/skills`, optional
6
+ `.claude/agents`, and `.mcp.json`. Start either `claude` or plain `grok` in the
7
+ wrapper. Do not use the old `launch grok` command: it is only for legacy native
8
+ Grok deliveries and disables the imports this mode needs.
9
+
10
+ ## What setup changes
11
+
12
+ Normal setup/update/repair/reset preview now includes a **separate user-wide
13
+ Grok prerequisite** when the desired delivery includes Claude but not native
14
+ Grok. If Grok is absent, it is reported as `not-present`; Claude still installs.
15
+ Old dual-provider sources retain their legacy behavior until migrated.
16
+
17
+ If Grok exists, explicit apply enables only these settings in its effective user
18
+ profile (`GROK_HOME/config.toml`, otherwise the user's `.grok/config.toml`):
19
+
20
+ ```toml
21
+ [compat.claude]
22
+ skills = true
23
+ rules = true
24
+ agents = true
25
+ mcps = true
26
+ hooks = true
27
+ ```
28
+
29
+ These preferences affect **all Grok workspaces**, including user-installed
30
+ hooks/MCP. They are not workspace isolation. Other settings, including model,
31
+ credentials, trust, permission rules and `compat.claude.sessions`, are preserved.
32
+ The installer never grants folder trust or starts a model/MCP session.
33
+
34
+ Use the normal command with `--json` to save its complete preview, then review
35
+ and apply that exact file. New Claude-only previews bind both the workspace
36
+ plan and the global prerequisite. Old previews must be regenerated; do not
37
+ manually unwrap/edit a preview to avoid its global checks.
38
+ This includes old ordinary (non-switch) continuation previews: regenerate with
39
+ `continue --workspace <path> --recovery <retained-record>` and approve the new
40
+ preview. Existing recovery evidence remains unchanged; it is not a lockout.
41
+
42
+ Close configuration editors while applying. Backups and operation records live
43
+ under `<GROK_HOME>/workspace-pipeline-backups/<id>/`, **not in the workspace**.
44
+ The reported directory contains `config.before.toml` when a file existed,
45
+ `operation.json`, and on success `completed.json`. Backups may contain secrets;
46
+ keep them private. They are not covered by workspace journal cleanup. Review
47
+ and remove obsolete backup directories manually when no longer needed.
48
+
49
+ ## Diagnosis and repair
50
+
51
+ `doctor` checks actual configuration and known overriding environment/policy
52
+ conditions. When a native Grok executable is available it also runs bounded
53
+ `grok inspect --json` without `--trust`, retaining only compatibility results.
54
+ It reports native inspection separately. This does not prove model-visible
55
+ skills, execution of agents, a working MCP connection or task acceptance.
56
+ Untrusted workspace discovery may be incomplete; grant trust yourself in the
57
+ harness only after reviewing the workspace.
58
+
59
+ Conflicting environment overrides, managed policy files, legacy import markers,
60
+ malformed/unsupported TOML, symlinks and stale before hashes fail closed. The CLI
61
+ does not override organization policy or silently remove an import marker.
62
+ An existing policy file conservatively requires separate resolution, even if
63
+ it might not ultimately pin these five fields. A missing native binary yields
64
+ `native: not-available`, not an invented native PASS.
65
+
66
+ If the workspace operation succeeded but global configuration failed, the
67
+ result is `needs-compatibility`, with `workspaceStatus: ready`. Workspace files
68
+ are already installed; run a fresh **repair preview/apply**, rather than replaying
69
+ a stale setup preview. There is no claim of a cross-filesystem atomic transaction.
70
+ Historical switch continuations have no global approval envelope: after workspace
71
+ activation they inspect compatibility without changing it. If needed, finish with
72
+ a fresh repair preview/apply. Source authors must make their complete `CLAUDE.md`
73
+ usable by either harness, not require a nonexistent Grok provider route.
74
+
75
+ To inspect/enable the same prerequisite separately (for example after installing
76
+ Grok later), use:
77
+
78
+ ```powershell
79
+ workspace-pipeline compat claude --json > compat-preview.json
80
+ workspace-pipeline compat claude --apply --preview "C:\absolute\compat-preview.json"
81
+ ```
82
+
83
+ After a killed installer, a recorded dead-process lock can be recovered:
84
+
85
+ ```powershell
86
+ workspace-pipeline compat claude recover-lock --json > lock-preview.json
87
+ workspace-pipeline compat claude recover-lock --apply --preview "C:\absolute\lock-preview.json"
88
+ ```
89
+
90
+ Recovery verifies the saved lock, current config hash and that the owner PID is
91
+ not alive. It removes only that installer lock; it does not restore config or
92
+ delete evidence. Then inspect backups and run fresh repair. A malformed lock,
93
+ PID reuse, or a crash during lock recovery itself requires manual diagnosis;
94
+ the tool refuses to guess or automatically delete an unknown/live lock.
95
+
96
+ ## Update and removal
97
+
98
+ When a still-selected bundle drops its old native Grok member, ordinary update
99
+ retires only owned Grok files/fields and preserves personal settings. Modified
100
+ owned files remain conflicts; review/reset them explicitly. Codex delivery is
101
+ not removed. Grok can also discover `.agents/skills`, so use matching canonical
102
+ pipeline skills and check actual source selection, not merely unique names.
103
+
104
+ Removing a workspace **leaves the user-wide compat preferences enabled**: other
105
+ workspaces may depend on them. Backups are not restored automatically. To undo
106
+ a preference, review the backup and current config and change only the relevant
107
+ fields; never overwrite a later personal config wholesale with an old backup.
108
+
109
+ ## Contributor boundaries
110
+
111
+ `src/compat/claude.js` owns the bounded profile editor/backup/lock and sanitized
112
+ native inspection. `src/compat/lifecycle.js` binds the additional approval at
113
+ the public CLI boundary; workspace schemas, historical recovery bytes and path
114
+ ownership remain unchanged. Embedded `runCli` callers explicitly inject the
115
+ compatibility service; the real executable supplies it. Source packages cannot
116
+ provide executable adapter code or arbitrary global destinations.
117
+
118
+ Run `node --test test/claude-compat.test.js`, the full regression and
119
+ `npm run test:packed:claude-compat`. The packed test uses only temporary profiles
120
+ and exercises a dual-provider-to-Claude-only migration, drift, repair/reset and
121
+ removal. Neither a successful test nor this document claims publication or a
122
+ real-workspace migration.
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,
@@ -110,8 +111,9 @@ This is installation metadata, not a pipeline/document version requirement.
110
111
  locks, renews approvals or silently selects an updated pipeline version.
111
112
  - A missing state is `not-installed` only when metadata is absent; residual
112
113
  metadata with no valid state remains incomplete, not a clean installation.
113
- - Provider-specific discovery/global compatibility checks are later adapter work;
114
- synthetic provider-double readiness is not live-provider certification.
114
+ - The public CLI additionally checks the [Claude-only Grok prerequisite](claude-grok.md),
115
+ including native compatibility inventory when an executable is available.
116
+ Synthetic provider-double readiness is not live-provider certification.
115
117
 
116
118
  ### Repository completion is not lock recovery
117
119
 
package/docs/launch.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Scoped Grok launch (S11 development)
2
2
 
3
+ For new Claude-only delivery, use **plain `grok`**, not this legacy launcher.
4
+ See [Claude/Grok shared delivery](claude-grok.md). The launcher below is retained
5
+ only for old native-Grok installations and deliberately sets imports false.
6
+
7
+ ## Configuration limitation (checked 2026-09-23)
8
+
9
+ The desired UX is ordinary `grok` with workspace-local compatibility settings,
10
+ not a mandatory launcher. This is currently blocked by the harness: installed
11
+ Grok 1.0.40 and the upstream configuration reference list only `mcp_servers`,
12
+ `plugins`, `permission`, and `mcp.max_output_bytes` as project config inputs.
13
+ `compat.claude` is not among them. Writing five false values locally must not be
14
+ represented as working import suppression. This legacy native-Grok route does
15
+ not enable the new user-wide Claude compatibility prerequisite. It remains an
16
+ explicit workaround, not the approved long-term configuration model.
17
+
18
+ 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),
19
+ configuration layers and `compat` fields. No new live-isolation claim is made.
20
+
3
21
  This command starts Grok from an already configured workspace with five Claude
4
22
  compatibility switches set to false in the child environment: skills, rules, agents,
5
23
  MCP and hooks. It does not install a pipeline. Configure Grok using the ordinary
@@ -23,7 +23,8 @@ 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
@@ -68,7 +69,7 @@ restoration; empty reset must be explicitly selected.
68
69
 
69
70
  ## Output and failures
70
71
 
71
- - stdout: one prepared JSON or operation result.
72
+ - stdout: readable result by default; with `--json`, one complete prepared JSON or operation result.
72
73
  - stderr during apply: structured journal/recovery locations and operation events.
73
74
  - exit 0: preview created, or observed configuration ready with successful lock
74
75
  release and reporting. It never certifies harness/MCP runtime.
@@ -79,7 +80,10 @@ restoration; empty reset must be explicitly selected.
79
80
  If stdout fails after a successful apply, exit 2 does not undo that apply.
80
81
  Operation events and `doctor` distinguish actual configuration state from output
81
82
  delivery. Errors expose stable codes, not raw exception messages or argv secrets.
82
- No force unlock, rollback, retry or global configuration mutation is performed.
83
+ The workspace transaction does not force unlock, rollback or mutate global config.
84
+ For Claude-only delivery, the public CLI binds a separate, explicit
85
+ [user-wide Grok prerequisite](claude-grok.md) to preview/apply. Workspace success
86
+ followed by prerequisite failure is reported as `needs-compatibility`.
83
87
 
84
88
  ## Offline repair and removal
85
89
 
@@ -162,4 +166,7 @@ boundary, `uncertain: null` means no ambiguous write: only unrecorded operations
162
166
  and phase checks remain. Completed outcomes are not replayed. After both recorded
163
167
  phase checks, an empty remainder permits separately checked activation, not an
164
168
  inferred phase success. Failed/corrupt journals and changed targets fail closed.
165
- No source download, global setting change, force unlock or retention cleanup occurs.
169
+ No source download, force unlock or retention cleanup occurs. A Claude-only
170
+ continuation with an explicit compatibility envelope can apply its separately
171
+ approved global prerequisite. Historical switch continuations only inspect it
172
+ after activation; missing compatibility requires a fresh repair preview/apply.
@@ -127,6 +127,9 @@ owned routes therefore does not prove that harness can no longer discover a
127
127
  pipeline still installed for another provider. Installer ownership isolation and
128
128
  native discovery are distinct checks.
129
129
 
130
+ The following describes legacy native-Grok delivery, not the new
131
+ [Claude-only compatibility mode](claude-grok.md), which installs no Grok copies.
132
+
130
133
  Grok's child-environment helper sets five Claude compatibility switches to false:
131
134
  skills, rules, agents, MCP and hooks. It changes only the child environment,
132
135
  including removal of conflicting Windows key spellings. It does not switch
package/docs/rebind.md CHANGED
@@ -15,8 +15,8 @@ The following PowerShell example uses illustrative absolute paths; replace them.
15
15
  Use a private directory for preview files: they can contain configuration values.
16
16
 
17
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'
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
20
  workspace-pipeline update --workspace 'C:\Work\Game' --apply --preview 'C:\Private\update.json'
21
21
  workspace-pipeline doctor --workspace 'C:\Work\Game'
22
22
  ```
@@ -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 CHANGED
@@ -13,7 +13,7 @@ Close running harnesses/configuration editors. Keep preview files private: they
13
13
  contain before/after bytes, potentially including MCP credentials.
14
14
 
15
15
  ```powershell
16
- workspace-pipeline reset --workspace 'C:\Work\Game' --all > 'C:\Private\reset.json'
16
+ workspace-pipeline reset --workspace 'C:\Work\Game' --all --json > 'C:\Private\reset.json'
17
17
  # Check $LASTEXITCODE and review reset.scope, reset.backup and preview.plan.targets.
18
18
  workspace-pipeline reset --workspace 'C:\Work\Game' --apply --preview 'C:\Private\reset.json'
19
19
  workspace-pipeline doctor --workspace 'C:\Work\Game'
@@ -55,7 +55,10 @@ Non-control table entries under `[agents]` are treated as named agent definition
55
55
  Unknown files outside the listed trees are not guessed to be pipeline files.
56
56
  Files under listed trees are included regardless of ignore rules. Empty directories
57
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.
58
+ or MCP declaration. Legacy native Grok uses its scoped launcher; new Claude-only
59
+ delivery uses [plain Grok and a separate compatibility prerequisite](claude-grok.md).
60
+ Reset-to-installed may explicitly reapply that prerequisite; reset-to-empty and
61
+ removal never disable user-wide compatibility.
59
62
 
60
63
  Malformed configurations, unsupported TOML representations, unsafe paths/links,
61
64
  hardlinks, repository overlap, unavailable snapshots, incompatible renderer replay,
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.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Workspace-local Git pipeline configuration dispatcher",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "test:packed": "node scripts/verify-packed-cli.mjs",
27
27
  "test:packed:identity": "node scripts/verify-packed-identity.mjs",
28
28
  "test:packed:providers": "node scripts/verify-packed-providers.mjs",
29
+ "test:packed:claude-compat": "node scripts/verify-packed-claude-compat.mjs",
29
30
  "test:packed:pilot": "node scripts/verify-packed-pilot.mjs"
30
31
  },
31
32
  "dependencies": {
package/src/cli.js CHANGED
@@ -1,7 +1,10 @@
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';
5
+ import {createClaudeCompatibility} from './compat/claude.js';
4
6
  const write=stream=>text=>new Promise((resolve,reject)=>stream.write(text,error=>error?reject(error):resolve()));
5
7
  // Prevent an unhandled pipe error; write callbacks report delivery failures.
6
8
  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});
9
+ const args=process.argv.slice(2),json=args.includes('--json');
10
+ process.exitCode=await runCli(args,{stdout:outputWriter(write(process.stdout),json),stderr:outputWriter(write(process.stderr),json),registry:providerRegistry,compatibility:createClaudeCompatibility()});
@@ -0,0 +1,22 @@
1
+ import {fail} from '../contracts/parse.js';
2
+ import {absoluteRoot} from '../workspace/paths.js';
3
+ import {contractDigest} from '../contracts/semantic.js';
4
+ import {readRecord} from '../operations/state.js';
5
+ export function parseCompat(args){
6
+ if(args[1]!=='claude')fail('cli.arguments');
7
+ const result={command:'compat',recover:args[2]==='recover-lock'},seen=new Set();
8
+ for(let i=result.recover?3:2;i<args.length;i++){
9
+ const flag=args[i];if(!['--json','--apply','--preview'].includes(flag)||seen.has(flag))fail('cli.arguments');seen.add(flag);
10
+ if(flag==='--json')continue;if(flag==='--apply'){result.apply=true;continue;}
11
+ const value=args[++i];if(!value||value.startsWith('--'))fail('cli.arguments');result.previewFile=absoluteRoot(value);
12
+ }
13
+ if(Boolean(result.apply)!==Boolean(result.previewFile))fail('cli.arguments');return result;
14
+ }
15
+ export async function runCompat(command,service,stdout){
16
+ if(!service)fail('grok-compat.unavailable');
17
+ if(!command.apply){const body={kind:'grok-compat-command',action:command.recover?'recover-lock':'enable',plan:await (command.recover?service.recovery():service.inspect())};
18
+ await stdout(JSON.stringify({...body,digest:contractDigest(body)})+'\n');return body.plan.status==='blocked'?1:0;}
19
+ const record=(await readRecord(command.previewFile)).value,{digest,...body}=record;
20
+ if(Object.keys(record).sort().join(',')!=='action,digest,kind,plan'||record.kind!=='grok-compat-command'||record.action!==(command.recover?'recover-lock':'enable')||digest!==contractDigest(body))fail('grok-compat.envelope');
21
+ const result=await (command.recover?service.recover(record.plan):service.apply(record.plan));await stdout(JSON.stringify(result)+'\n');return 0;
22
+ }
@@ -26,9 +26,12 @@ import {parseMigrationCommand,runMigrationCommand} from './migration.js';
26
26
  import {parseLaunch,runLaunch} from './launch.js';
27
27
  import {parseRebind,runRebind,acceptedRebind} from './rebind.js';
28
28
  import {parseReset,runReset} from './reset.js';
29
+ import {bindCompatibility,unwrapCompatibility,finishCompatibility,doctorCompatibility} from '../compat/lifecycle.js';
30
+ import {parseCompat,runCompat} from './compat.js';
29
31
 
30
32
  export const help=`Workspace Pipeline CLI — development preview
31
33
  Usage: workspace-pipeline doctor --workspace <absolute-directory> [--recovery <relative-record>] [--json]
34
+ workspace-pipeline compat claude [recover-lock] [--apply --preview <absolute-json-file>] [--json]
32
35
  workspace-pipeline launch grok --workspace <absolute-directory> --executable <absolute-native-executable> [--inspect] [--execute]
33
36
  workspace-pipeline <init|adopt|wrap> --workspace <absolute-directory> --choices <absolute-json-file> [--manifest <absolute-file>] [--network]
34
37
  workspace-pipeline <init|adopt|wrap> --workspace <absolute-directory> --apply --preview <absolute-json-file>
@@ -62,7 +65,9 @@ Preview stages Git externally; inspect is read-only/offline. JSON can contain
62
65
  private config bytes. Only explicit apply writes or clears a validated pending marker.
63
66
  Apply requires the saved installer-bound envelope and accepts no source overrides.
64
67
 
65
- Doctor is read-only and prints JSON. Exit 0 means observed configuration ready;
68
+ Output is human-readable by default; use --json for complete machine output.
69
+ Save previews for apply with --json (including when redirecting stdout).
70
+ Doctor is read-only. Exit 0 means observed configuration ready;
66
71
  exit 1 means not ready/incomplete; exit 2 means invalid invocation or unavailable command.
67
72
  Runtime, MCP/harness discovery and provider compatibility are NOT verified.
68
73
  No source access, automatic repair or lock removal. Read-only/configuration commands never clean history.
@@ -134,15 +139,16 @@ logs clean --repositories --workspace <absolute-directory> --max-age-days <N>
134
139
  Apply with --repositories --apply --preview <file>; this is a separate cleanup
135
140
  domain, never an implicit increase of the ordinary journal deletion budget.
136
141
  Execution inputs are retained privately under .pipeline/repository-inputs;
137
- --json is optional because JSON is the default.
142
+ --json selects the complete machine-readable result without presentation changes.
138
143
  This package is not ready to replace an existing installation.`;
139
144
 
140
145
  // Parse strictly before any filesystem observation. Never echo unknown arguments
141
146
  // (which can contain credentials). Output transport is trusted CLI code.
142
147
  export function parseCommand(args) {
143
148
  if(!Array.isArray(args) || args.some(a=>typeof a!=='string')) fail('cli.arguments');
144
- if(args.length===0 || (args.length===1 && ['--help','-h'].includes(args[0])))return {command:'help'};
149
+ if(args.length===0 || (['--help','-h'].includes(args[0]) && (args.length===1 || (args.length===2 && args[1]==='--json'))))return {command:'help'};
145
150
  if(args[0]==='launch')return parseLaunch(args);
151
+ if(args[0]==='compat')return parseCompat(args);
146
152
  if(args[0]==='rebind')return parseRebind(args);
147
153
  if(args[0]==='reset')return parseReset(args);
148
154
  if(['setup','update','repair','remove','switch','continue'].includes(args[0]))return parseLifecycle(args);
@@ -259,7 +265,7 @@ function parseLifecycle(args) {
259
265
  return result;
260
266
  }
261
267
 
262
- async function runLifecycle(command,registry,stdout,stderr) {
268
+ async function runLifecycle(command,registry,stdout,stderr,compatibility) {
263
269
  // A trusted embedding/CLI assembly can supply functions; argv and package
264
270
  // data cannot. Until real adapters ship, the default command fails before IO.
265
271
  if(registry===null || registry===undefined)fail('cli.providers-unavailable');
@@ -275,12 +281,22 @@ async function runLifecycle(command,registry,stdout,stderr) {
275
281
  if(command.bundles)input.bundles=command.bundles;
276
282
  if(command.recoveryPath)input.recoveryPath=command.recoveryPath;
277
283
  if(command.rebindFile)Object.assign(input,await acceptedRebind(command.workspace,command.rebindFile));
278
- await stdout(JSON.stringify(await prepare(input,registry))+'\n');return 0;
284
+ const prepared=await prepare(input,registry);
285
+ await stdout(JSON.stringify(command.command==='remove'?prepared:await bindCompatibility(prepared,compatibility))+'\n');return 0;
279
286
  }
280
- const prepared=(await readRecord(command.previewFile)).value;
281
- const result=await apply({command:command.command,wrapper:command.workspace,prepared,
287
+ const saved=(await readRecord(command.previewFile)).value;
288
+ const unwrapped=command.command==='remove'?{prepared:saved,compatibility:null}:await unwrapCompatibility(saved,compatibility);
289
+ const prepared=unwrapped.prepared;
290
+ const workspaceResult=await apply({command:command.command,wrapper:command.workspace,prepared,
282
291
  approval:{decision:'approve',preparedDigest:prepared.digest}},registry,
283
292
  {report:async event=>stderr(JSON.stringify(event)+'\n')});
293
+ let result=await finishCompatibility(workspaceResult,unwrapped.compatibility,compatibility);
294
+ // Historical switch continuations do not carry a desired-provider envelope.
295
+ // Inspect the activated installation, but never infer approval for a global write.
296
+ if(command.command==='continue'&&!unwrapped.compatibility&&compatibility&&result.status==='ready'&&result.lockRelease==='released'){
297
+ const checked=await doctorCompatibility(await inspectInstallation(command.workspace),compatibility);
298
+ if(checked.compatibility)result={...result,compatibility:checked.compatibility,...checked.status==='needs-compatibility'?{workspaceStatus:'ready',status:'needs-compatibility'}:{}};
299
+ }
284
300
  await stdout(JSON.stringify(result)+'\n');
285
301
  return (result.status==='ready' || (['remove','continue'].includes(command.command) && result.status==='not-installed')) && result.lockRelease==='released' && !result.outputError?0:1;
286
302
  }
@@ -384,14 +400,15 @@ async function runLogs(command,stdout,stderr) {
384
400
  }finally{await lock.release();}
385
401
  }
386
402
 
387
- export async function runCli(args,{stdout,stderr,registry=null}) {
403
+ export async function runCli(args,{stdout,stderr,registry=null,compatibility=null}) {
388
404
  if(typeof stdout!=='function' || typeof stderr!=='function')fail('cli.transport');
389
405
  try {
390
406
  const command=parseCommand(args);
391
407
  if(command.command==='help') {await stdout(help+'\n');return 0;}
408
+ if(command.command==='compat')return await runCompat(command,compatibility,stdout);
392
409
  if(command.command==='launch')return await runLaunch(command,stdout,stderr);
393
410
  if(command.command==='rebind')return await runRebind(command,stdout);
394
- if(command.command==='reset')return await runReset(command,registry,stdout,stderr);
411
+ if(command.command==='reset')return await runReset(command,registry,stdout,stderr,compatibility);
395
412
  if(command.command==='migration')return await runMigrationCommand(command,stdout,stderr);
396
413
  if(command.command==='logs')return await runLogs(command,stdout,stderr);
397
414
  if(command.command==='policy')return await runPolicy(command,stdout);
@@ -400,9 +417,9 @@ export async function runCli(args,{stdout,stderr,registry=null}) {
400
417
  ['abandon','continue-abandon'].includes(command.action)?runRepositoryAbandon:
401
418
  command.action==='recover-locks'?runRepositoryLockRecovery:
402
419
  ['recover-bootstrap','continue-bootstrap','retire-bootstrap'].includes(command.action)?runBootstrapContinuation:runRepositoryRecovery)(command,stdout);
403
- if(['setup','update','repair','remove','switch','continue'].includes(command.command))return await runLifecycle(command,registry,stdout,stderr);
420
+ if(['setup','update','repair','remove','switch','continue'].includes(command.command))return await runLifecycle(command,registry,stdout,stderr,compatibility);
404
421
  const options=command.recoveryPath===undefined?{}:{recoveryPath:command.recoveryPath};
405
- const result=await inspectInstallation(command.workspace,options);
422
+ const result=await doctorCompatibility(await inspectInstallation(command.workspace,options),compatibility);
406
423
  await stdout(JSON.stringify(result)+'\n');
407
424
  return result.ready?0:1;
408
425
  } catch(error) {
@@ -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,51 @@
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
+ if(value.compatibility)lines.push(`Grok / Claude compatibility: ${safe(value.compatibility.status)}`,
15
+ ...(value.compatibility.path?[`User config: ${safe(value.compatibility.path)}`]:[]),
16
+ ...(value.compatibility.blockers??[]).map(b=>` - ${safe(b)}`),
17
+ `Native compatibility inspection: ${safe(value.compatibility.native?.status??'not-run')}.`,
18
+ 'Native discovery is not model/agent/MCP execution certification.');
19
+ lines.push('', `Diagnostics: ${value.diagnostics.length}`);
20
+ for (const item of value.diagnostics) lines.push(` - ${safe(item.code)}${item.subject ? ': ' + safe(item.subject) : ''}${item.pointer ? ' ' + safe(item.pointer) : ''}`);
21
+ lines.push('', 'Runtime / model-visible skills / MCP: not verified by doctor.',
22
+ 'Full report: repeat with --json.');
23
+ return lines.join('\n') + '\n';
24
+ }
25
+ const lines = ['Workspace Pipeline', ''];
26
+ function render(node, indent = '', depth = 0) {
27
+ if (node === null || typeof node !== 'object') { lines.push(indent + safe(node)); return; }
28
+ if (depth > 5) { lines.push(indent + '(details omitted; use --json)'); return; }
29
+ const entries = Object.entries(node);
30
+ for (const [key, item] of entries.slice(0, 40)) {
31
+ const label = indent + safe(key) + ':';
32
+ if (privateKey.test(key)) lines.push(label + ' (private payload; use --json)');
33
+ else if (item !== null && typeof item === 'object') { lines.push(label); render(item, indent + ' ', depth + 1); }
34
+ else { const text=safe(item); lines.push(label + ' ' + (text.length>500?text.slice(0,500)+' (truncated; use --json)':text)); }
35
+ }
36
+ if (entries.length > 40) lines.push(indent + `(${entries.length - 40} more entries; use --json)`);
37
+ }
38
+ render(value);
39
+ lines.push('', 'For complete machine output or a saved apply preview, repeat with --json.',
40
+ 'This display is not an apply preview. JSON may contain private configuration.');
41
+ return lines.join('\n') + '\n';
42
+ }
43
+
44
+ export function outputWriter(write, json) {
45
+ if (json) return write;
46
+ return text => {
47
+ let value;
48
+ try { value = JSON.parse(text); } catch { return write(text); }
49
+ return write(formatResult(value));
50
+ };
51
+ }
@@ -9,8 +9,9 @@ export function parseRebind(args) {
9
9
  const result={command:'rebind'},seen=new Set();
10
10
  for(let i=1;i<args.length;i++) {
11
11
  const flag=args[i];
12
- if(!['--workspace','--manifest'].includes(flag) || seen.has(flag))fail('cli.arguments');
12
+ if(!['--workspace','--manifest','--json'].includes(flag) || seen.has(flag))fail('cli.arguments');
13
13
  seen.add(flag);
14
+ if(flag==='--json')continue;
14
15
  const value=args[++i];if(!value || value.startsWith('--'))fail('cli.arguments');
15
16
  result[flag==='--workspace'?'workspace':'manifestPath']=absoluteRoot(value);
16
17
  }
@@ -6,13 +6,15 @@ import {applyReset} from '../operations/apply.js';
6
6
  import {acquireWorkspaceLock} from '../operations/lock.js';
7
7
  import {inspectHistory} from '../operations/history.js';
8
8
  import {assertNoRepositoryPending} from '../operations/repository-pending.js';
9
+ import {bindCompatibility,unwrapCompatibility,finishCompatibility} from '../compat/lifecycle.js';
9
10
 
10
11
  export function parseReset(args){
11
12
  const result={command:'reset'},seen=new Set();
12
13
  for(let i=1;i<args.length;i++){
13
14
  const flag=args[i];
14
- if(!['--workspace','--to','--all','--providers','--bundles','--apply','--preview'].includes(flag) || seen.has(flag))fail('cli.arguments');
15
+ if(!['--workspace','--to','--all','--providers','--bundles','--apply','--preview','--json'].includes(flag) || seen.has(flag))fail('cli.arguments');
15
16
  seen.add(flag);
17
+ if(flag==='--json')continue;
16
18
  if(flag==='--apply'){result.apply=true;continue;}
17
19
  if(flag==='--all'){(result.options??={}).all=true;continue;}
18
20
  const value=args[++i];if(!value || value.startsWith('--'))fail('cli.arguments');
@@ -34,12 +36,13 @@ async function history(workspace){
34
36
  await assertNoRepositoryPending(workspace);
35
37
  const h=await inspectHistory(workspace);if(!h.complete || h.diagnostics.length)fail('reset.history');
36
38
  }
37
- export async function runReset(command,registry,stdout,stderr){
39
+ export async function runReset(command,registry,stdout,stderr,compatibility=null){
38
40
  if(!command.apply){
39
41
  await history(command.workspace);
40
- await stdout(JSON.stringify(await prepareReset(command.workspace,registry,command.options))+'\n');return 0;
42
+ await stdout(JSON.stringify(await bindCompatibility(await prepareReset(command.workspace,registry,command.options),compatibility))+'\n');return 0;
41
43
  }
42
- const raw=(await readRecord(command.previewFile)).value;
44
+ const unwrapped=await unwrapCompatibility((await readRecord(command.previewFile)).value,compatibility);
45
+ const raw=unwrapped.prepared;
43
46
  const approval={decision:'approve',preparedDigest:raw.digest};
44
47
  const prepared=validateResetRecord(raw,approval);
45
48
  if(prepared.preview.plan.workspace!==command.workspace)fail('reset.workspace');
@@ -58,6 +61,7 @@ export async function runReset(command,registry,stdout,stderr){
58
61
  result.status=applied.status;
59
62
  }catch(error){result.error=error instanceof ContractError?error.code:'reset.io';}
60
63
  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;
64
+ const final=await finishCompatibility(result,unwrapped.compatibility,compatibility);
65
+ await stdout(JSON.stringify(final)+'\n');
66
+ return ['ready','not-installed'].includes(final.status) && final.lockRelease==='released'?0:1;
63
67
  }
@@ -0,0 +1,152 @@
1
+ // Compiled user-profile policy, outside workspace ownership. Packages cannot
2
+ // choose global paths or arbitrary keys. No harness, model or MCP is launched.
3
+ import path from 'node:path';
4
+ import {homedir} from 'node:os';
5
+ import {lstat,readFile,mkdir,rename,unlink,open} from 'node:fs/promises';
6
+ import {randomUUID} from 'node:crypto';
7
+ import {isDeepStrictEqual} from 'node:util';
8
+ import {promisify} from 'node:util';
9
+ import {execFile} from 'node:child_process';
10
+ import {parseTOML,getStaticTOMLValue} from 'toml-eslint-parser';
11
+ import {fail} from '../contracts/parse.js';
12
+ import {contractDigest} from '../contracts/semantic.js';
13
+ import {sha256,utf8} from '../source/inventory.js';
14
+ export const compatKeys=Object.freeze(['skills','rules','agents','mcps','hooks']);
15
+ const keys=node=>node.keys.map(k=>k.name??k.value);
16
+ const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v)&&!(v instanceof Date);
17
+ function document(bytes){
18
+ if(bytes!==null&&(!Buffer.isBuffer(bytes)||bytes.length>1024*1024))fail('grok-compat.size');
19
+ const text=bytes===null?'':utf8(bytes);
20
+ let ast,value;try{ast=parseTOML(text,{tomlVersion:'1.0'});value=getStaticTOMLValue(ast);}catch{fail('grok-compat.syntax');}
21
+ if(value.compat!==undefined&&!object(value.compat))fail('grok-compat.shape');
22
+ if(value.compat?.claude!==undefined&&!object(value.compat.claude))fail('grok-compat.shape');
23
+ if(value.claude_compat?.imported===true)fail('grok-compat.import-marker');
24
+ for(const k of compatKeys)if(value.compat?.claude?.[k]!==undefined&&typeof value.compat.claude[k]!=='boolean')fail('grok-compat.shape');
25
+ return {text,ast,value};
26
+ }
27
+ export function enableClaudeCompat(bytes){
28
+ const {text,ast,value}=document(bytes),edits=[],seen=new Set();
29
+ const changes=compatKeys.filter(k=>value.compat?.claude?.[k]!==true);
30
+ if(!changes.length)return {bytes:bytes??Buffer.alloc(0),changes};
31
+ let table=null,parentTable=null;
32
+ function pair(node,base){
33
+ const p=[...base,...keys(node.key)];if(p[0]!=='compat')return;
34
+ if(p.length===1||(p.length===2&&p[1]==='claude'))fail('grok-compat.inline-ancestor');
35
+ if(p.length===3&&p[1]==='claude'&&compatKeys.includes(p[2])){
36
+ seen.add(p[2]);if(changes.includes(p[2]))edits.push([...node.value.range,'true']);
37
+ }
38
+ }
39
+ for(const node of ast.body[0].body){
40
+ if(node.type==='TOMLKeyValue')pair(node,[]);
41
+ else if(node.type==='TOMLTable'){
42
+ if(node.resolvedKey.join('.')==='compat.claude'){if(node.kind!=='standard')fail('grok-compat.shape');table=node;}
43
+ if(node.resolvedKey.length===1&&node.resolvedKey[0]==='compat'){if(node.kind!=='standard')fail('grok-compat.shape');parentTable=node;}
44
+ for(const item of node.body)pair(item,node.resolvedKey);
45
+ }
46
+ }
47
+ const missing=compatKeys.filter(k=>!seen.has(k)),nl=text.includes('\r\n')?'\r\n':'\n';
48
+ if(missing.length){
49
+ const selected=table??parentTable;
50
+ if(selected){const end=text.indexOf('\n',selected.key.range[1]),pos=end<0?text.length:end+1;
51
+ edits.push([pos,pos,(end<0?nl:'')+missing.map(k=>`${table?'':'claude.'}${k} = true${nl}`).join('')]);
52
+ }else edits.push([0,0,missing.map(k=>`compat.claude.${k} = true${nl}`).join('')]);
53
+ }
54
+ let result=text;for(const [start,end,replacement]of edits.sort((a,b)=>b[0]-a[0]))result=result.slice(0,start)+replacement+result.slice(end);
55
+ const expected=structuredClone(value);expected.compat??={};expected.compat.claude??={};for(const k of compatKeys)expected.compat.claude[k]=true;
56
+ // The strict decoder consumes a leading UTF-8 BOM; preserve it on re-encode.
57
+ const bom=bytes?.subarray(0,3).equals(Buffer.from([0xef,0xbb,0xbf]));
58
+ const output=Buffer.from((bom?'\uFEFF':'')+result);if(!isDeepStrictEqual(document(output).value,expected))fail('grok-compat.postcondition');
59
+ return {bytes:output,changes};
60
+ }
61
+ async function stat(p){try{return await lstat(p);}catch(e){if(e.code==='ENOENT')return null;throw e;}}
62
+ async function safe(p){
63
+ let current=path.parse(p).root;
64
+ for(const segment of path.relative(current,p).split(path.sep)){
65
+ current=path.join(current,segment);const s=await stat(current);
66
+ if(s&&(s.isSymbolicLink()||(!s.isDirectory()&&current!==p)))fail('grok-compat.path');
67
+ }
68
+ }
69
+ async function read(p){
70
+ await safe(p);const s=await stat(p);if(!s)return null;
71
+ if(!s.isFile()||s.nlink!==1||s.size>1024*1024)fail('grok-compat.file');
72
+ const bytes=await readFile(p),after=await lstat(p);
73
+ if(after.ino!==s.ino||after.dev!==s.dev||after.size!==bytes.length||after.mtimeMs!==s.mtimeMs)fail('grok-compat.drift');
74
+ return bytes;
75
+ }
76
+ const digest=b=>b===null?null:sha256(b);
77
+ async function record(p,value){const h=await open(p,'wx',0o600);try{await h.writeFile(JSON.stringify(value,null,2)+'\n');await h.sync();}finally{await h.close();}}
78
+ const warning='User-wide Claude compatibility affects every Grok workspace, including user hooks/MCP. Removing one workspace does not disable it.';
79
+ export function createClaudeCompatibility({env=process.env,home=homedir()}={}){
80
+ function profile(){const base=env.GROK_HOME||path.join(env.USERPROFILE||env.HOME||home,'.grok');if(!path.isAbsolute(base))fail('grok-compat.home');return path.resolve(base);}
81
+ async function executable(){
82
+ const filename=process.platform==='win32'?'grok.exe':'grok';
83
+ const candidates=[path.join(env.USERPROFILE||env.HOME||home,'.grok','bin',filename),...(env.PATH||'').split(path.delimiter).filter(p=>path.isAbsolute(p)).map(p=>path.join(p,filename))];
84
+ for(const p of candidates)if((await stat(p))?.isFile())return p;return null;
85
+ }
86
+ async function inspect(){
87
+ const base=profile(),target=path.join(base,'config.toml');await safe(base);
88
+ if(!await stat(base)&&!await executable())return {kind:'grok-claude-prerequisite',profile:base,path:target,status:'not-present',beforeHash:null,resultHash:null,changes:[],blockers:[],warning};
89
+ const before=await read(target),edited=enableClaudeCompat(before),blockers=[];
90
+ for(const key of compatKeys){const name=`GROK_CLAUDE_${key.toUpperCase()}_ENABLED`;if(Object.keys(env).some(k=>k.toUpperCase()===name&&!['1','true'].includes(String(env[k]).toLowerCase())))blockers.push('environment:'+name);}
91
+ for(const name of ['managed_config.toml','requirements.toml'])if(await stat(path.join(base,name)))blockers.push('policy:'+name);
92
+ if(await stat(path.join(base,'workspace-pipeline-compat.lock')))blockers.push('global-lock');
93
+ if(await stat(path.join(base,'workspace-pipeline-compat-recovery.lock')))blockers.push('global-recovery-lock');
94
+ return {kind:'grok-claude-prerequisite',profile:base,path:target,status:blockers.length?'blocked':edited.changes.length?'needs-apply':'configured',beforeHash:digest(before),resultHash:digest(edited.bytes),changes:edited.changes,blockers,warning,verification:'config-only; native effective state and runtime not verified'};
95
+ }
96
+ async function check(expected){const actual=await inspect();if(contractDigest(actual)!==contractDigest(expected))fail('grok-compat.preview-drift');if(actual.status==='blocked')fail('grok-compat.blocked');return actual;}
97
+ async function apply(expected){
98
+ const plan=await check(expected);if(plan.status!=='needs-apply')return {...plan,changed:false};
99
+ const base=profile(),lockPath=path.join(base,'workspace-pipeline-compat.lock');await safe(base);await mkdir(base,{recursive:true,mode:0o700});await safe(base);
100
+ const token=randomUUID(),lock=await open(lockPath,'wx',0o600);let temporary,backupDir,lockBytes;
101
+ try{
102
+ lockBytes=Buffer.from(JSON.stringify({pid:process.pid,token,createdAt:new Date().toISOString()})+'\n');await lock.writeFile(lockBytes);await lock.sync();
103
+ if(await stat(path.join(base,'workspace-pipeline-compat-recovery.lock')))fail('grok-compat.recovery-active');
104
+ const before=await read(plan.path);if(digest(before)!==plan.beforeHash)fail('grok-compat.preview-drift');
105
+ const output=enableClaudeCompat(before).bytes;if(digest(output)!==plan.resultHash)fail('grok-compat.preview-drift');
106
+ backupDir=path.join(base,'workspace-pipeline-backups',token);await safe(backupDir);await mkdir(backupDir,{recursive:true,mode:0o700});
107
+ if(before!==null){const b=await open(path.join(backupDir,'config.before.toml'),'wx',0o600);try{await b.writeFile(before);await b.sync();}finally{await b.close();}}
108
+ if(before!==null&&digest(await read(path.join(backupDir,'config.before.toml')))!==plan.beforeHash)fail('grok-compat.backup');
109
+ await record(path.join(backupDir,'operation.json'),{schemaVersion:1,kind:'grok-claude-compatibility',plan,pid:process.pid,token,scope:compatKeys,restoration:'Explicit only; never overwrite later user edits'});
110
+ temporary=path.join(base,`workspace-pipeline-${token}.tmp`);const h=await open(temporary,'wx',0o600);try{await h.writeFile(output);await h.sync();}finally{await h.close();}
111
+ if(digest(await read(plan.path))!==plan.beforeHash)fail('grok-compat.preview-drift');await rename(temporary,plan.path);temporary=null;
112
+ if(digest(await read(plan.path))!==plan.resultHash)fail('grok-compat.readback');
113
+ await record(path.join(backupDir,'completed.json'),{resultHash:plan.resultHash});
114
+ return {...plan,status:'configured',changed:true,backupDir};
115
+ }finally{await lock.close();if(temporary)await unlink(temporary).catch(()=>{});if(lockBytes&&(await read(lockPath))?.equals(lockBytes))await unlink(lockPath);}
116
+ }
117
+ async function recovery(){
118
+ const base=profile(),lockPath=path.join(base,'workspace-pipeline-compat.lock'),bytes=await read(lockPath);
119
+ if(bytes===null)fail('grok-compat.no-lock');
120
+ let owner;try{owner=JSON.parse(utf8(bytes));}catch{fail('grok-compat.lock-record');}
121
+ if(Object.keys(owner).sort().join(',')!=='createdAt,pid,token'||!Number.isSafeInteger(owner.pid)||owner.pid<1||
122
+ !/^[a-f0-9-]{36}$/.test(owner.token)||typeof owner.createdAt!=='string')fail('grok-compat.lock-record');
123
+ try{process.kill(owner.pid,0);fail('grok-compat.owner-running');}catch(e){if(e.code!=='ESRCH')throw e;}
124
+ if(await stat(path.join(base,'workspace-pipeline-compat-recovery.lock')))fail('grok-compat.recovery-active');
125
+ return {kind:'grok-compat-lock-recovery',profile:base,path:lockPath,lockHash:sha256(bytes),owner,
126
+ configHash:digest(await read(path.join(base,'config.toml'))),backupDir:path.join(base,'workspace-pipeline-backups',owner.token),
127
+ warning:'Removes only this dead-process installer lock. Does not restore or rewrite config, backups or workspace.'};
128
+ }
129
+ async function recover(expected){
130
+ const actual=await recovery();if(contractDigest(actual)!==contractDigest(expected))fail('grok-compat.preview-drift');
131
+ const guardPath=path.join(profile(),'workspace-pipeline-compat-recovery.lock'),guard=await open(guardPath,'wx',0o600);
132
+ const guardBytes=Buffer.from(JSON.stringify({pid:process.pid,token:randomUUID()}));
133
+ try{
134
+ await guard.writeFile(guardBytes);await guard.sync();
135
+ const bytes=await read(actual.path);if(digest(bytes)!==actual.lockHash||digest(await read(path.join(profile(),'config.toml')))!==actual.configHash)fail('grok-compat.preview-drift');
136
+ try{process.kill(actual.owner.pid,0);fail('grok-compat.owner-running');}catch(e){if(e.code!=='ESRCH')throw e;}
137
+ await unlink(actual.path);return {...actual,status:'lock-released',next:'Run repair preview/apply; preserve backups and inspect whether the prior config write completed.'};
138
+ }finally{await guard.close();if((await read(guardPath))?.equals(guardBytes))await unlink(guardPath);}
139
+ }
140
+ async function verify(workspace){
141
+ const binary=await executable();if(!binary)return {status:'not-available',runtime:'not-run'};
142
+ if(!path.isAbsolute(workspace))fail('grok-compat.workspace');
143
+ try{
144
+ const {stdout}=await promisify(execFile)(binary,['inspect','--json'],{cwd:workspace,env,windowsHide:true,encoding:'utf8',timeout:30000,maxBuffer:8*1024*1024});
145
+ const data=JSON.parse(stdout),cells=data.externalCompat?.cells;
146
+ const effective=compatKeys.map(surface=>({surface,enabled:Array.isArray(cells)&&cells.filter(c=>c.vendor==='claude'&&c.surface===surface).length===1&&cells.find(c=>c.vendor==='claude'&&c.surface===surface).enabled===true}));
147
+ return {status:effective.every(c=>c.enabled)?'verified':'blocked',effective,projectTrusted:data.projectTrusted===true,
148
+ runtime:'not-run',method:'native inspect only; no trust grant, model or MCP invocation'};
149
+ }catch{return {status:'blocked',code:'grok-compat.native-inspect',runtime:'not-run'};}
150
+ }
151
+ return Object.freeze({inspect,check,apply,recovery,recover,verify});
152
+ }
@@ -0,0 +1,45 @@
1
+ import {contractDigest} from '../contracts/semantic.js';
2
+ import {fail,ContractError,parse} from '../contracts/parse.js';
3
+ export const usesClaudeCompatibility=providers=>providers?.includes('claude')&&!providers.includes('grok');
4
+ const selected=p=>usesClaudeCompatibility(p?.preview?.plan?.desired?.providers??
5
+ (p?.kind==='prepared-switch'?p.preview?.phases?.find(s=>s.name==='install-new')?.preview?.plan?.desired?.providers:undefined));
6
+ export async function bindCompatibility(prepared,service){
7
+ if(!service||!selected(prepared))return prepared;
8
+ const body={kind:'prepared-with-claude-compatibility',workspace:prepared,compatibility:await service.inspect()};
9
+ const result={...body,digest:contractDigest(body)};
10
+ parse(JSON.stringify(result),'json'); // Still readable by the public preview reader.
11
+ return result;
12
+ }
13
+ export async function unwrapCompatibility(prepared,service){
14
+ if(prepared?.kind!=='prepared-with-claude-compatibility'){
15
+ if(service&&selected(prepared))fail('grok-compat.preview-required');
16
+ return {prepared,compatibility:null};
17
+ }
18
+ if(!service||Object.keys(prepared).sort().join(',')!=='compatibility,digest,kind,workspace'||!selected(prepared.workspace))fail('grok-compat.envelope');
19
+ const {digest,...body}=prepared;if(digest!==contractDigest(body))fail('grok-compat.envelope');
20
+ await service.check(prepared.compatibility);
21
+ return {prepared:prepared.workspace,compatibility:prepared.compatibility};
22
+ }
23
+ export async function finishCompatibility(result,requirement,service){
24
+ if(!requirement)return result;
25
+ if(result.status!=='ready'||result.lockRelease!=='released'||result.outputError)return {...result,compatibility:{status:'not-applied',reason:'workspace-operation-incomplete'}};
26
+ try{
27
+ const compatibility=await service.apply(requirement);
28
+ if(compatibility.status==='configured'&&service.verify&&result.workspace){
29
+ compatibility.native=await service.verify(result.workspace);
30
+ if(compatibility.native.status==='blocked')return {...result,workspaceStatus:result.status,status:'needs-compatibility',compatibility};
31
+ }
32
+ return {...result,compatibility};
33
+ }
34
+ catch(error){return {...result,workspaceStatus:result.status,status:'needs-compatibility',compatibility:{status:'failed',profile:requirement.profile,error:error instanceof ContractError?error.code:'grok-compat.io',next:'Run repair preview/apply; workspace changes are already committed. Inspect workspace-pipeline-backups and the global lock in this profile before retry after a process crash.'}};}
35
+ }
36
+ export async function doctorCompatibility(result,service){
37
+ if(!service||!usesClaudeCompatibility(result.pipeline?.providers))return result;
38
+ try{
39
+ const compatibility=await service.inspect();
40
+ if(compatibility.status==='configured'&&service.verify&&result.workspace)compatibility.native=await service.verify(result.workspace);
41
+ const okay=['configured','not-present'].includes(compatibility.status)&&compatibility.native?.status!=='blocked';
42
+ return {...result,compatibility,ready:result.ready&&okay,status:okay?result.status:'needs-compatibility',
43
+ diagnostics:[...result.diagnostics,...okay?[]:[{code:compatibility.native?.status==='blocked'?'grok-compat.native-mismatch':'grok-compat.'+compatibility.status,subject:compatibility.path}]]};
44
+ }catch(error){return {...result,ready:false,status:'needs-compatibility',compatibility:{status:'blocked'},diagnostics:[...result.diagnostics,{code:error instanceof ContractError?error.code:'grok-compat.io',subject:'Grok user profile'}]};}
45
+ }