@deftai/directive-content 0.81.0 → 0.83.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.
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const assert = require("node:assert/strict");
5
+ const fs = require("node:fs");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const { describe, it } = require("node:test");
9
+ const {
10
+ buildDispatchSteps,
11
+ executeAllowlisted,
12
+ hasCmd,
13
+ parsePnpmPin,
14
+ runPackageScript,
15
+ validateScriptName,
16
+ } = require("./engine-pm-run.cjs");
17
+
18
+ describe("parsePnpmPin", () => {
19
+ it("accepts stable, prerelease, and build-metadata pins", () => {
20
+ for (const pin of ["pnpm@11.8.0", "pnpm@1.0.0-alpha.1", "pnpm@1.0.0+build.1"]) {
21
+ const result = parsePnpmPin(pin);
22
+ assert.equal(result.ok, true);
23
+ }
24
+ });
25
+
26
+ it("accepts missing/empty pins for unpinned fallback", () => {
27
+ assert.deepEqual(parsePnpmPin(undefined), { ok: true, semver: null, pin: null });
28
+ assert.deepEqual(parsePnpmPin(""), { ok: true, semver: null, pin: null });
29
+ });
30
+
31
+ it("rejects shell metacharacters and malformed pins before spawn", () => {
32
+ for (const pin of [
33
+ "pnpm@9.0.0; echo pwned",
34
+ "pnpm@9.0.0 & echo pwned",
35
+ "pnpm@^1.0.0",
36
+ "npm@1.0.0",
37
+ "pnpm@1.0.0 extra",
38
+ " pnpm@1.0.0",
39
+ "pnpm@1.0.0 ",
40
+ "pnpm@1.0.0\n",
41
+ ]) {
42
+ const result = parsePnpmPin(pin);
43
+ assert.equal(result.ok, false, pin);
44
+ }
45
+ });
46
+ });
47
+
48
+ describe("validateScriptName", () => {
49
+ it("accepts only declared script keys with safe names", () => {
50
+ const scripts = { build: "tsc", "test:unit": "vitest" };
51
+ assert.equal(validateScriptName("build", scripts), true);
52
+ assert.equal(validateScriptName("test:unit", scripts), true);
53
+ assert.equal(validateScriptName("missing", scripts), false);
54
+ assert.equal(validateScriptName("build;rm", scripts), false);
55
+ });
56
+ });
57
+
58
+ describe("buildDispatchSteps", () => {
59
+ it("preserves installed pnpm -> pinned corepack -> unpinned corepack order", () => {
60
+ const steps = buildDispatchSteps({
61
+ hasPnpm: true,
62
+ hasCorepack: true,
63
+ semver: "11.8.0",
64
+ script: "build",
65
+ });
66
+ assert.deepEqual(steps, [
67
+ { cmd: "pnpm", args: ["run", "build"] },
68
+ { cmd: "corepack", args: ["pnpm@11.8.0", "run", "build"] },
69
+ { cmd: "corepack", args: ["pnpm", "run", "build"] },
70
+ ]);
71
+ });
72
+ });
73
+
74
+ describe("runPackageScript security", () => {
75
+ function mkFixture(/** @type {Record<string, unknown>} */ pkgExtra) {
76
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deft-engine-pm-run-"));
77
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkgExtra, null, 2), "utf8");
78
+ return dir;
79
+ }
80
+
81
+ it("rejects malicious pins before any execution call", () => {
82
+ const dir = mkFixture({
83
+ packageManager: "pnpm@9.0.0; echo pwned",
84
+ scripts: { build: "node -e \"\"" },
85
+ });
86
+ const sentinel = path.join(dir, "sentinel.txt");
87
+ let execCalls = 0;
88
+ const code = runPackageScript(dir, "build", {
89
+ execFileSync() {
90
+ execCalls += 1;
91
+ fs.writeFileSync(sentinel, "pwned", "utf8");
92
+ },
93
+ });
94
+ assert.equal(code, 3);
95
+ assert.equal(execCalls, 0);
96
+ assert.equal(fs.existsSync(sentinel), false);
97
+ fs.rmSync(dir, { recursive: true, force: true });
98
+ });
99
+
100
+ it("records exact argv on execution without shell:true", () => {
101
+ const dir = mkFixture({
102
+ packageManager: "pnpm@11.8.0",
103
+ scripts: { build: "tsc" },
104
+ });
105
+ /** @type {Array<{ cmd: string, args: string[], shell?: boolean, stdio?: string }>} */
106
+ const calls = [];
107
+ const code = runPackageScript(dir, "build", {
108
+ execFileSync(cmd, args, opts) {
109
+ calls.push({ cmd, args: [...args], shell: opts?.shell, stdio: opts?.stdio });
110
+ if (opts?.stdio === "inherit") {
111
+ return;
112
+ }
113
+ },
114
+ });
115
+ assert.equal(code, 0);
116
+ const execCalls = calls.filter((c) => c.stdio === "inherit");
117
+ assert.equal(execCalls.length, 1);
118
+ assert.equal(execCalls[0].shell, false);
119
+ assert.equal(execCalls[0].stdio, "inherit");
120
+ if (process.platform === "win32") {
121
+ assert.equal(execCalls[0].cmd, "cmd.exe");
122
+ assert.deepEqual(execCalls[0].args.slice(0, 3), ["/d", "/s", "/c"]);
123
+ assert.match(execCalls[0].args[3], /pnpm.*run.*build/);
124
+ } else {
125
+ assert.deepEqual(execCalls[0], {
126
+ cmd: "pnpm",
127
+ args: ["run", "build"],
128
+ shell: false,
129
+ stdio: "inherit",
130
+ });
131
+ }
132
+ fs.rmSync(dir, { recursive: true, force: true });
133
+ });
134
+
135
+ it("allows probe hasCmd to use shell:true separately from execution", () => {
136
+ /** @type {Array<{ shell?: boolean, stdio?: string }>} */
137
+ const calls = [];
138
+ let probeAttempts = 0;
139
+ const execFn = (/** @type {string} */ _cmd, /** @type {string[]} */ _args, /** @type {{ shell?: boolean, stdio?: string }} */ opts) => {
140
+ if (opts?.stdio === "ignore") {
141
+ probeAttempts += 1;
142
+ if (probeAttempts === 1 && !opts.shell) {
143
+ throw new Error("probe without shell failed");
144
+ }
145
+ }
146
+ calls.push({ shell: opts?.shell, stdio: opts?.stdio });
147
+ };
148
+ assert.equal(hasCmd(execFn, "pnpm"), true);
149
+ assert.ok(calls.some((c) => c.shell === true && c.stdio === "ignore"));
150
+ const execCalls = calls.filter((c) => c.stdio === "inherit");
151
+ assert.equal(execCalls.length, 0);
152
+ });
153
+
154
+ it("rejects invalid package.json before probing PATH", () => {
155
+ const dir = mkFixture({ packageManager: "pnpm@11.8.0", scripts: { build: "tsc" } });
156
+ fs.writeFileSync(path.join(dir, "package.json"), "null", "utf8");
157
+ let execCalls = 0;
158
+ const code = runPackageScript(dir, "build", {
159
+ execFileSync() {
160
+ execCalls += 1;
161
+ },
162
+ });
163
+ assert.equal(code, 2);
164
+ assert.equal(execCalls, 0);
165
+ fs.rmSync(dir, { recursive: true, force: true });
166
+ });
167
+
168
+ it("rejects unknown scripts before probing PATH", () => {
169
+ const dir = mkFixture({ packageManager: "pnpm@11.8.0", scripts: { build: "tsc" } });
170
+ let execCalls = 0;
171
+ const code = runPackageScript(dir, "lint", {
172
+ execFileSync() {
173
+ execCalls += 1;
174
+ },
175
+ });
176
+ assert.equal(code, 2);
177
+ assert.equal(execCalls, 0);
178
+ fs.rmSync(dir, { recursive: true, force: true });
179
+ });
180
+
181
+ it("uses cmd.exe on win32 without shell:true on the Node spawn", () => {
182
+ if (process.platform !== "win32") {
183
+ return;
184
+ }
185
+ /** @type {{ cmd?: string, args?: string[], shell?: boolean } | null} */
186
+ let recorded = null;
187
+ executeAllowlisted(
188
+ (cmd, args, opts) => {
189
+ recorded = { cmd, args: [...args], shell: opts?.shell };
190
+ },
191
+ "pnpm",
192
+ ["run", "build"],
193
+ { cwd: process.cwd() },
194
+ );
195
+ assert.ok(recorded);
196
+ assert.equal(recorded.cmd, "cmd.exe");
197
+ assert.equal(recorded.args[0], "/d");
198
+ assert.equal(recorded.shell, false);
199
+ assert.match(recorded.args[3], /^pnpm run build$|^"pnpm" "run" "build"$/);
200
+ });
201
+ });
package/tasks/engine.yml CHANGED
@@ -20,71 +20,7 @@ tasks:
20
20
  cmds:
