@sabaiway/agent-workflow-kit 1.43.0 → 1.45.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +8 -4
  3. package/SKILL.md +20 -8
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +87 -16
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +4 -2
  8. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  9. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  10. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -0
  11. package/bridges/codex-cli-bridge/bin/codex-review.sh +90 -19
  12. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +4 -0
  13. package/bridges/codex-cli-bridge/capability.json +3 -2
  14. package/bridges/codex-cli-bridge/references/driving-codex.md +3 -2
  15. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
  16. package/capability.json +1 -1
  17. package/package.json +1 -1
  18. package/references/modes/autonomy-doctor.md +21 -0
  19. package/references/modes/bootstrap.md +1 -1
  20. package/references/modes/doc-parity.md +2 -1
  21. package/references/modes/grounding.md +8 -7
  22. package/references/modes/recommendations.md +14 -0
  23. package/references/modes/review-state.md +1 -1
  24. package/references/modes/sandbox-masks.md +15 -0
  25. package/references/modes/upgrade.md +10 -7
  26. package/references/modes/velocity.md +13 -2
  27. package/references/shared/composition-handoff.md +21 -16
  28. package/references/shared/report-footer.md +5 -1
  29. package/references/templates/AGENTS.md +2 -1
  30. package/references/templates/autonomy.json +3 -0
  31. package/tools/autonomy-config.mjs +13 -3
  32. package/tools/autonomy-doctor.mjs +488 -0
  33. package/tools/commands.mjs +24 -3
  34. package/tools/delegation.mjs +9 -5
  35. package/tools/detect-backends.mjs +2 -2
  36. package/tools/doc-parity.mjs +31 -2
  37. package/tools/engine-source.mjs +6 -0
  38. package/tools/family-registry.mjs +21 -7
  39. package/tools/fold-completeness-run.mjs +5 -0
  40. package/tools/grounding.mjs +139 -22
  41. package/tools/inject-methodology.mjs +117 -28
  42. package/tools/manifest/schema.md +20 -0
  43. package/tools/manifest/validate.mjs +26 -0
  44. package/tools/procedures.mjs +61 -8
  45. package/tools/recipes.mjs +94 -15
  46. package/tools/recommendations.mjs +469 -0
  47. package/tools/review-ledger-write.mjs +14 -0
  48. package/tools/review-ledger.mjs +3 -2
  49. package/tools/review-state.mjs +101 -24
  50. package/tools/run-gates.mjs +3 -0
  51. package/tools/sandbox-masks.mjs +370 -0
  52. package/tools/set-recipe.mjs +13 -1
  53. package/tools/velocity-profile.mjs +231 -24
@@ -28,7 +28,7 @@ const invocationOf = (token) => `${BARE_INVOCATION}${token ? ` ${token}` : ''}`;
28
28
  // ── kinds ────────────────────────────────────────────────────────────────────────
29
29
  // read-only — never writes, never commits, never runs a subscription CLI.
30
30
  // writer — writes files (a project deployment or a settings/skill placement).
31
- // guarded — a destructive teardown gated behind a mandatory dry-run-first + explicit consent.
31
+ // guarded — consent-gated destructive/privileged actions (dry-run-first + explicit consent).
32
32
  // project-exec — the kit itself writes nothing, but the mode RUNS the project's own declared
33
33
  // commands with the caller's privileges (the `gates` runner) — honest-tagged so a
34
34
  // user never reads "read-only" on a surface that executes their gate matrix.
@@ -98,6 +98,13 @@ const CATALOG = [
98
98
  kind: READ_ONLY,
99
99
  oneLine: 'List every command, grouped, marking each as read-only or as one that makes changes.',
100
100
  },