21
21
  - |
22
22
  set -eu
23
- node -e "
24
- const {execFileSync}=require('child_process');
25
- const fs=require('fs');
26
- const root=process.argv[1];
27
- const script=process.argv[2];
28
- const pkgPath=root+'/package.json';
29
- if(!fs.existsSync(pkgPath)){
30
- console.error('deft: package.json missing at '+root);
31
- process.exit(2);
32
- }
33
- const pkg=JSON.parse(fs.readFileSync(pkgPath,'utf8'));
34
- // Cross-platform probe (#2415): do NOT use Unix `sh -c 'command -v'` —
35
- // Windows native Task often has no `sh` on PATH, so Corepack.cmd was
36
- // invisible after #2411. Prefer a direct spawn (POSIX / real binaries);
37
- // fall back to shell:true so PATHEXT resolves .cmd/.exe on win32.
38
- // windowsHide (#2563): CREATE_NO_WINDOW so Cursor Task shells do not
39
- // flood visible cmd.exe/conhost windows on every probe/build.
40
- const spawnOpts=(extra)=>({stdio:'ignore',windowsHide:true,...extra});
41
- const hasCmd=(name)=>{
42
- try{
43
- execFileSync(name,['--version'],spawnOpts());
44
- return true;
45
- }catch{
46
- try{
47
- execFileSync(name,['--version'],spawnOpts({shell:true}));
48
- return true;
49
- }catch{
50
- return false;
51
- }
52
- }
53
- };
54
- const run=(cmd,args)=>{
55
- execFileSync(cmd,args,{cwd:root,stdio:'inherit',shell:true,windowsHide:true});
56
- };
57
- const trySteps=(steps)=>{
58
- for(const [cmd,args] of steps){
59
- try{
60
- run(cmd,args);
61
- process.exit(0);
62
- }catch{
63
- // fall through to Corepack / next resolver
64
- }
65
- }
66
- };
67
- const envPm=String(process.env.DEFT_PACKAGE_MANAGER||'').trim().toLowerCase();
68
- if(envPm==='npm'){
69
- run('npm',['run',script]);
70
- process.exit(0);
71
- }
72
- const pin=String(pkg.packageManager||'').trim();
73
- const match=pin.match(/^pnpm@(.+)$/);
74
- const steps=[];
75
- if(hasCmd('pnpm')) steps.push(['pnpm',['run',script]]);
76
- if(hasCmd('corepack')&&match) steps.push(['corepack',['pnpm@'+match[1],'run',script]]);
77
- if(hasCmd('corepack')) steps.push(['corepack',['pnpm','run',script]]);
78
- trySteps(steps);
79
- console.error('deft: neither pnpm nor corepack is available to run \"'+script+'\".');
80
- if(pin){
81
- console.error(' Enable Corepack for the pinned manager: corepack enable && corepack prepare '+pin+' --activate');
82
- }else{
83
- console.error(' Install pnpm or enable Corepack (see package.json#packageManager).');
84
- }
85
- console.error(' Or set DEFT_PACKAGE_MANAGER=npm for an explicit npm build path.');
86
- process.exit(127);
87
- " "{{.DEFT_ROOT}}" "{{.PM_SCRIPT}}"
23
+ node "{{.TASKFILE_DIR}}/engine-pm-run.cjs" "{{.DEFT_ROOT}}" "{{.PM_SCRIPT}}"
88
24
 