101
+ {
102
+ key: 'recommendations',
103
+ invocation: invocationOf('recommendations'),
104
+ group: 'Inspect',
105
+ kind: READ_ONLY,
106
+ oneLine: 'See what in this deployment is configured sub-optimally — each item with a one-line benefit and the exact consent-gated command that applies it; renders even when everything is optimal.',
107
+ },
101
108
  {
102
109
  key: 'gates',
103
110
  invocation: invocationOf('gates'),
@@ -140,6 +147,20 @@ const CATALOG = [
140
147
  kind: GUARDED,
141
148
  oneLine: 'Read or change the host-level bridge settings (e.g. the codex Fast tier) — a KEY=VALUE file that survives kit upgrades; previews first, and the Fast tier carries its extra-cost caveat.',
142
149
  },
150
+ {
151
+ key: 'autonomy-doctor',
152
+ invocation: invocationOf('autonomy-doctor'),
153
+ group: 'Configure',
154
+ kind: GUARDED,
155
+ oneLine: 'Check whether this machine can run the Claude sandbox and, only with your explicit consent, install what is missing — privileged, preview-first, consent-per-run; it never auto-runs and never commits.',
156
+ },
157
+ {
158
+ key: 'sandbox-masks',
159
+ invocation: invocationOf('sandbox-masks'),
160
+ group: 'Configure',
161
+ kind: GUARDED,
162
+ oneLine: 'Hide the sandbox’s injected device masks from git status via one managed block in the repo’s local exclude file — probe-derived (never a frozen list), preview first, full-block replace on your yes; reviews already ignore the masks by construction.',
163
+ },
143
164
  {
144
165
  key: 'recipes',
145
166
  invocation: invocationOf('recipes'),
@@ -180,7 +201,7 @@ const CATALOG = [
180
201
  invocation: invocationOf('grounding'),
181
202
  group: 'Orchestrate',
182
203
  kind: WRITER,
183
- oneLine: 'Assemble the verified-facts payload a grounded review runs against — the entry-point Hard Constraints plus a plan’s decision sections; prints it, or writes ONE scratch file with --out.',
204
+ oneLine: 'Assemble the verified-facts payload a grounded review runs against — the entry-point Hard Constraints, the effective autonomy policy, plus a plan’s decision sections; prints it, or writes ONE scratch file with --out.',
184
205
  },
185
206
  {
186
207
  key: 'review-ledger',
@@ -274,7 +295,7 @@ export const formatHelp = () => {
274
295
  const lines = [
275
296
  `${SKILL_NAME} — command index (this list is read-only)`,
276
297
  '',
277
- 'Each command is tagged read-only · writer (makes changes) · guarded (destructive, previews first) · runs project cmds (executes your own declared commands).',
298
+ 'Each command is tagged read-only · writer (makes changes) · guarded (consent-gated destructive/privileged actions, previews first) · runs project cmds (executes your own declared commands).',
278
299
  ];
279
300
  for (const group of GROUP_ORDER) {
280
301
  const inGroup = COMMANDS.filter((c) => c.group === group);
@@ -27,6 +27,10 @@ export const EXPECTED_MEMORY_NAME = 'agent-workflow-memory';
27
27
  // DOES seed orchestration.json (Mode: upgrade step 3). This closes the stale-memory trap that the
28
28
  // read-only family-registry note (MEMORY_ORCH_TEMPLATE_REL) only INFORMS about — the gate ACTS. The
29
29
  // two key on the same asset; a cross-tool parity test pins them in lockstep.
30
+ // The autonomy seed (AD-044 Plan 4) is deliberately NOT here: the Plan-3 decision stands — no
31
+ // detectMemory autonomy-marker gate. A memory too old to ship references/templates/autonomy.json
32
+ // stays delegable; the kit-side upgrade ensure seeds the policy from the kit's own template twin,
33
+ // and the family-registry caveat INFORMS about the stale memory (inform, never gate).
30
34
  export const REQUIRED_MEMORY_ASSETS = [
31
35
  { path: 'references/templates', type: 'dir' },
32
36
  { path: 'references/templates/orchestration.json', type: 'file' },
@@ -85,7 +89,7 @@ export const handoffPlan = (delegate) =>
85
89
  memoryWrites: ['docs/ai/', 'AGENTS.md', 'docs/ai/.memory-version'],
86
90
  // The lens region runs AFTER the substrate deploy (its own precondition: the file exists)
87
91
  // — it converges a stale-memory seed to the installed engine's canon (AD-041).
88
- kitWrites: ['AGENTS.md methodology slot', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
92
+ kitWrites: ['AGENTS.md pointer slots (methodology / orchestration / autonomy)', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
89
93
  stampsPresent: ['.memory-version', '.workflow-version'],
90
94
  memoryRaisesCommitGate: false,
91
95
  commitGate: 'kit-only-after-injection',
@@ -93,11 +97,11 @@ export const handoffPlan = (delegate) =>
93
97
  : {
94
98
  mode: 'fallback',
95
99
  memoryWrites: [],
96
- // Fallback now ships the kit's OWN AGENTS.md carrying the EMPTY methodology slot (Plan 2);
97
- // the kit reconciles it (ensure-slot + inject-because-empty) exactly like the delegate
98
- // path — so both paths end with a FILLED slot, not inline methodology. The lens region
100
+ // Fallback now ships the kit's OWN AGENTS.md carrying the EMPTY pointer slots (Plan 2);
101
+ // the kit reconciles them (ensure-slot + inject-because-empty) exactly like the delegate
102
+ // path — so both paths end with FILLED slots, not inline methodology. The lens region
99
103
  // runs after the fallback-template copy of docs/ai (same reconcile, both paths — AD-041).
100
- kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology slot', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
104
+ kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md pointer slots (methodology / orchestration / autonomy)', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
101
105
  stampsPresent: ['.workflow-version'],
102
106
  memoryRaisesCommitGate: false,
103
107
  commitGate: 'kit-only-after-injection',
@@ -88,7 +88,7 @@ const RAW_BACKENDS = [
88
88
  ],
89
89
  grounding: 'automatic — the wrapper precomputes the full working-tree change set (repo map, status, diffs, untracked contents) and codex auto-merges the root AGENTS.md; no grounding flags',
90
90
  continue: [],
91
- receipt: 'side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain) in code mode, the artifact-file sha256 in plan mode; verdict parsed from the mandated literal verdict line (schema mode: the verdict field); always fresh:true (one-shot) + grounded:true (native AGENTS.md auto-merge, factsHash null); a write failure warns, never fails the review',
91
+ receipt: 'side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan mode; verdict parsed from the mandated literal verdict line (schema mode: the verdict field); always fresh:true (one-shot) + grounded:true (native AGENTS.md auto-merge, factsHash null); a write failure warns, never fails the review',
92
92
  },
93
93
  },
94
94
  bin: 'codex',
@@ -121,7 +121,7 @@ const RAW_BACKENDS = [
121
121
  'agy-review --continue [--decided @f] [--focus "…"]',
122
122
  'agy-review --conversation <id> [--decided @f] [--focus "…"]',
123
123
  ],
124
- receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); a write failure warns, never fails the review",
124
+ receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); a write failure warns, never fails the review",
125
125
  },
126
126
  },
127
127
  bin: 'agy',
@@ -26,11 +26,16 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
26
26
  import { SCHEMA_VERSION, REVIEW_CAP, V4_CLASSES, V4_OVERRIDE_SCOPES } from './review-ledger.mjs';
27
27
  import { HARD_MAX, DEFAULT_DIFF_CAP } from './review-ledger-write.mjs';
28
28
  import { RESULT_SCHEMA_VERSION } from './fold-completeness.mjs';
29
+ import { EXIT as DOCTOR_EXIT, STATUS as DOCTOR_STATUS, TRUSTED_DIRS as DOCTOR_TRUSTED_DIRS } from './autonomy-doctor.mjs';
30
+ import { RECOMMENDATIONS_SECTION_HEADER, RECOMMENDATIONS_EMPTY_LINE } from './recommendations.mjs';
29
31
 
30
32
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
31
33
 
32
34
  const REVIEW_LEDGER_DOC = 'references/modes/review-ledger.md';
33
35
  const FOLD_DOC = 'references/modes/fold-completeness.md';
36
+ const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
37
+ const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
38
+ const UPGRADE_DOC = 'references/modes/upgrade.md';
34
39
 
35
40
  // A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
36
41
  const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -45,6 +50,19 @@ const valueBinding = (constant, value, phrase, files) => ({ constant, value, tok
45
50
  // so the closed set can never disagree with the code. Every word must appear in the ledger contract.
46
51
  const LEDGER_VOCAB = [...V4_CLASSES, ...V4_OVERRIDE_SCOPES, 'gate-run'];
47
52
 
53
+ // The autonomy-doctor D7 contract (AD-044 Plan 2): the live EXIT table + every status token must
54
+ // render into the mode's contract doc. `usage` is skipped as a bare-word token (trivially present
55
+ // everywhere) — its exit-code phrase below pins that outcome instead.
56
+ const DOCTOR_EXIT_PHRASES = [
57
+ ['ready', `\`${DOCTOR_EXIT.ready}\` ready`],
58
+ ['stop', `\`${DOCTOR_EXIT.stop}\` precondition STOP`],
59
+ ['usage', `\`${DOCTOR_EXIT.usage}\` usage`],
60
+ ['notReady', `\`${DOCTOR_EXIT.notReady}\` not-ready diagnosis`],
61
+ ['installFailed', `\`${DOCTOR_EXIT.installFailed}\` install failed`],
62
+ ['verifyFailed', `\`${DOCTOR_EXIT.verifyFailed}\` verify failed`],
63
+ ['unsupported', `\`${DOCTOR_EXIT.unsupported}\` unsupported / untrusted`],
64
+ ];
65
+
48
66
  export const BINDINGS = Object.freeze([
49
67
  valueBinding('SCHEMA_VERSION', SCHEMA_VERSION, `schema v${SCHEMA_VERSION}`, [REVIEW_LEDGER_DOC]),
50
68
  valueBinding('HARD_MAX', HARD_MAX, `hard-max ceiling of ${HARD_MAX}`, [REVIEW_LEDGER_DOC]),
@@ -52,6 +70,16 @@ export const BINDINGS = Object.freeze([
52
70
  valueBinding('REVIEW_CAP', REVIEW_CAP, `cap ≤${REVIEW_CAP}`, [REVIEW_LEDGER_DOC]),
53
71
  valueBinding('RESULT_SCHEMA_VERSION', RESULT_SCHEMA_VERSION, `schema v${RESULT_SCHEMA_VERSION}`, [FOLD_DOC]),
54
72
  ...LEDGER_VOCAB.map((word) => valueBinding(`vocab:${word}`, word, word, [REVIEW_LEDGER_DOC])),
73
+ ...DOCTOR_EXIT_PHRASES.map(([key, phrase]) => valueBinding(`doctor-exit:${key}`, DOCTOR_EXIT[key], phrase, [AUTONOMY_DOCTOR_DOC])),
74
+ ...Object.values(DOCTOR_STATUS)
75
+ .filter((token) => token !== DOCTOR_STATUS.usage)
76
+ .map((token) => valueBinding(`doctor-status:${token}`, token, token, [AUTONOMY_DOCTOR_DOC])),
77
+ valueBinding('doctor-trusted-dirs', DOCTOR_TRUSTED_DIRS.join(':'), DOCTOR_TRUSTED_DIRS.join(':'), [AUTONOMY_DOCTOR_DOC]),
78
+ // The upgrade Recommendations section contract (AD-044 Plan 4): the section header and the
79
+ // exact empty-state line must render in BOTH the mode doc and upgrade.md (both exits reference
80
+ // them) — a reworded doc would silently break the paste-verbatim contract.
81
+ valueBinding('recommendations-header', RECOMMENDATIONS_SECTION_HEADER, RECOMMENDATIONS_SECTION_HEADER, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
82
+ valueBinding('recommendations-empty-line', RECOMMENDATIONS_EMPTY_LINE, RECOMMENDATIONS_EMPTY_LINE, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
55
83
  ].map((b) => Object.freeze(b)));
56
84
 
57
85
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -96,8 +124,9 @@ Usage:
96
124
  node doc-parity.mjs [--check | --json]
97
125
 
98
126
  A CLOSED, exported registry binds each live code constant (review-ledger SCHEMA_VERSION / REVIEW_CAP,
99
- review-ledger-write HARD_MAX / DEFAULT_DIFF_CAP, fold-completeness RESULT_SCHEMA_VERSION) and the
100
- ledger vocabulary (V4_CLASSES / V4_OVERRIDE_SCOPES + gate-run) to the exact token its
127
+ review-ledger-write HARD_MAX / DEFAULT_DIFF_CAP, fold-completeness RESULT_SCHEMA_VERSION), the
128
+ ledger vocabulary (V4_CLASSES / V4_OVERRIDE_SCOPES + gate-run), and the autonomy-doctor contract
129
+ (the EXIT table, the status tokens, the trusted-dir allowlist) to the exact token its
101
130
  references/modes/*.md contract must carry, and asserts the CURRENT value renders into every bound
102
131
  file. A drifted doc, an unreadable bound file, or an absent token FAILS CLOSED.
103
132
 
@@ -31,6 +31,12 @@ export const ORCHESTRATION_FRAGMENT_REL = 'references/orchestration-slot.md';
31
31
  // CLI fail loudly on — an engine too old to ship it. readEngineFragment accepts an arbitrary `rel`
32
32
  // (no whitelist), so no further plumbing is needed beyond this constant.
33
33
  export const PROCEDURES_FRAGMENT_REL = 'references/procedures.md';
34
+ // The autonomy-policy slot fragment — the THIRD bounded fragment the kit injects (AD-044 Plan 3),
35
+ // the cross-agent read contract for docs/ai/autonomy.json. An engine released before it does not
36
+ // ship it; detectEngine({ rel }) lets the chained reconcile soft-skip that too-old engine (and
37
+ // `status` caveat it) instead of discarding the methodology/orchestration fills. readEngineFragment
38
+ // accepts an arbitrary `rel`, so no further plumbing is needed beyond this constant.
39
+ export const AUTONOMY_FRAGMENT_REL = 'references/autonomy-slot.md';
34
40
  // The agent-rules lens pair — the canonical lens block + its append-only prior store (engine
35
41
  // >= 1.13.0; an older engine ships neither). The kit's lens-region reconcile live-reads BOTH via
36
42
  // readEngineFragment({ rel }); detectEngine({ rel }) lets it soft-skip a too-old engine (and
@@ -26,7 +26,7 @@ import { resolveDir, detectBackends, findOnPath } from './detect-backends.mjs';
26
26
  import { parseSemver, compareSemver } from './semver-lite.mjs';
27
27
  import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
28
28
  import { START_MARKER, excludePath, inferVisibility } from './hide-footprint.mjs';
29
- import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL, LENS_FRAGMENT_REL, LENS_PRIORS_REL } from './engine-source.mjs';
29
+ import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL, AUTONOMY_FRAGMENT_REL, LENS_FRAGMENT_REL, LENS_PRIORS_REL } from './engine-source.mjs';
30
30
  import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
31
31
  // The config reader lives in orchestration-config.mjs (the single config contract). The read-only status
32
32
  // settings-survey reuses THIS reader (one strict-JSON + loud-on-malformed contract), not a second copy.
@@ -182,6 +182,7 @@ export const classifyMember = (member, deps = {}) => {
182
182
  // caveat keys on both — an engine missing only the prior store must never report healthy.
183
183
  const ENGINE_FRAGMENT_CAVEATS = [
184
184
  { rels: [ORCHESTRATION_FRAGMENT_REL], caveat: 'engine present but does not supply the recipes pointer (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
185
+ { rels: [AUTONOMY_FRAGMENT_REL], caveat: 'engine present but does not supply the autonomy pointer (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
185
186
  { rels: [PROCEDURES_FRAGMENT_REL], caveat: 'engine present but does not ship the activity-procedures canon (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
186
187
  { rels: [LENS_FRAGMENT_REL, LENS_PRIORS_REL], caveat: 'engine present but does not ship the agent-rules lens canon (the fragment + its prior store; too old / incomplete — engine >= 1.13.0) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
187
188
  ];
@@ -192,10 +193,16 @@ const ENGINE_FRAGMENT_CAVEATS = [
192
193
  // adds a parity drift-guard tying this path to that required-asset set, so the note and the gate can
193
194
  // never key on different files (until then they are kept in lockstep by review).
194
195
  export const MEMORY_ORCH_TEMPLATE_REL = 'references/templates/orchestration.json';
196
+ // The autonomy-policy seed (AD-044 Plan 4) gets the SAME inform-never-gate treatment: the
197
+ // delegation gate deliberately does NOT key on it (the Plan-3 decision), so this caveat is the
198
+ // discovery mechanism — the recommendations advisor surfaces it via surveyFamily.
199
+ export const MEMORY_AUTONOMY_TEMPLATE_REL = 'references/templates/autonomy.json';
195
200
  // Worded as an honest OBSERVATION, not a diagnosis (absence can mean old OR incomplete), and it makes
196
201
  // NO claim about an orchestration.json seeding outcome (that depends on delegate-vs-fallback).
197
202
  const MEMORY_BEHIND_NOTE =
198
203
  "the memory installed here doesn't include the current orchestration template — refresh it with `npx @sabaiway/agent-workflow-memory@latest init`, then restart the session.";
204
+ const MEMORY_AUTONOMY_BEHIND_NOTE =
205
+ "the memory installed here doesn't include the current autonomy-policy template — refresh it with `npx @sabaiway/agent-workflow-memory@latest init`, then restart the session.";
199
206
 
200
207
  // ── the bridge freshness probe (deterministic-first — INV-A / INV-B) ─────────────
201
208
  // The bridges are not npm packages: their ONLY delivery channel is the copy bundled inside this kit
@@ -261,12 +268,19 @@ export const surveyFamily = (deps = {}) =>
261
268
  // Only attach when it is provably ABSENT (a non-ENOENT probe error → 'unknown' → skip, never a
262
269
  // false "missing" claim). Mirrors the engine-caveat SHAPE; keyed on the Step-2.4 required asset.
263
270
  if (row.kind === 'memory-substrate' && row.manifestState === OK && row.skillDir) {
264
- const templateProbe = probeMarker(join(row.skillDir, MEMORY_ORCH_TEMPLATE_REL), deps);
265
- if (templateProbe === 'absent') {
266
- row.caveats = [...(row.caveats ?? []), MEMORY_BEHIND_NOTE];
267
- } else if (templateProbe === 'unknown') {
268
- // Could not verify → this row must not be counted "checked, current" by the verdict (INV-B).
269
- row.freshness = FRESH_UNKNOWN;
271
+ // Two required-template probes, same shape: orchestration (Step 2.4) + autonomy (AD-044
272
+ // Plan 4 — inform, never gate). Each attaches its own caveat on provable absence only.
273
+ for (const [rel, note] of [
274
+ [MEMORY_ORCH_TEMPLATE_REL, MEMORY_BEHIND_NOTE],
275
+ [MEMORY_AUTONOMY_TEMPLATE_REL, MEMORY_AUTONOMY_BEHIND_NOTE],
276
+ ]) {
277
+ const templateProbe = probeMarker(join(row.skillDir, rel), deps);
278
+ if (templateProbe === 'absent') {
279
+ row.caveats = [...(row.caveats ?? []), note];
280
+ } else if (templateProbe === 'unknown') {
281
+ // Could not verify → this row must not be counted "checked, current" by the verdict (INV-B).
282
+ row.freshness = FRESH_UNKNOWN;
283
+ }
270
284
  }
271
285
  }
272
286
  // Bridge freshness probe (INV-A / INV-B): only for a provably-OURS placed bridge (manifestState
@@ -1029,6 +1029,11 @@ loud advisory failure) but leaves --check unaffected.
1029
1029
 
1030
1030
  The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
1031
1031
 
1032
+ Sandbox-safe: the RUNNER itself needs no network and writes only repo-local state (the D4 sandbox
1033
+ lane). The spawned suite/bound commands are COMMAND-SHAPE dependent — --suite / AW_FOLD_SUITE_CMD /
1034
+ AW_FOLD_BOUND_CMD / the verification profile can name project-defined commands; the default
1035
+ (node --test on stdout TAP) is plain and no-network — keep overrides sandbox-safe by shape.
1036
+
1032
1037
  Exit codes: 0 written / advisory printed; 1 a typed STOP (loop derivation / suite discovery / a --red
1033
1038
  or --reattest refusal / a malformed SARIF on --findings / malformed record / fs error); 2 usage.`;
1034
1039
 
@@ -19,14 +19,15 @@
19
19
  // in-band marker + stderr report (never a silent cut).
20
20
  //
21
21
  // Catalog honesty: this is a WRITER — `--out <path>` writes a file. Invariant: `--out` accepts
22
- // only gitignored / out-of-repo scratch destinations and REFUSES a tracked path AND an in-repo
22
+ // only system-temp destinations or a FRESH gitignored in-repo path and REFUSES a tracked/in-repo
23
23
  // not-ignored path (a new untracked file would itself move the review fingerprint the facts are
24
24
  // about to ground). stdout is the default. It never commits and never runs a subscription CLI.
25
25
  //
26
26
  // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
27
27
 
28
28
  import { readFileSync, writeFileSync, lstatSync, realpathSync } from 'node:fs';
29
- import { resolve, join, relative, isAbsolute, dirname, basename } from 'node:path';
29
+ import { resolve, join, relative, isAbsolute, dirname, basename, sep } from 'node:path';
30
+ import { tmpdir } from 'node:os';
30
31
  import { pathToFileURL } from 'node:url';
31
32
  import { spawnSync } from 'node:child_process';
32
33
  import { fail } from './orchestration-config.mjs';
@@ -35,6 +36,9 @@ import { fail } from './orchestration-config.mjs';
35
36
  // computeTelemetry give the counts; the segment filter gives the scope.
36
37
  import { ORIGINS, computeTelemetry, filterSegmentRecords, readLedger, resolveLedgerPath, resolveBase } from './review-ledger.mjs';
37
38
  import { plansInFlight } from './review-state.mjs';
39
+ // (f) --autonomy (AD-044 Plan 3): the effective per-project autonomy policy for the facts payload.
40
+ // READ core only — never autonomy-write.mjs (the import-split invariant).
41
+ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
38
42
 
39
43
  const PLAN_EXECUTION = 'plan-execution';
40
44
 
@@ -82,11 +86,15 @@ export const sliceSection = (text, heading, { optional = false, label = 'documen
82
86
 
83
87
  // ── assembly ───────────────────────────────────────────────────────────────────────
84
88
 
85
- export const assembleGrounding = ({ constraintsText = null, planText = null, planLabel = 'plan' } = {}) => {
89
+ export const assembleGrounding = ({ constraintsText = null, autonomyText = null, planText = null, planLabel = 'plan' } = {}) => {
86
90
  const parts = [];
87
91
  if (constraintsText != null) {
88
92
  parts.push(sliceSection(constraintsText, CONSTRAINTS_HEADING, { label: 'AGENTS.md' }));
89
93
  }
94
+ // The autonomy block is COMPUTED (resolveAutonomy over the policy file), not sliced — it rides
95
+ // between the constraints and the plan sections: the natural read order (what always binds →
96
+ // how autonomous this session is → what this plan decides).
97
+ if (autonomyText != null) parts.push(autonomyText);
90
98
  if (planText != null) {
91
99
  for (const { heading, optional } of PLAN_SECTIONS) {
92
100
  const section = sliceSection(planText, heading, { optional, label: planLabel });
@@ -178,6 +186,38 @@ export const resolveLedgerSummary = ({ cwd, env }) => {
178
186
  return renderLedgerSummary(records, { loop, base });
179
187
  };
180
188
 
189
+ // ── (f) --autonomy: the effective autonomy policy as a computed facts block (AD-044 Plan 3) ─────
190
+ // The ONE shared resolver (resolveAutonomy) renders the FULL effective policy — every red-line and
191
+ // every activity resolved, no key undefined — with a stated source line. The policy file resolves
192
+ // from the GIT TOP of cwd (the --ledger-summary gitTop precedent): a cwd-relative read from a
193
+ // subdir would silently emit computed defaults for a policy-carrying repo, the exact silent failure
194
+ // grounding exists to kill. Malformed/unreadable policy fails CLOSED (loadAutonomy throws exit 1);
195
+ // an ABSENT file is NOT an error — the computed defaults ARE the effective policy (deliberately
196
+ // diverging from `velocity-profile --autonomy`'s refuse-on-absent: that surface WRITES settings and
197
+ // demands a declared policy; this one emits informational facts). Enforcement honesty: these facts
198
+ // INFORM a review; enforcement stays the OS sandbox + the orchestrator process (AD-044 D1).
199
+
200
+ export const renderAutonomyFacts = (config, source) => {
201
+ const resolved = resolveAutonomy(config);
202
+ // The STRUCTURAL seed (_README only) reads as computed defaults; an EXPLICIT declared-defaults
203
+ // policy reads as a declared policy (the shared predicate — same contract on every surface).
204
+ const defaultsEquivalent = source !== 'none' && isSparseSeedConfig(config);
205
+ const head =
206
+ source === 'none'
207
+ ? `## Autonomy policy — ${AUTONOMY_REL} absent; the computed defaults ARE the effective policy`
208
+ : defaultsEquivalent
209
+ ? `## Autonomy policy — ${AUTONOMY_REL} present but defaults-equivalent (the sparse seed); the computed defaults ARE the effective policy`
210
+ : `## Autonomy policy — ${AUTONOMY_REL}`;
211
+ const redlines = Object.entries(resolved.redlines).map(([k, v]) => `${k}:${v}`).join(' ');
212
+ const activities = Object.entries(resolved.activities).map(([a, v]) => `${a}:${v.autonomy}`).join(' ');
213
+ return `${head}\n\nred-lines — ${redlines}\nactivities — ${activities}\n`;
214
+ };
215
+
216
+ const resolveAutonomyFacts = ({ cwd }) => {
217
+ const { config, source } = loadAutonomy(gitTop(cwd));
218
+ return renderAutonomyFacts(config, source);
219
+ };
220
+
181
221
  // ── the --out destination guard (gitignored / out-of-repo scratch ONLY) ────────────────
182
222
 
183
223
  const gitLine = (args, cwd) => {
@@ -185,24 +225,35 @@ const gitLine = (args, cwd) => {
185
225
  return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
186
226
  };
187
227
 
188
- // STOP unless `outPath` is a safe scratch destination: outside any git work tree, or gitignored
189
- // inside one. A TRACKED path and an in-repo NOT-ignored path are both refused (a facts file must
190
- // never enter the tree it grounds it would move the fingerprint and could be committed). The
191
- // check runs on the REAL destination, never the lexical path (codex R1 finding): a symlink leaf is
192
- // refused outright, and the parent directory is realpath-resolved first, so a gitignored or
193
- // out-of-repo symlink can never route the write onto a tracked/in-repo file. Returns the resolved
194
- // real path the caller must write to.
228
+ // STOP unless `outPath` is a safe scratch destination the CLOSED surface (this writer is
229
+ // bridge-tier auto-allowable): (a) under a SYSTEM TEMP root outside the repo ($TMPDIR /
230
+ // os.tmpdir() / /tmprewritable), or (b) a FRESH gitignored in-repo path (create-only; an
231
+ // EXISTING in-repo file, even gitignored, is a project file the .env clobber class). Refused:
232
+ // tracked paths, in-repo not-ignored paths (a new untracked file would move the fingerprint),
233
+ // every other outside-repo destination, symlink and existing non-regular leaves, and any
234
+ // unverifiable lstat. The check runs on the REAL destination, never the lexical path (codex R1):
235
+ // the parent directory is realpath-resolved first, so a symlink can never route the write onto a
236
+ // tracked/in-repo file. Returns { path, kind: 'temp' | 'repo-fresh' } — a repo-fresh caller MUST
237
+ // write with the exclusive flag ('wx'), sealing the guard→write race (codex R11).
195
238
  export const assertScratchDestination = (outPath, cwd) => {
196
239
  const lexical = isAbsolute(outPath) ? outPath : resolve(cwd, outPath);
197
240
  let leaf = null;
198
241
  try {
199
242
  leaf = lstatSync(lexical);
200
- } catch {
243
+ } catch (err) {
244
+ // ONLY an absent leaf is a fresh file; any other lstat failure fails CLOSED (codex R9 — this
245
+ // writer is bridge-tier auto-allowable, so an unverifiable leaf must never be written).
246
+ if (err?.code !== 'ENOENT') {
247
+ throw fail(1, `--out cannot inspect the destination (${outPath}: ${err?.code ?? err?.message ?? err}) — refusing to write an unverifiable leaf`);
248
+ }
201
249
  leaf = null; // absent → a fresh file; the parent still gets realpath-checked below
202
250
  }
203
251
  if (leaf?.isSymbolicLink()) {
204
252
  throw fail(1, `--out refuses a symlink destination (${outPath}) — the write would follow it onto another file; name the real scratch path`);
205
253
  }
254
+ if (leaf != null && !leaf.isFile()) {
255
+ throw fail(1, `--out refuses an existing non-regular destination (${outPath}) — a FIFO/device/directory write would hang or land on a non-file (codex R9); name a fresh or regular scratch path`);
256
+ }
206
257
  let realParent;
207
258
  try {
208
259
  realParent = realpathSync(dirname(lexical));
@@ -210,11 +261,31 @@ export const assertScratchDestination = (outPath, cwd) => {
210
261
  throw fail(1, `--out parent directory does not exist (${dirname(lexical)}) — create the scratch dir first`);
211
262
  }
212
263
  const full = join(realParent, basename(lexical));
264
+ // An out-of-repo (or no-repo) destination is scratch ONLY under a system temp root (codex R8
265
+ // blocker): the bridge tier auto-allows this tool with an args wildcard, so "anything outside
266
+ // the repo is scratch" would let an unattended run overwrite e.g. ~/.bashrc promptless.
267
+ // $TMPDIR / os.tmpdir() / /tmp are the scratch surface; everything else refuses loudly.
268
+ const assertTempScratch = () => {
269
+ const tempRoots = [...new Set([tmpdir(), process.env.TMPDIR, '/tmp'].filter(Boolean).map((p) => {
270
+ try {
271
+ return realpathSync(p);
272
+ } catch {
273
+ return null;
274
+ }
275
+ }).filter(Boolean))];
276
+ if (!tempRoots.some((t) => full === t || full.startsWith(`${t}${sep}`))) {
277
+ throw fail(1, `--out refuses an outside-repo destination that is not under a system temp root (${full}) — grounding output is scratch: use $TMPDIR//tmp, or a fresh gitignored in-repo path (temp roots checked: ${tempRoots.join(', ')})`);
278
+ }
279
+ return { path: full, kind: 'temp' };
280
+ };
213
281
  const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
214
- if (top == null || top.status !== 0) return full; // no git tree → plain scratch, fine
282
+ if (top == null || top.status !== 0) return { path: assertTempScratch().path, kind: 'temp' }; // no git tree → temp-only scratch
215
283
  const root = top.stdout.replace(/\r?\n$/, '');
216
284
  const rel = relative(root, full);
217
- if (rel.startsWith('..') || isAbsolute(rel)) return full; // really outside the work tree → scratch
285
+ // Segment-safe outside test (the Issue-004 class, codex R5): an in-repo file literally named
286
+ // `..facts` has rel `..facts` — `startsWith('..')` would misread it as outside and BYPASS the
287
+ // tracked/ignored refusals below. Only `..` exactly or a `..${sep}`-prefixed rel is outside.
288
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { path: assertTempScratch().path, kind: 'temp' };
218
289
  const tracked = gitLine(['ls-files', '--error-unmatch', '--', rel], root);
219
290
  if (tracked != null && tracked.status === 0) {
220
291
  throw fail(1, `--out refuses a TRACKED path (${rel}) — grounding output is scratch; write it to a gitignored path or outside the repo`);
@@ -223,7 +294,15 @@ export const assertScratchDestination = (outPath, cwd) => {
223
294
  if (ignored == null || ignored.status !== 0) {
224
295
  throw fail(1, `--out refuses an in-repo path that is not gitignored (${rel}) — a new untracked file would move the review fingerprint; use a gitignored path or a location outside the repo`);
225
296
  }
226
- return full;
297
+ // An in-repo destination must be a FRESH file (codex R10): even a gitignored existing file is a
298
+ // project file (.env is the canonical victim) — an auto-allowable writer must never clobber one.
299
+ // The rewritable scratch surface is the system temp; in-repo gitignored writes are create-only —
300
+ // the CALLER must open with the exclusive flag ('wx'): the kind seals the pre-check against the
301
+ // guard→write race (codex R11).
302
+ if (leaf != null) {
303
+ throw fail(1, `--out refuses to OVERWRITE an existing in-repo file (${rel}) — even gitignored, it is a project file (the .env class); write rewritable scratch under $TMPDIR//tmp, or remove the file first`);
304
+ }
305
+ return { path: full, kind: 'repo-fresh' };
227
306
  };
228
307
 
229
308
  // ── CLI ────────────────────────────────────────────────────────────────────────────
@@ -231,10 +310,15 @@ export const assertScratchDestination = (outPath, cwd) => {
231
310
  const HELP = `grounding — grounded-review facts assembler for the agent-workflow family (AD-038).
232
311
 
233
312
  Usage:
234
- node grounding.mjs [--constraints] [--plan <path>] [--ledger-summary] [--reserve-bytes <n>] [--out <path>]
313
+ node grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--ledger-summary] [--reserve-bytes <n>] [--out <path>]
235
314
 
236
315
  --constraints slice the root AGENTS.md "Hard Constraints" section verbatim
237
316
  (exactly one matching heading, else a loud STOP)
317
+ --autonomy append the COMPUTED effective autonomy policy (resolveAutonomy over the
318
+ git-top docs/ai/autonomy.json): every red-line + per-activity level, with a
319
+ stated source line; absent file → the computed defaults ARE the policy
320
+ (exit 0); malformed/unreadable → fail-closed STOP (exit 1); informational —
321
+ enforcement stays the sandbox + the orchestrator
238
322
  --plan <path> extract the plan's decision-bearing sections verbatim + whole:
239
323
  "## Approach" + "## Verification" (REQUIRED — STOP if missing),
240
324
  "## Decisions (locked)" when present; a duplicate heading is a STOP
@@ -244,7 +328,8 @@ Usage:
244
328
  loud STOP unless exactly one plan is in flight
245
329
  --reserve-bytes <n> the artifact share agy-review will add around these facts — the output
246
330
  budget becomes AGY_MAX_PROMPT_BYTES − n (loud tail-trim on overflow)
247
- --out <path> write instead of stdout — gitignored / out-of-repo scratch ONLY
331
+ --out <path> write instead of stdout — system-temp scratch (rewritable), or a FRESH
332
+ gitignored in-repo path (create-only); everything else refuses
248
333
  (a tracked or in-repo not-ignored path is refused)
249
334
 
250
335
  Feed the output to the review wrapper: agy-review code --facts @<out>. AGY_MAX_PROMPT_BYTES
@@ -256,6 +341,7 @@ Exit codes: 0 success; 2 usage; 1 STOP (missing/duplicate section, unreadable fi
256
341
 
257
342
  const parseArgs = (argv) => {
258
343
  let constraints = false;
344
+ let autonomy = false;
259
345
  let plan = null;
260
346
  let out = null;
261
347
  let reserve = 0;
@@ -263,6 +349,7 @@ const parseArgs = (argv) => {
263
349
  for (let i = 0; i < argv.length; i += 1) {
264
350
  const a = argv[i];
265
351
  if (a === '--constraints') constraints = true;
352
+ else if (a === '--autonomy') autonomy = true;
266
353
  else if (a === '--ledger-summary') ledgerSummary = true;
267
354
  else if (a === '--plan') {
268
355
  plan = argv[i + 1];
@@ -279,8 +366,10 @@ const parseArgs = (argv) => {
279
366
  i += 1;
280
367
  } else throw fail(2, `unknown argument: ${a}`);
281
368
  }
282
- if (!constraints && plan == null && !ledgerSummary) throw fail(2, 'nothing to assemble — pass --constraints, --plan <path>, and/or --ledger-summary');
283
- return { constraints, plan, out, reserve, ledgerSummary };
369
+ if (!constraints && !autonomy && plan == null && !ledgerSummary) {
370
+ throw fail(2, 'nothing to assemble — pass --constraints, --autonomy, --plan <path>, and/or --ledger-summary');
371
+ }
372
+ return { constraints, autonomy, plan, out, reserve, ledgerSummary };
284
373
  };
285
374
 
286
375
  const resolveBudget = (env, reserve) => {
@@ -300,7 +389,7 @@ export const main = (argv, ctx = {}) => {
300
389
  const env = ctx.env ?? process.env;
301
390
  try {
302
391
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
303
- const { constraints, plan, out, reserve, ledgerSummary } = parseArgs(argv);
392
+ const { constraints, autonomy, plan, out, reserve, ledgerSummary } = parseArgs(argv);
304
393
  const budget = resolveBudget(env, reserve);
305
394
 
306
395
  const readOrStop = (path, label) => {
@@ -311,10 +400,29 @@ export const main = (argv, ctx = {}) => {
311
400
  }
312
401
  };
313
402
  const constraintsText = constraints ? readOrStop('AGENTS.md', 'root AGENTS.md') : null;
403
+ const autonomyText = autonomy ? resolveAutonomyFacts({ cwd }) : null;
404
+ // --plan is CONFINED to the git work tree (codex R4): the bridge tier auto-allows this tool
405
+ // with an args wildcard, so an unattended invocation could otherwise point --plan at ANY
406
+ // readable file and ship it into the facts payload. Plans are in-repo by contract
407
+ // (docs/plans); an outside-tree path is a loud refusal, never a silent read.
408
+ if (plan != null) {
409
+ const top = gitTop(cwd);
410
+ const planReal = (() => {
411
+ try {
412
+ return realpathSync(resolve(cwd, plan));
413
+ } catch (err) {
414
+ throw fail(1, `plan file '${plan}' is unreadable (${(err && err.code) || err}) — STOP`);
415
+ }
416
+ })();
417
+ const topReal = realpathSync(top);
418
+ if (planReal !== topReal && !planReal.startsWith(`${topReal}${sep}`)) {
419
+ throw fail(1, `--plan '${plan}' resolves outside the git work tree (${planReal}) — plans live in-repo (docs/plans); refusing to read outside-tree content into the facts payload`);
420
+ }
421
+ }
314
422
  const planText = plan != null ? readOrStop(plan, 'plan file') : null;
315
423
 
316
424
  const parts = [];
317
- const assembled = assembleGrounding({ constraintsText, planText, planLabel: plan ?? 'plan' });
425
+ const assembled = assembleGrounding({ constraintsText, autonomyText, planText, planLabel: plan ?? 'plan' });
318
426
  if (assembled) parts.push(assembled);
319
427
  if (ledgerSummary) {
320
428
  const summary = resolveLedgerSummary({ cwd, env });
@@ -327,8 +435,17 @@ export const main = (argv, ctx = {}) => {
327
435
  : '';
328
436
 
329
437
  if (out != null) {
330
- const realOut = assertScratchDestination(out, cwd);
331
- writeFileSync(realOut, text);
438
+ const dest = assertScratchDestination(out, cwd);
439
+ // repo-fresh writes are EXCLUSIVE ('wx'): the create-only pre-check would otherwise race a
440
+ // file created between the guard and this write (codex R11); temp scratch stays rewritable.
441
+ // ctx.writeFile is a TEST seam (the EEXIST race arm is not constructible without it).
442
+ const writeFile = ctx.writeFile ?? writeFileSync;
443
+ try {
444
+ writeFile(dest.path, text, dest.kind === 'repo-fresh' ? { flag: 'wx' } : {});
445
+ } catch (err) {
446
+ if (err?.code === 'EEXIST') throw fail(1, `--out lost the create-only race: ${out} appeared between the guard and the write — an in-repo destination is never overwritten; re-run with a fresh path or temp scratch`);
447
+ throw err;
448
+ }
332
449
  return { code: 0, stdout: `[grounding] wrote ${Buffer.byteLength(text, 'utf8')} bytes to ${out} — pass it as: agy-review code --facts @${out}`, stderr };
333
450
  }
334
451
  return { code: 0, stdout: text, stderr };