89
25
  _ts-build:
90
26
  internal: true
@@ -115,76 +51,7 @@ tasks:
115
51
  if node "{{.TASKFILE_DIR}}/ts-build-fresh.cjs" "{{.DEFT_ROOT}}"; then
116
52
  exit 0
117
53
  fi
118
- node -e "
119
- const {execFileSync}=require('child_process');
120
- const fs=require('fs');
121
- const root=process.argv[1];
122
- const script='build';
123
- const pkgPath=root+'/package.json';
124
- const pkg=JSON.parse(fs.readFileSync(pkgPath,'utf8'));
125
- // Cross-platform probe (#2415): do NOT use Unix `sh -c 'command -v'` —
126
- // Windows native Task often has no `sh` on PATH, so Corepack.cmd was
127
- // invisible after #2411. Prefer a direct spawn (POSIX / real binaries);
128
- // fall back to shell:true so PATHEXT resolves .cmd/.exe on win32.
129
- // windowsHide (#2563): CREATE_NO_WINDOW so Cursor Task shells do not
130
- // flood visible cmd.exe/conhost windows on every probe/build.
131
- const spawnOpts=(extra)=>({stdio:'ignore',windowsHide:true,...extra});
132
- const hasCmd=(name)=>{
133
- try{
134
- execFileSync(name,['--version'],spawnOpts());
135
- return true;
136
- }catch{
137
- try{
138
- execFileSync(name,['--version'],spawnOpts({shell:true}));
139
- return true;
140
- }catch{
141
- return false;
142
- }
143
- }
144
- };
145
- const run=(cmd,args)=>{
146
- execFileSync(cmd,args,{cwd:root,stdio:'inherit',shell:true,windowsHide:true});
147
- };
148
- const markWarm=()=>{
149
- try{
150
- const dist=root+'/packages/cli/dist';
151
- fs.mkdirSync(dist,{recursive:true});
152
- fs.writeFileSync(dist+'/.deft-ts-build-stamp',new Date().toISOString());
153
- }catch{}
154
- };
155
- const trySteps=(steps)=>{
156
- for(const [cmd,args] of steps){
157
- try{
158
- run(cmd,args);
159
- markWarm();
160
- process.exit(0);
161
- }catch{
162
- // fall through to Corepack / next resolver
163
- }
164
- }
165
- };
166
- const envPm=String(process.env.DEFT_PACKAGE_MANAGER||'').trim().toLowerCase();
167
- if(envPm==='npm'){
168
- run('npm',['run',script]);
169
- markWarm();
170
- process.exit(0);
171
- }
172
- const pin=String(pkg.packageManager||'').trim();
173
- const match=pin.match(/^pnpm@(.+)$/);
174
- const steps=[];
175
- if(hasCmd('pnpm')) steps.push(['pnpm',['run',script]]);
176
- if(hasCmd('corepack')&&match) steps.push(['corepack',['pnpm@'+match[1],'run',script]]);
177
- if(hasCmd('corepack')) steps.push(['corepack',['pnpm','run',script]]);
178
- trySteps(steps);
179
- console.error('deft: neither pnpm nor corepack is available to run \"'+script+'\".');
180
- if(pin){
181
- console.error(' Enable Corepack for the pinned manager: corepack enable && corepack prepare '+pin+' --activate');
182
- }else{
183
- console.error(' Install pnpm or enable Corepack (see package.json#packageManager).');
184
- }
185
- console.error(' Or set DEFT_PACKAGE_MANAGER=npm for an explicit npm build path.');
186
- process.exit(127);
187
- " "{{.DEFT_ROOT}}"
54
+ node "{{.TASKFILE_DIR}}/engine-pm-run.cjs" "{{.DEFT_ROOT}}" build --mark-warm
188
55
  fi
189
56
 
190
57
  invoke:
package/tasks/scm.yml CHANGED
@@ -113,6 +113,16 @@ tasks:
113
113
  vars:
114
114
  ENGINE_CMD: 'github-body issue-edit {{.CLI_ARGS}}'
115
115
 
116
+ body:issue:fetch:
117
+ desc: "[#2607] Fetch live issue body to UTF-8 --out-file for safe read-modify-write"
118
+ dir: '{{.USER_WORKING_DIR}}'
119
+ deps:
120
+ - task: :engine:_ts-build
121
+ cmds:
122
+ - task: :engine:invoke
123
+ vars:
124
+ ENGINE_CMD: 'github-body issue-fetch {{.CLI_ARGS}}'
125
+
116
126
  body:comment:create:
117
127
  desc: "[#1555] Safely create an issue/PR comment body from --body-file and live gh read-back"
118
128
  dir: '{{.USER_WORKING_DIR}}'
package/tasks/verify.yml CHANGED
@@ -59,6 +59,16 @@ tasks:
59
59
  vars:
60
60
  ENGINE_CMD: 'verify-content-manifest --project-root "{{.DEFT_ROOT}}"'
61
61
 
62
+ skill-external-fetch-gate:
63
+ desc: "Verify shipped skills do not pair external fetch/follow-through with execute/install without Security context mitigation (#1936 / #1532)."
64
+ deps:
65
+ - task: :engine:_ts-build
66
+ # Framework-source-only gate: scans THIS repo's content/skills tree.
67
+ cmds:
68
+ - task: :engine:invoke
69
+ vars:
70
+ ENGINE_CMD: 'verify-skill-external-fetch-gate --project-root "{{.DEFT_ROOT}}"'
71
+
62
72
  contract-drift:
63
73
  desc: "Drift gate for the public contract layer (#1799). Asserts packages/types/schemas/vbrief-core-0.6.schema.json matches content/vbrief/schemas/vbrief-core.schema.json and that @deftai/directive-types Status/version constants align with the schema. Three-state exit (0 clean / 1 drift / 2 config error)."
64
74
  deps:
@@ -393,6 +403,16 @@ tasks:
393
403
  vars:
394
404
  ENGINE_CMD: 'verify:wip-cap --project-root "{{.USER_WORKING_DIR}}" {{.CLI_ARGS}}'
395
405
 
406
+ orphan-active:
407
+ desc: "Fail-closed orphan-active guard (#2321). Detects xbrief/active/ briefs with plan.status==running whose referenced GitHub issues are all closed and/or whose linked PR is merged — the stop-at:pr-open lifecycle leak. Remediation points at task scope:complete / scope:cancel or swarm finalize surfaces. Three-state exit (0 clean / 1 orphan / 2 config). Pass --skip-gh to rely on triage cache only."
408
+ dir: '{{.USER_WORKING_DIR}}'
409
+ deps:
410
+ - task: :engine:_ts-build
411
+ cmds:
412
+ - task: :engine:invoke
413
+ vars:
414
+ ENGINE_CMD: 'verify:orphan-active --project-root "{{.USER_WORKING_DIR}}" {{.CLI_ARGS}}'
415
+
396
416
  agents-md-budget:
397
417
  desc: "Layered AGENTS.md budget instrument (#645 + #2450). Fail-closed relative ratchet: counts the managed section and the unmanaged region separately (the #1309 propagation duplicates content across the marker) and fails when either region grows past plan.policy.agentsMdBudget. Seeded at current size, so it ships green; growth past the ratchet fails. ADVISORY absolute north-star: also reports managed-section size vs ≤8 KB / ~2k tok (#2372 layered instrument) without affecting exit codes in Wave 1. Three-state exit (0 within / 1 over ratchet / 2 config error)."
398
418
  dir: '{{.USER_WORKING_DIR}}'
@@ -233,6 +233,8 @@ Reference: issue #2563; swarm skill Platform Requirements; env scrub + stdio inh
233
233
 
234
234
  ! Multi-line git commit / gh issue|pr|comment bodies: write UTF-8 (no BOM) to OS temp, then `git commit -F` / `gh --body-file` / `deft scm:body:* --body-file`. ⊗ bash heredocs, `<<<`, inline multi-line `--body`, or multi-line PS here-strings in the agent command box on Windows PowerShell — those patterns fail at parse time, split arguments, or get rewritten by host shell wrappers before git/gh runs. This applies to your own commit and PR tooling on win32; do not use bash heredocs even when user rules show POSIX patterns. `ghx` is read-only — mutations stay on live `gh`. Detail: `content/scm/github.md` § #2646 (#1417, #240, #798).
235
235
 
236
+ ! Issue-body read-modify-write on win32: `task scm:body:issue:fetch --out-file` then edit the body file then `task scm:body:issue:edit --body-file` (fail-closed postcondition verify, #2607). ⊗ Capture-concat of `gh api repos/.../issues/<N> --jq .body` into PowerShell variables — PS string[]/$OFS collapses newlines to spaces and silently destroys live bodies (#2744, #2087, #2741, #1492). Detail: `content/scm/github.md` § #2744.
237
+
236
238
  ## 4. pre-pr and review-cycle skills
237
239
 
238
240
  Before pushing any branch:
@@ -326,6 +328,7 @@ Use the canonical safe wrapper for issue bodies, PR bodies, and issue/PR comment
326
328
  task scm:body:comment:create -- --repo OWNER/REPO --issue 1555 --body-file "$bodyFile"
327
329
  task scm:body:comment:edit -- --repo OWNER/REPO --comment 123456789 --body-file "$bodyFile"
328
330
  task scm:body:issue:create -- --repo OWNER/REPO --title "Title" --body-file "$bodyFile"
331
+ task scm:body:issue:fetch -- --repo OWNER/REPO --issue 1555 --out-file "$bodyFile"
329
332
  task scm:body:issue:edit -- --repo OWNER/REPO --issue 1555 --body-file "$bodyFile"
330
333
  task scm:body:pr:edit -- --repo OWNER/REPO --pr 42 --body-file "$bodyFile"
331
334
  ```
@@ -446,8 +449,10 @@ These rules bind **orchestrators** dispatching implementation, fix, or review-cy
446
449
  **Worker-owns-lifecycle (Gap C):**
447
450
 
448
451
  - ! When dispatching an implementation worker, the dispatch envelope MUST declare the unit-of-work boundary explicitly: `stop-at: pr-open` (worker opens PR and exits) OR `drive-to: merge-ready` (worker owns PR + Greptile review cycle + fix batches through merge-ready as ONE unit of work, spawning its own review poller per `skills/deft-directive-review-cycle/SKILL.md` monitoring tiers). Default for story implementation dispatches is `drive-to: merge-ready`.
452
+ - ! **Post-merge scope lifecycle (#2321 / Gap C):** Workers scoped `stop-at: pr-open` MUST NOT run `task scope:complete` before exit — their activation checkpoint rides into master on merge. The **orchestrator** (or Phase 6 `task swarm:finalize-cohort` / `task swarm:complete-cohort` on the headless path) MUST run `task scope:complete` or `task scope:cancel` for each shipped story xBRIEF after its PR merges. Workers scoped `drive-to: merge-ready` (or `drive-to: merge`) MUST include `task scope:complete` on their active xBRIEF as part of the same unit of work (after merge when appropriate).
449
453
  - ! Workers scoped `drive-to: merge-ready` MUST drive to merge-ready in their own tool loop — pre-PR, push, PR open, review-cycle poll/fix loop, and the #1259 Step 6 fail-closed exit — without handing back at PR-open for the orchestrator to re-dispatch separate leaf agents for review or fixes.
450
454
  - ⊗ Re-dispatch a separate review-monitor or fix agent after an implementation worker exits at PR-open when the original envelope scoped `drive-to: merge-ready` — that split recreates cross-agent state-handoff hazards and terminal lifecycle gaps (#1878 / Gap C).
455
+ - ⊗ Leave an `xbrief/active/` brief with `plan.status == running` on master after the story's issue is closed or its PR merged — `task verify:orphan-active` fails closed on that signature (#2321).
451
456
 
452
457
  **Background / independent dispatch (Gap D):**
453
458
 
@@ -83,9 +83,9 @@ Legacy `vbrief/` read-accepted; `deft migrate:xbrief` for `xbrief/` (v0.6→v0.8
83
83
 
84
84
  ! When `plan.policy.allowDirectCommitsToMaster = true`, surface via `deft policy:show --field=allowDirectCommitsToMaster` (#746) — `.deft/core/scm/github.md` § Branch policy.
85
85
 
86
- ## Windows PowerShell: multi-line git/gh bodies (#2646)
86
+ ## Windows PowerShell: multi-line git/gh bodies (#2646 / #2744)
87
87
 
88
- ! Multi-line git commit / gh issue|pr|comment bodies: write UTF-8 (no BOM) to OS temp, then `git commit -F` / `gh --body-file` / `deft scm:body:* --body-file`. ⊗ bash heredocs, `<<<`, or inline multi-line `--body` on Windows PowerShell. Detail: `.deft/core/scm/github.md` § #2646. `ghx` is read-only — mutations stay on live `gh`.
88
+ ! Multi-line git commit / gh issue|pr|comment bodies: write UTF-8 (no BOM) to OS temp, then `git commit -F` / `gh --body-file` / `deft scm:body:* --body-file`. Issue-body RMW on win32: `deft scm:body:issue:fetch --out-file` then edit the file then `deft scm:body:issue:edit --body-file` (#2607 postcondition verify). ⊗ bash heredocs, `<<<`, inline multi-line `--body`, or PS capture-concat of `gh api --jq .body` (string[]/$OFS destroys bodies — #2087, #2741, #1492). Detail: `.deft/core/scm/github.md` § #2646 / #2744. `ghx` is read-only — mutations stay on live `gh`.
89
89
 
90
90
  ## Contextual guardrails (runtime-detect lazy-load)
91
91