@sabaiway/agent-workflow-kit 14.32.0 → 14.34.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/CHANGELOG.md +81 -0
- package/README.md +3 -3
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/migrations/1.2.0-agent-attribution.md +10 -8
- package/migrations/3.0.0-hardened-core-loop.md +9 -13
- package/package.json +1 -1
- package/references/contracts.md +2 -2
- package/references/modes/bootstrap.md +5 -3
- package/references/modes/recommendations.md +7 -5
- package/references/modes/upgrade.md +5 -5
- package/references/modes/velocity.md +7 -5
- package/references/modes/worktrees.md +10 -9
- package/references/shared/composition-handoff.md +3 -3
- package/references/shared/deploy-tail.md +2 -2
- package/references/templates/vehicles.json +1 -1
- package/tools/apply-danger-check.mjs +57 -15
- package/tools/attribution.mjs +133 -0
- package/tools/cheap-agents-read.mjs +12 -0
- package/tools/commit-guard.mjs +3 -16
- package/tools/ensure-configs.mjs +6 -6
- package/tools/ensure-executor.mjs +13 -30
- package/tools/ensure-vocabulary.mjs +4 -4
- package/tools/git-hooks.mjs +41 -3
- package/tools/hide-footprint.mjs +87 -61
- package/tools/init-project.mjs +31 -6
- package/tools/recommendations.mjs +112 -65
- package/tools/upgrade-runlist.mjs +1 -1
- package/tools/velocity-profile.mjs +118 -10
- package/tools/worktrees.mjs +28 -10
- package/tools/write-lanes.mjs +23 -2
|
@@ -75,16 +75,19 @@ import { CHECKER_CLAIM, INITIAL_ADOPTION_REASON, SOURCE_SIZE_GATE_ID, classifySo
|
|
|
75
75
|
// The declared-path resolution + segment containment this item's convergence lane shares with the
|
|
76
76
|
// autonomy render's allowWrite degrade — ONE leaf, so the two answers cannot drift.
|
|
77
77
|
import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
|
|
78
|
-
import {
|
|
78
|
+
import { readPreCommitState } from './git-hooks.mjs';
|
|
79
|
+
export { resolveGitHooksPath, readPreCommitState, canonicalGuardLineRe, INSTALLER_MARKER_RE } from './git-hooks.mjs';
|
|
79
80
|
import { loadConfig } from './orchestration-config.mjs';
|
|
80
81
|
import { homeRelative } from './bridge-state-dirs.mjs';
|
|
81
82
|
import { sandboxEnabled, HOST_HONORS_QUALIFIER } from './bridge-wiring.mjs';
|
|
82
83
|
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
83
|
-
import {
|
|
84
|
-
import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
|
|
84
|
+
import { nearestExistingDir, worktreesGrantDir, worktreesGrantHoldsHome } from './worktrees.mjs';
|
|
85
|
+
import { preflightCheapAgents, writeCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
|
|
86
|
+
import { ATTRIBUTION_KEY, LEGACY_ATTRIBUTION_KEY, attributionHidesText, effectiveAttribution, readAttributionChoice, wrongTypedKey } from './attribution.mjs';
|
|
87
|
+
import { planGlobalRemoval } from './hide-footprint.mjs';
|
|
85
88
|
import { NODE_EVIDENCE, NODE_EVIDENCE_SCRIPTS, probeNodeEvidence } from './node-evidence.mjs';
|
|
86
89
|
// The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
|
|
87
|
-
import { surveyExecutorVehicle, readExecutorPosture, executorTemplate, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
|
|
90
|
+
import { surveyExecutorVehicle, readExecutorPosture, executorBodyDiffers, executorTemplate, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
|
|
88
91
|
// The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
|
|
89
92
|
// (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
|
|
90
93
|
import {
|
|
@@ -194,11 +197,18 @@ export const SEVERITIES = Object.freeze({
|
|
|
194
197
|
// names the subagent carrier over a vehicle that cannot carry it — a configured declaration that
|
|
195
198
|
// is broken, which is what attention means.
|
|
196
199
|
'executor-vehicle': SEVERITY_ATTENTION,
|
|
200
|
+
// A hand edit of the placed executor is not a broken declaration — the vehicle still carries — so
|
|
201
|
+
// re-deriving it is an offer.
|
|
202
|
+
'executor-vehicle.differing': SEVERITY_OPTIONAL,
|
|
203
|
+
// The AGENTS.md block is a configured choice the harness does not honour.
|
|
204
|
+
attribution: SEVERITY_ATTENTION,
|
|
205
|
+
'footprint-global': SEVERITY_OPTIONAL,
|
|
197
206
|
'family-freshness': SEVERITY_ATTENTION,
|
|
198
207
|
'adr-store-migration': SEVERITY_ATTENTION,
|
|
199
208
|
'sandbox-masks': SEVERITY_OPTIONAL,
|
|
200
209
|
'sandbox-masks.unfenced-mount': SEVERITY_ATTENTION,
|
|
201
210
|
'worktrees-dir': SEVERITY_OPTIONAL,
|
|
211
|
+
'worktrees-dir.home': SEVERITY_OPTIONAL,
|
|
202
212
|
// The layer is opt-in, so both arms are OFFERS under the frozen registry (attention is a CONFIGURED
|
|
203
213
|
// declaration that is broken): an absent store offers the seed, a store with no live contract offers
|
|
204
214
|
// the decline. Neither arm can leave the flow-optimal line standing — an offer is still an item.
|
|
@@ -277,11 +287,15 @@ export const WHATS = Object.freeze({
|
|
|
277
287
|
'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
|
|
278
288
|
agents: '{n} bundled subagent vehicle(s) not placed (Claude Code) — {ro} read-only, {ex} the full-tool executor; the apply PREVIEWS first',
|
|
279
289
|
'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
|
|
290
|
+
'executor-vehicle.differing': 'the placed executor vehicle differs from the body derived from docs/ai/vehicles.json — a hand edit stands until it is re-derived',
|
|
291
|
+
attribution: 'AGENTS.md says attribution off but .claude/settings.json does not hide the harness trailer and PR text',
|
|
292
|
+
'footprint-global': 'legacy kit ignore line(s) sit in {file} — they ignore kit paths in every repository here',
|
|
280
293
|
'family-freshness': '{parts}',
|
|
281
294
|
'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
|
|
282
295
|
'sandbox-masks': '{n} sandbox mask(s) clutter git status — the managed exclude block is absent or stale',
|
|
283
296
|
'sandbox-masks.stale-real': '{n} sandbox mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
|
|
284
297
|
'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
|
|
298
|
+
'worktrees-dir.home': 'the worktrees parent dir {dir} is the home or holds it — the kit grants no write surface there',
|
|
285
299
|
'spec-adoption': 'feature-spec store absent (docs/ai/specs) — no feature contract can govern a plan here yet; seed the store, or record the decline',
|
|
286
300
|
'profile-gap': '{what}',
|
|
287
301
|
'jev-connect': 'Jev (TypeSafe) is not connected: TYPESAFE_API_KEY is not in the environment the agent\'s commands run in',
|
|
@@ -341,6 +355,8 @@ export const BENEFITS = Object.freeze({
|
|
|
341
355
|
'mcp-channel': 'velocity — path facts and literal searches arrive as typed tool calls whose arguments are JSON fields, never a shell string',
|
|
342
356
|
agents: 'cost and quiet — cheap-model mechanical work, no shell on a read-only vehicle (no prompt flood); the executor carries slices you verify',
|
|
343
357
|
'executor-vehicle': 'carrier readiness — a slot you configured subagent dispatches the subagent it names instead of silently running solo',
|
|
358
|
+
attribution: 'attribution — the harness adds no Co-Authored-By trailer, PR text or session link, as your AGENTS.md block requires',
|
|
359
|
+
'footprint-global': 'hygiene — the legacy machine-global excludes stop ignoring kit paths in every other repository on this host',
|
|
344
360
|
'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
|
|
345
361
|
'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
|
|
346
362
|
'sandbox-masks': 'zero clutter — a mask git ignores through the block leaves git status; a mount-only one leaves git add -A and the review fingerprint too',
|
|
@@ -350,6 +366,10 @@ export const BENEFITS = Object.freeze({
|
|
|
350
366
|
'jev-connect': 'decisions — a typed choice with a confidence in under a second for your code and the agent\'s scripts, once for every project here',
|
|
351
367
|
'jev-skill': 'the TypeSafe skill for Claude Code, Codex and Antigravity CLI, one pinned verified copy per agent root, from one command you run',
|
|
352
368
|
});
|
|
369
|
+
// The one arm whose gain differs from its base item's (BENEFITS stays keyed by item key).
|
|
370
|
+
export const VARIANT_BENEFITS = Object.freeze({
|
|
371
|
+
'executor-vehicle.differing': 'carrier fidelity — the placed executor runs the model and effort docs/ai/vehicles.json names, not a hand edit',
|
|
372
|
+
});
|
|
353
373
|
|
|
354
374
|
// ── the CLOSED opt-in capability registry (OPT-IN-SHIPS-INVISIBLE) ──────────────────────────────
|
|
355
375
|
// 3.14.0 shipped a capability with every surface an AGENT reads and NO advisor entry, so `upgrade`
|
|
@@ -386,6 +406,8 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
|
|
|
386
406
|
{ id: 'read-lane', mode: 'hook', advisorKey: 'read-lane' },
|
|
387
407
|
{ id: 'commit-guard', mode: 'commit-guard', advisorKey: 'commit-guard' },
|
|
388
408
|
{ id: 'enforcement', mode: 'bootstrap', advisorKey: 'enforcement' },
|
|
409
|
+
{ id: 'attribution', mode: 'bootstrap', advisorKey: 'attribution' },
|
|
410
|
+
{ id: 'footprint-global', mode: 'bootstrap', advisorKey: 'footprint-global' },
|
|
389
411
|
{ id: 'state-block', mode: 'state-block-guard', advisorKey: 'state-block' },
|
|
390
412
|
{ id: 'sandbox-masks', mode: 'sandbox-masks', advisorKey: 'sandbox-masks' },
|
|
391
413
|
{ id: 'mcp-channel', mode: 'mcp', advisorKey: 'mcp-channel' },
|
|
@@ -888,27 +910,11 @@ const probeSourceSize = ({ root, deps, add, skip }) => {
|
|
|
888
910
|
// alike; an UNMANAGED hook is a loud skip naming the user's own step (never an overwrite offer); a
|
|
889
911
|
// non-final-capable declaration gets NO offer (an armed guard over a matrix that cannot mint a
|
|
890
912
|
// receipt would block every commit).
|
|
891
|
-
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
const canonicalGuardLineRe = () => new RegExp(`^node "${escapeRe(toolPath('commit-guard.mjs'))}" --check$`, 'm');
|
|
897
|
-
// The one reader of git's pre-commit: whose hook it is, and whether the canonical guard line arms it.
|
|
898
|
-
export const readPreCommitState = (root, deps = {}) => {
|
|
899
|
-
const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
|
|
900
|
-
if (hooksPath === null) return { state: 'no-hooks-dir', armed: false };
|
|
901
|
-
const path = join(hooksPath, 'pre-commit');
|
|
902
|
-
const hook = (() => {
|
|
903
|
-
try {
|
|
904
|
-
if (readsAbsent(deps, path)) return null;
|
|
905
|
-
return String((deps.readFile ?? readFileSync)(path, 'utf8'));
|
|
906
|
-
} catch {
|
|
907
|
-
return null;
|
|
908
|
-
}
|
|
909
|
-
})();
|
|
910
|
-
if (hook === null) return { state: 'absent', armed: false };
|
|
911
|
-
return { state: INSTALLER_MARKER_RE.test(hook) ? 'managed' : 'unmanaged', armed: canonicalGuardLineRe().test(hook) };
|
|
913
|
+
// The advisor asks git under its own process env; only the commit-guard apply passes the installer's.
|
|
914
|
+
const readHookState = (root, deps) => {
|
|
915
|
+
const hook = readPreCommitState(root, { ...deps, env: undefined });
|
|
916
|
+
if (hook.state === 'unreadable') throw new Error(`${hook.path} is unreadable (${hook.code}) — the pre-commit hook cannot be judged`);
|
|
917
|
+
return hook;
|
|
912
918
|
};
|
|
913
919
|
const formsApply = (variant, root) => consoleArgv(variant, { toolsDir: HERE, root }).apply.map(q).join(' ');
|
|
914
920
|
const probeCommitGuard = ({ root, deps, add, skip }) => {
|
|
@@ -929,7 +935,7 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
|
|
|
929
935
|
skip('commit-guard', new Error(enforcementAdmits(root, deps) ? COMMIT_GUARD_INSTALLER_VIA_ITEM : COMMIT_GUARD_INSTALLER_OWN_STEP));
|
|
930
936
|
return;
|
|
931
937
|
}
|
|
932
|
-
const { state, armed } =
|
|
938
|
+
const { state, armed } = readHookState(root, deps);
|
|
933
939
|
if (armed || state === 'no-hooks-dir') return; // armed (managed or not), or not a git tree — converged
|
|
934
940
|
if (state === 'unmanaged') {
|
|
935
941
|
skip('commit-guard', new Error(COMMIT_GUARD_UNMANAGED_HOOK));
|
|
@@ -975,7 +981,7 @@ const enforcementOffer = (root, deps) => {
|
|
|
975
981
|
if (readEnforcementNode(destination, lstat) === null) continue;
|
|
976
982
|
throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
|
|
977
983
|
}
|
|
978
|
-
if (
|
|
984
|
+
if (readHookState(root, deps).state === 'unmanaged') throw new Error(ENFORCEMENT_UNMANAGED_HOOK);
|
|
979
985
|
return true;
|
|
980
986
|
};
|
|
981
987
|
|
|
@@ -1123,16 +1129,31 @@ const writerBlock = (root, deps) => {
|
|
|
1123
1129
|
}
|
|
1124
1130
|
};
|
|
1125
1131
|
|
|
1132
|
+
const HIDDEN_AGENTS_FOLLOW_UP = (root) => `hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`;
|
|
1133
|
+
|
|
1126
1134
|
const probeExecutorVehicle = ({ root, deps, add, skip }) => {
|
|
1135
|
+
if (renderBrokenExecutor({ root, deps, add, skip })) return;
|
|
1136
|
+
try {
|
|
1137
|
+
if (!executorBodyDiffers(root, deps)) return;
|
|
1138
|
+
const plan = writeCheapAgents({ cwd: root, dryRun: true }, deps);
|
|
1139
|
+
if (!plan.stampOk) throw new Error(`run /agent-workflow-kit upgrade first (deployment stamp ${plan.stamp ?? 'none'}, expected ${EXPECTED_WORKFLOW_VERSION})`);
|
|
1140
|
+
add('executor-vehicle', WHATS['executor-vehicle.differing'], formsApply('executor-vehicle.differing', root),
|
|
1141
|
+
'executor-vehicle.differing', HIDDEN_AGENTS_FOLLOW_UP(root));
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
skip('executor-vehicle', err);
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
const renderBrokenExecutor = ({ root, deps, add, skip }) => {
|
|
1127
1148
|
try {
|
|
1128
1149
|
// The VALIDATED reader, as probeReviewRecipe uses it: a schema-invalid config is a stated skip,
|
|
1129
1150
|
// never an item computed over a shape nothing accepted.
|
|
1130
1151
|
const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
|
|
1131
1152
|
const configured = Object.entries(ACTIVITIES).flatMap(([activity, def]) =>
|
|
1132
1153
|
Object.keys(def.slots).filter((slot) => config?.[activity]?.[slot] === 'subagent'));
|
|
1133
|
-
if (configured.length === 0) return;
|
|
1154
|
+
if (configured.length === 0) return false;
|
|
1134
1155
|
const survey = (deps.surveyVehicle ?? surveyExecutorVehicle)(root, deps);
|
|
1135
|
-
if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return;
|
|
1156
|
+
if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return false;
|
|
1136
1157
|
const reason = safeLine(survey.reason ?? '');
|
|
1137
1158
|
const { posture, reason: settingsReason } = readExecutorPosture(root, deps);
|
|
1138
1159
|
const stamp = readStamp(join(root, WORKFLOW_STAMP), readFsDeps(deps));
|
|
@@ -1145,7 +1166,7 @@ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
|
|
|
1145
1166
|
const blocked = writerBlock(root, deps);
|
|
1146
1167
|
if (blocked) preconditions.push(`${blocked} — fix that`);
|
|
1147
1168
|
const room = templateBudget(WHATS['executor-vehicle']) - String(configured.length).length - survey.state.length - 2;
|
|
1148
|
-
add(
|
|
1169
|
+
return add(
|
|
1149
1170
|
'executor-vehicle',
|
|
1150
1171
|
fillTemplate(WHATS['executor-vehicle'], {
|
|
1151
1172
|
n: configured.length,
|
|
@@ -1156,10 +1177,11 @@ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
|
|
|
1156
1177
|
// frontmatter or read-only tools included), so those states need no precondition; every precondition above is a refusal the apply would meet.
|
|
1157
1178
|
`node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
|
|
1158
1179
|
'executor-vehicle',
|
|
1159
|
-
`${preconditions.length ? `precondition: ${preconditions.join('; ')}; ` : ''}
|
|
1180
|
+
`${preconditions.length ? `precondition: ${preconditions.join('; ')}; ` : ''}${HIDDEN_AGENTS_FOLLOW_UP(root)}`,
|
|
1160
1181
|
);
|
|
1161
1182
|
} catch (err) {
|
|
1162
1183
|
skip('executor-vehicle', err);
|
|
1184
|
+
return false;
|
|
1163
1185
|
}
|
|
1164
1186
|
};
|
|
1165
1187
|
|
|
@@ -1312,7 +1334,7 @@ const readReadLaneToggle = (root, deps) => {
|
|
|
1312
1334
|
// D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
|
|
1313
1335
|
// at the consent moment; the static contract test asserts EXACT bidirectional coverage
|
|
1314
1336
|
// (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
|
|
1315
|
-
export const RISK_NOTED_KEYS = Object.freeze(['bridge-tier', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap', 'jev-connect', 'jev-skill']);
|
|
1337
|
+
export const RISK_NOTED_KEYS = Object.freeze(['bridge-tier', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap', 'jev-connect', 'jev-skill', 'attribution', 'footprint-global']);
|
|
1316
1338
|
|
|
1317
1339
|
// The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
|
|
1318
1340
|
// zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
|
|
@@ -1404,46 +1426,31 @@ const declaresWritableDir = (root, probeDir, deps) => {
|
|
|
1404
1426
|
// existing ancestor resolve to the same dir and keep the same ack.
|
|
1405
1427
|
export const worktreesDirFingerprint = (probeDir, home) => recipeFingerprint({ hosts: [], dirs: [probeDir], home });
|
|
1406
1428
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
// production, so the item fired forever even once its own advice had been applied.
|
|
1429
|
+
const WORKTREES_ACK_FALLBACK = 'ack fallback, only on your word that this host ignores the settings key, instead of the grant: ';
|
|
1430
|
+
|
|
1431
|
+
// The worktrees-dir item, by the table of spec bridges/velocity-profile, part worktrees-grant: the grant
|
|
1432
|
+
// names the parent itself; the probe dir (its nearest existing ancestor) serves the convergence alone.
|
|
1412
1433
|
const probeWorktreesDir = ({ root, deps, add, skip }) => {
|
|
1413
1434
|
try {
|
|
1414
|
-
const
|
|
1415
|
-
const
|
|
1416
|
-
// the SAME canonical derivation the provision preflight probes (worktrees.mjs) — an absent
|
|
1417
|
-
// configured dir resolves to its nearest existing ancestor, never a false denial
|
|
1418
|
-
const probeDir = resolveProbeDir(parent, deps);
|
|
1419
|
-
// A supplied host signal is runtime truth and OVERRIDES both declaration lanes in EITHER
|
|
1420
|
-
// direction: a trusted yes converges, a trusted no fires however the project is configured.
|
|
1435
|
+
const grantDir = worktreesGrantDir(root, deps);
|
|
1436
|
+
const probeDir = nearestExistingDir(grantDir, deps);
|
|
1421
1437
|
const hostSignal = deps.canWriteDir?.(probeDir);
|
|
1422
1438
|
if (hostSignal === true) return;
|
|
1423
1439
|
const home = deps.home ?? homedir();
|
|
1440
|
+
if (worktreesGrantHoldsHome(grantDir, home, deps)) {
|
|
1441
|
+
const dir = truncatedTo(oneLineOf(grantDir), templateBudget(WHATS['worktrees-dir.home']));
|
|
1442
|
+
add('worktrees-dir', fillTemplate(WHATS['worktrees-dir.home'], { dir }), '', 'worktrees-dir.home');
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1424
1445
|
const fingerprint = worktreesDirFingerprint(probeDir, home);
|
|
1425
1446
|
if (hostSignal !== false) {
|
|
1426
1447
|
if (declaresWritableDir(root, probeDir, deps)) return;
|
|
1427
1448
|
if (readAckValue(root, deps, ACKS_WORKTREES_DIR_KEY) === fingerprint) return;
|
|
1428
1449
|
}
|
|
1429
|
-
const dir = truncatedTo(oneLineOf(
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
// the apply (dry-run; it prints the exact --apply the flow then runs) and the grant advice
|
|
1434
|
-
// rides the detail, labeled as the first step. Against a trusted NO the ack could never
|
|
1435
|
-
// converge, so the grant advice is the apply and no ack surfaces.
|
|
1436
|
-
if (hostSignal === false) {
|
|
1437
|
-
add('worktrees-dir', fillTemplate(WHATS['worktrees-dir'], { dir }), `HAND-APPLY: ${grantAdvice}`);
|
|
1438
|
-
} else {
|
|
1439
|
-
add(
|
|
1440
|
-
'worktrees-dir',
|
|
1441
|
-
fillTemplate(WHATS['worktrees-dir'], { dir }),
|
|
1442
|
-
`node ${q(toolPath('ack-write.mjs'))} --lane worktrees-dir --fingerprint ${fingerprint} --cwd ${q(root)}`,
|
|
1443
|
-
'worktrees-dir',
|
|
1444
|
-
`HAND-APPLY FIRST: ${grantAdvice}; THEN this item's apply one-liner previews the dir-bound ack and prints the exact --apply that records it`,
|
|
1445
|
-
);
|
|
1446
|
-
}
|
|
1450
|
+
const dir = truncatedTo(oneLineOf(grantDir), templateBudget(WHATS['worktrees-dir']));
|
|
1451
|
+
const ack = `node ${q(toolPath('ack-write.mjs'))} --lane worktrees-dir --fingerprint ${fingerprint} --cwd ${q(root)}`;
|
|
1452
|
+
add('worktrees-dir', fillTemplate(WHATS['worktrees-dir'], { dir }), ack, 'worktrees-dir',
|
|
1453
|
+
hostSignal === false ? null : `${WORKTREES_ACK_FALLBACK}${ack}`);
|
|
1447
1454
|
} catch (err) {
|
|
1448
1455
|
skip('worktrees-dir', err);
|
|
1449
1456
|
}
|
|
@@ -1577,9 +1584,48 @@ export const probeProfileGaps = ({ root, deps, add, skip }) => {
|
|
|
1577
1584
|
}
|
|
1578
1585
|
};
|
|
1579
1586
|
|
|
1587
|
+
// The attribution item by its table (spec init-project, part in-session-writes): a choice of off the
|
|
1588
|
+
// effective settings do not honour; the writer's preflight runs before it renders, so no rendered check always exits 3.
|
|
1589
|
+
const probeAttribution = ({ root, deps, add, skip }) => {
|
|
1590
|
+
try {
|
|
1591
|
+
if (readAttributionChoice(root, deps) !== 'off') return;
|
|
1592
|
+
const project = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root }).data;
|
|
1593
|
+
const local = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root }).data;
|
|
1594
|
+
const effective = effectiveAttribution(project, local);
|
|
1595
|
+
if (attributionHidesText(effective)) return;
|
|
1596
|
+
preflightVelocityProfile({ cwd: root }, deps);
|
|
1597
|
+
for (const [file, data] of [[SETTINGS_FILE, project], [SETTINGS_LOCAL_FILE, local]]) {
|
|
1598
|
+
const wrong = wrongTypedKey(data);
|
|
1599
|
+
if (wrong) throw new Error(`${file}: ${wrong} — the attribution item cannot judge it`);
|
|
1600
|
+
}
|
|
1601
|
+
const setsText = (value) => value !== null && typeof value === 'object' && ['commit', 'pr'].some((key) => Object.hasOwn(value, key));
|
|
1602
|
+
const localKey = Object.hasOwn(local ?? {}, ATTRIBUTION_KEY) && !attributionHidesText({ [ATTRIBUTION_KEY]: local[ATTRIBUTION_KEY] }) ? ATTRIBUTION_KEY
|
|
1603
|
+
: local?.[LEGACY_ATTRIBUTION_KEY] === true && !setsText(effective[ATTRIBUTION_KEY]) ? LEGACY_ATTRIBUTION_KEY : null;
|
|
1604
|
+
if (localKey) throw new Error(`${SETTINGS_LOCAL_FILE} sets ${localKey} so the harness adds its text — a local-scope override is your own edit`);
|
|
1605
|
+
add('attribution', WHATS.attribution, formsApply('attribution', root));
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
skip('attribution', err);
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
|
|
1611
|
+
// The leftover machine-global block: the one plan the hide reports and the --remove-global mode removes,
|
|
1612
|
+
// asked of git under the home the advisor was given.
|
|
1613
|
+
const probeFootprintGlobal = ({ root, deps, add, skip }) => {
|
|
1614
|
+
try {
|
|
1615
|
+
const home = deps.home ?? deps.jevHost?.home ?? homedir();
|
|
1616
|
+
const plan = (deps.planGlobalRemoval ?? planGlobalRemoval)({ env: { ...process.env, HOME: home }, home }, root, { home });
|
|
1617
|
+
if (plan === null) return;
|
|
1618
|
+
const file = truncatedTo(plan.source, templateBudget(WHATS['footprint-global']));
|
|
1619
|
+
add('footprint-global', fillTemplate(WHATS['footprint-global'], { file }), formsApply('footprint-global', root));
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
skip('footprint-global', err);
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1580
1625
|
// ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
|
|
1581
1626
|
const PROBES = Object.freeze([
|
|
1582
1627
|
probeVelocityItems,
|
|
1628
|
+
probeAttribution,
|
|
1583
1629
|
probeAutonomyItems,
|
|
1584
1630
|
probeSandboxProvision,
|
|
1585
1631
|
probeReviewRecipe,
|
|
@@ -1595,6 +1641,7 @@ const PROBES = Object.freeze([
|
|
|
1595
1641
|
probeFamilyFreshness,
|
|
1596
1642
|
probeAdrStore,
|
|
1597
1643
|
probeMasksItem,
|
|
1644
|
+
probeFootprintGlobal,
|
|
1598
1645
|
probeWorktreesDir,
|
|
1599
1646
|
probeMcpChannel,
|
|
1600
1647
|
probeSpecAdoption,
|
|
@@ -1619,7 +1666,7 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
|
|
|
1619
1666
|
// pattern-matching prose that is free to be reworded.
|
|
1620
1667
|
// `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
|
|
1621
1668
|
// the capped WHAT and does NOT belong in the pure-command apply (the bridge-tier missing-entries
|
|
1622
|
-
// line; the worktrees-dir
|
|
1669
|
+
// line; the worktrees-dir ack fallback; the
|
|
1623
1670
|
// agents hidden-mode reconcile follow-up). Single-line like apply; absent for every other item.
|
|
1624
1671
|
// Returns whether the item really RENDERED. One probe's disposition depends on another's having
|
|
1625
1672
|
// spoken (the marker-stale ⟷ read-lane.stale precedence), and "the conditions still look right"
|
|
@@ -1642,7 +1689,7 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
|
|
|
1642
1689
|
home: deps.jevHost?.home ?? deps.home ?? '',
|
|
1643
1690
|
platform: deps.jevHost?.platform ?? deps.platform,
|
|
1644
1691
|
});
|
|
1645
|
-
items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: BENEFITS[key],
|
|
1692
|
+
items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: VARIANT_BENEFITS[variant] ?? BENEFITS[key],
|
|
1646
1693
|
check: slot?.check ?? null, apply: slot?.apply ?? apply, detail });
|
|
1647
1694
|
return true;
|
|
1648
1695
|
};
|
|
@@ -1711,7 +1758,7 @@ Usage:
|
|
|
1711
1758
|
Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
|
|
1712
1759
|
one composed verdict line opens every non-optimal render, then per item {severity · what is
|
|
1713
1760
|
sub-optimal · the benefit in one plain line · the lane · an optional \`recipe:\` line (the bridge-tier
|
|
1714
|
-
missing-entries line, the worktrees-dir
|
|
1761
|
+
missing-entries line, the worktrees-dir ack fallback, or the agents hidden-mode reconcile
|
|
1715
1762
|
follow-up) · the harness check line · the exact consent-gated apply one-liner}. --cwd is
|
|
1716
1763
|
REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
|
|
1717
1764
|
section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
|
|
@@ -13,8 +13,9 @@ import { SHELL_METACHARACTERS, hasShellMetacharacter, isSeedablePathToken } from
|
|
|
13
13
|
import { isDirectRun } from './direct-run.mjs';
|
|
14
14
|
import { compareSemver } from './semver-lite.mjs';
|
|
15
15
|
import { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL, usedBridges, bundledSandboxRecipe, exclusionOf } from './bridge-sandbox-recipe.mjs';
|
|
16
|
-
import { stateDirsOf, mergeAllowWrite } from './bridge-state-dirs.mjs';
|
|
16
|
+
import { stateDirsOf, mergeAllowWrite, homeRelative } from './bridge-state-dirs.mjs';
|
|
17
17
|
import { HOST_HONORS_QUALIFIER, sandboxSurfaceDelta } from './bridge-wiring.mjs';
|
|
18
|
+
import { nearestExistingDir, worktreesGrantDir, worktreesGrantHoldsHome } from './worktrees.mjs';
|
|
18
19
|
import { loadConfig } from './orchestration-config.mjs';
|
|
19
20
|
import { validateManifest } from './manifest/validate.mjs';
|
|
20
21
|
export { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL };
|
|
@@ -269,6 +270,7 @@ export const VELOCITY_STAMP = 'VELOCITY_STAMP';
|
|
|
269
270
|
export const VELOCITY_UNSAFE_MODE = 'VELOCITY_UNSAFE_MODE';
|
|
270
271
|
export const VELOCITY_MALFORMED = 'VELOCITY_MALFORMED';
|
|
271
272
|
export const VELOCITY_SYMLINK = 'VELOCITY_SYMLINK';
|
|
273
|
+
export const VELOCITY_WORKTREES_HOME = 'VELOCITY_WORKTREES_HOME';
|
|
272
274
|
// The --autonomy render refuses an absent policy file (a writer renders only a declared policy).
|
|
273
275
|
export const VELOCITY_NO_POLICY = 'VELOCITY_NO_POLICY';
|
|
274
276
|
|
|
@@ -299,6 +301,7 @@ const FLAG_ACCEPT_EDITS = '--accept-edits';
|
|
|
299
301
|
const FLAG_KIT_TOOLS = '--kit-tools';
|
|
300
302
|
const FLAG_BRIDGE_TIER = '--bridge-tier';
|
|
301
303
|
const FLAG_AUTONOMY = '--autonomy';
|
|
304
|
+
const FLAG_WORKTREES = '--worktrees';
|
|
302
305
|
const FLAG_CHECK = '--check';
|
|
303
306
|
const FLAG_CWD = '--cwd';
|
|
304
307
|
const FLAG_HELP = '--help';
|
|
@@ -329,6 +332,7 @@ const RESIDUAL_NOTICE =
|
|
|
329
332
|
const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--bridge-tier] [--accept-edits] [--cwd <dir>] [--help]
|
|
330
333
|
velocity-profile --autonomy [--apply] [--cwd <dir>] (render the autonomy policy)
|
|
331
334
|
velocity-profile --autonomy --check [--cwd <dir>] (read-only drift gate)
|
|
335
|
+
velocity-profile --worktrees [--apply] [--cwd <dir>] (grant the worktrees parent)
|
|
332
336
|
|
|
333
337
|
Allowlist mode (default): seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
|
|
334
338
|
Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.
|
|
@@ -337,7 +341,7 @@ absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned by
|
|
|
337
341
|
(project-exec - it runs YOUR declared gates.json), source-size-check.mjs as ONE exact --check
|
|
338
342
|
byte-string (its read-only mode only - its writers keep prompting), and the writers' exact arg-free
|
|
339
343
|
dry-run preview byte-strings. Never touches settings.local.json.
|
|
340
|
-
Every apply also appends the apply ask rule ${APPLY_ASK_RULE} to permissions.ask once, so all work
|
|
344
|
+
Every flagless, --kit-tools and --bridge-tier apply also appends the apply ask rule ${APPLY_ASK_RULE} to permissions.ask once, so all work
|
|
341
345
|
can stay in auto mode: without it auto mode refuses a harness apply and you must leave auto mode for it; with it the harness shows its usual dialog. It allows nothing.
|
|
342
346
|
--bridge-tier (own consent) derives each entry for a used bridge from the effective project recipes:
|
|
343
347
|
review wrappers' CODE mode (codex-review code, agy-review code), the quoted grounding pre-step rule,
|
|
@@ -354,7 +358,16 @@ permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never see
|
|
|
354
358
|
leaves permissions.allow untouched. Preview by default; --apply writes; --check is a read-only drift
|
|
355
359
|
gate (exit 1 on drift). --autonomy cannot combine with --accept-edits, --kit-tools, or --bridge-tier
|
|
356
360
|
(allowlist-mode flags). Refuses an absent policy (seed one first with set-autonomy). Never touches
|
|
357
|
-
settings.local.json
|
|
361
|
+
settings.local.json.
|
|
362
|
+
|
|
363
|
+
--worktrees (own consent, its own mode) writes ONE sandbox.filesystem.allowWrite entry, the worktrees
|
|
364
|
+
parent (docs/ai/worktrees.json parentDir, else the repository's own parent), into this copy's
|
|
365
|
+
.claude/settings.local.json only, never .claude/settings.json, and creates the parent when it is
|
|
366
|
+
missing; it seeds no core and no ask rule.
|
|
367
|
+
It widens, ${HOST_HONORS_QUALIFIER}: every sandboxed command of this copy
|
|
368
|
+
and of every worktree provisioned from it can then write the whole parent dir, sibling repositories
|
|
369
|
+
under it included. A parent that is the home or holds it is refused. It cannot combine with
|
|
370
|
+
--autonomy, --kit-tools, --bridge-tier or --accept-edits.`;
|
|
358
371
|
|
|
359
372
|
const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
360
373
|
|
|
@@ -578,13 +591,13 @@ const collectPreExistingNonReadonly = (sources, derivedTier = [], derivedBridgeA
|
|
|
578
591
|
.map((entry) => ({ source, entry })),
|
|
579
592
|
);
|
|
580
593
|
|
|
581
|
-
const assertTargetWritable = (absPath, deps = {}) => {
|
|
594
|
+
const assertTargetWritable = (absPath, deps = {}, relPath = SETTINGS_FILE) => {
|
|
582
595
|
const fs = fsDeps(deps);
|
|
583
596
|
const stat = lstatNoFollow(absPath, fs);
|
|
584
597
|
if (stat !== null && !stat.isFile()) {
|
|
585
598
|
throw makeVelocityProfileError(
|
|
586
599
|
VELOCITY_SYMLINK,
|
|
587
|
-
`${
|
|
600
|
+
`${relPath} exists but is not a regular file - refusing to clobber it`,
|
|
588
601
|
);
|
|
589
602
|
}
|
|
590
603
|
};
|
|
@@ -689,6 +702,19 @@ const formatBridgeTier = (result) =>
|
|
|
689
702
|
]
|
|
690
703
|
: [];
|
|
691
704
|
|
|
705
|
+
export const KIT_WORKTREES_GRANT_NOTICE =
|
|
706
|
+
`worktrees grant: ${HOST_HONORS_QUALIFIER}, every sandboxed command of this copy, and of every worktree provisioned from it afterwards (provision copies the local settings), can write the whole worktrees parent dir, sibling repositories under it included, for every later session. ` +
|
|
707
|
+
'The entry lands in this copy\'s .claude/settings.local.json only, never the committed project settings. A host that ignores the key, a session under permissions.blockReadsOutsideWorkingDirectories and an admin-required sandbox are unchanged by it. ' +
|
|
708
|
+
'Narrow it: choose a dedicated docs/ai/worktrees.json parentDir BEFORE the first apply — no apply removes an entry, so a parent changed later leaves the earlier entry in settings.local.json until you delete it, your own step.';
|
|
709
|
+
|
|
710
|
+
const formatWorktreesGrant = (result) => [
|
|
711
|
+
`${result.wrote ? 'added' : 'would add'} worktrees grant (sandbox.filesystem.allowWrite in ${SETTINGS_LOCAL_FILE}) entries: ${result.worktreesDirToAdd.length}`,
|
|
712
|
+
...formatEntryList(result.worktreesDirToAdd),
|
|
713
|
+
`already present (worktrees grant): ${result.worktreesDirAlreadyPresent.length}`,
|
|
714
|
+
...result.worktreesDirsToCreate.map((dir) => `${result.wrote ? 'created' : 'would create'}: ${dir}`),
|
|
715
|
+
KIT_WORKTREES_GRANT_NOTICE,
|
|
716
|
+
];
|
|
717
|
+
|
|
692
718
|
const formatGateAdvisory = (gateCandidates) => [
|
|
693
719
|
'gate advisory: candidates you may add BY HAND to .claude/settings.json or settings.local.json; this tool will NOT add them',
|
|
694
720
|
...(gateCandidates.length
|
|
@@ -710,7 +736,10 @@ const formatDefaultMode = (result) =>
|
|
|
710
736
|
: `defaultMode: ${result.setsDefaultMode ? `would set to ${ACCEPT_EDITS_MODE}` : 'would not be set by this run'}`;
|
|
711
737
|
|
|
712
738
|
const formatVelocityProfileResult = (result) =>
|
|
713
|
-
[
|
|
739
|
+
result.worktrees ? [
|
|
740
|
+
result.dryRun ? 'agent-workflow velocity profile - DRY RUN (no changes)' : 'agent-workflow velocity profile - APPLY',
|
|
741
|
+
...formatWorktreesGrant(result),
|
|
742
|
+
].join(LF) : [
|
|
714
743
|
result.dryRun ? 'agent-workflow velocity profile - DRY RUN (no changes)' : 'agent-workflow velocity profile - APPLY',
|
|
715
744
|
...formatAllowlist(result),
|
|
716
745
|
...formatKitTier(result),
|
|
@@ -986,7 +1015,65 @@ const deriveUsedBridgeTier = (root, deps = {}) => {
|
|
|
986
1015
|
return { used, recipe, dirs, env, home };
|
|
987
1016
|
};
|
|
988
1017
|
|
|
1018
|
+
const NO_WORKTREES_GRANT = Object.freeze({
|
|
1019
|
+
worktrees: false, worktreesDir: null, worktreesDirToAdd: [], worktreesDirAlreadyPresent: [], worktreesDirsToCreate: [],
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
const assertLocalSandboxMergeable = (data) => {
|
|
1023
|
+
const malformed = (key, shape) => makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_LOCAL_FILE}: ${key} must be ${shape}`);
|
|
1024
|
+
const sandbox = data?.sandbox;
|
|
1025
|
+
if (sandbox === undefined) return {};
|
|
1026
|
+
if (!isJsonObject(sandbox)) throw malformed('sandbox', 'a JSON object');
|
|
1027
|
+
for (const key of ['filesystem', 'network']) {
|
|
1028
|
+
if (sandbox[key] !== undefined && !isJsonObject(sandbox[key])) throw malformed(`sandbox.${key}`, 'a JSON object');
|
|
1029
|
+
}
|
|
1030
|
+
if (sandbox.excludedCommands !== undefined && !Array.isArray(sandbox.excludedCommands)) throw malformed('sandbox.excludedCommands', 'an array');
|
|
1031
|
+
if (sandbox.network?.allowedDomains !== undefined && !Array.isArray(sandbox.network.allowedDomains)) {
|
|
1032
|
+
throw malformed('sandbox.network.allowedDomains', 'an array');
|
|
1033
|
+
}
|
|
1034
|
+
const allowWrite = sandbox.filesystem?.allowWrite;
|
|
1035
|
+
if (allowWrite !== undefined && !(Array.isArray(allowWrite) && allowWrite.every(isResolvableDeclaredEntry))) {
|
|
1036
|
+
throw malformed('sandbox.filesystem.allowWrite', 'an array of non-blank strings');
|
|
1037
|
+
}
|
|
1038
|
+
return sandbox;
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const planWorktreesGrant = (preflight, opts) => {
|
|
1042
|
+
const home = opts.home ?? homedir();
|
|
1043
|
+
const root = preflight.cwd;
|
|
1044
|
+
const dir = (() => {
|
|
1045
|
+
try {
|
|
1046
|
+
return worktreesGrantDir(root, opts);
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, String(err?.message ?? err).replace(/^\[agent-workflow-kit\] /u, ''));
|
|
1049
|
+
}
|
|
1050
|
+
})();
|
|
1051
|
+
if (worktreesGrantHoldsHome(dir, home, opts)) {
|
|
1052
|
+
throw makeVelocityProfileError(VELOCITY_WORKTREES_HOME,
|
|
1053
|
+
`the worktrees parent dir ${dir} is the home or holds it - the kit grants no write surface there; point docs/ai/worktrees.json parentDir at a dedicated directory`);
|
|
1054
|
+
}
|
|
1055
|
+
const sandbox = assertLocalSandboxMergeable(preflight.localSettings?.data);
|
|
1056
|
+
const entry = homeRelative(dir, home);
|
|
1057
|
+
const { dirs: worktreesDirToAdd } = sandboxSurfaceDelta({ excludedCommands: [], hosts: [], dirs: [entry] }, sandbox, { home, root });
|
|
1058
|
+
const existing = nearestExistingDir(dir, opts);
|
|
1059
|
+
const worktreesDirsToCreate = [];
|
|
1060
|
+
for (let at = dir; at !== existing; at = dirname(at)) worktreesDirsToCreate.unshift(at);
|
|
1061
|
+
return {
|
|
1062
|
+
...planVelocityProfile(preflight, {}),
|
|
1063
|
+
toAdd: [],
|
|
1064
|
+
alreadyPresent: [],
|
|
1065
|
+
askToAdd: [],
|
|
1066
|
+
askAlreadyPresent: [],
|
|
1067
|
+
worktrees: true,
|
|
1068
|
+
worktreesDir: entry,
|
|
1069
|
+
worktreesDirToAdd,
|
|
1070
|
+
worktreesDirAlreadyPresent: worktreesDirToAdd.length ? [] : [entry],
|
|
1071
|
+
worktreesDirsToCreate,
|
|
1072
|
+
};
|
|
1073
|
+
};
|
|
1074
|
+
|
|
989
1075
|
export const planVelocityProfile = (preflight, opts = {}) => {
|
|
1076
|
+
if (opts.worktrees === true) return planWorktreesGrant(preflight, opts);
|
|
990
1077
|
const { acceptEdits, kitTools, bridgeTier } = opts;
|
|
991
1078
|
const projectAllow = getAllowEntries(preflight.projectSettings?.data);
|
|
992
1079
|
const projectAsk = getPermissions(preflight.projectSettings?.data).ask ?? [];
|
|
@@ -1041,17 +1128,32 @@ export const planVelocityProfile = (preflight, opts = {}) => {
|
|
|
1041
1128
|
dirsAlreadyPresent,
|
|
1042
1129
|
executeToAdd,
|
|
1043
1130
|
setsDefaultMode: acceptEdits === true,
|
|
1131
|
+
...NO_WORKTREES_GRANT,
|
|
1044
1132
|
};
|
|
1045
1133
|
};
|
|
1046
1134
|
|
|
1047
|
-
|
|
1135
|
+
// The parent chain goes first, outermost first, so the entry never names a missing directory.
|
|
1136
|
+
const applyWorktreesGrant = (projectDir, preflight, plan, resultBase, deps) => {
|
|
1137
|
+
const fs = fsDeps(deps);
|
|
1138
|
+
for (const dir of plan.worktreesDirsToCreate) fs.mkdir(dir);
|
|
1139
|
+
if (plan.worktreesDirToAdd.length === 0) return { wrote: true, dryRun: false, settingsPath: null, ...resultBase };
|
|
1140
|
+
if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
|
|
1141
|
+
const settingsPath = join(projectDir, SETTINGS_LOCAL_FILE);
|
|
1142
|
+
const merged = mergeAllowWrite(preflight.localSettings.data ?? {}, plan.worktreesDirToAdd);
|
|
1143
|
+
fs.writeFile(settingsPath, formatJson(merged, preflight.localSettings.eol ?? LF), UTF8);
|
|
1144
|
+
return { wrote: true, dryRun: false, settingsPath, ...resultBase };
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true, kitTools = false, bridgeTier = false, worktrees = false } = {}, deps = {}) => {
|
|
1048
1148
|
const projectDir = cwd ?? deps.cwd ?? process.cwd();
|
|
1149
|
+
if (worktrees === true) assertTargetWritable(join(projectDir, SETTINGS_LOCAL_FILE), deps, SETTINGS_LOCAL_FILE);
|
|
1049
1150
|
const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
|
|
1050
1151
|
const plan = planVelocityProfile(preflight, {
|
|
1051
1152
|
...deps,
|
|
1052
1153
|
acceptEdits,
|
|
1053
1154
|
kitTools,
|
|
1054
1155
|
bridgeTier,
|
|
1156
|
+
worktrees,
|
|
1055
1157
|
});
|
|
1056
1158
|
// Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
|
|
1057
1159
|
// validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
|
|
@@ -1079,6 +1181,7 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
|
|
|
1079
1181
|
`not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) - run init/upgrade first`,
|
|
1080
1182
|
);
|
|
1081
1183
|
}
|
|
1184
|
+
if (worktrees === true) return applyWorktreesGrant(projectDir, preflight, plan, resultBase, deps);
|
|
1082
1185
|
|
|
1083
1186
|
const fs = fsDeps(deps);
|
|
1084
1187
|
const settingsPath = join(projectDir, SETTINGS_FILE);
|
|
@@ -1697,7 +1800,7 @@ export const formatAutonomyResult = (r) => {
|
|
|
1697
1800
|
// boundary is deliberate: permissions.* precedence is the harness's own documented permission
|
|
1698
1801
|
// model (the layer this whole mode rests on), so those masks stay flat statements.
|
|
1699
1802
|
const scoped = m.key.startsWith(`${SANDBOX_KEY}.`) ? ` ${HOST_HONORS_QUALIFIER}` : '';
|
|
1700
|
-
lines.push(` ⚠ ${SETTINGS_LOCAL_FILE} sets ${m.key}=${JSON.stringify(m.local)}, which MASKS this render's ${m.key}=${JSON.stringify(m.rendered)} (local > project) — the local value wins${scoped}, so the render is not effective for that key; the local file is the maintainer's and
|
|
1803
|
+
lines.push(` ⚠ ${SETTINGS_LOCAL_FILE} sets ${m.key}=${JSON.stringify(m.local)}, which MASKS this render's ${m.key}=${JSON.stringify(m.rendered)} (local > project) — the local value wins${scoped}, so the render is not effective for that key; the local file is the maintainer's and this render never writes it.`);
|
|
1701
1804
|
}
|
|
1702
1805
|
for (const b of r.redlineBypass ?? []) {
|
|
1703
1806
|
lines.push(` ⚠ DEGRADE: ${b.source} has a pre-existing allow entry ${b.entry} that would BYPASS the rendered red-line(s) ${b.redlines.join('/')} (a matching allow rule AUTO-APPROVES the command, defeating ask/deny) — remove it by hand; this render never touches permissions.allow.`);
|
|
@@ -1881,6 +1984,7 @@ export const parseArgs = (argv) => {
|
|
|
1881
1984
|
if (arg === FLAG_KIT_TOOLS) return { ...state, kitTools: true };
|
|
1882
1985
|
if (arg === FLAG_BRIDGE_TIER) return { ...state, bridgeTier: true };
|
|
1883
1986
|
if (arg === FLAG_AUTONOMY) return { ...state, autonomy: true };
|
|
1987
|
+
if (arg === FLAG_WORKTREES) return { ...state, worktrees: true };
|
|
1884
1988
|
if (arg === FLAG_CHECK) return { ...state, check: true };
|
|
1885
1989
|
if (arg === FLAG_CWD) {
|
|
1886
1990
|
const next = allArgs[index + 1];
|
|
@@ -1890,8 +1994,11 @@ export const parseArgs = (argv) => {
|
|
|
1890
1994
|
if (arg.startsWith('-')) throw fail(EXIT_USAGE, `unknown flag: ${arg}`);
|
|
1891
1995
|
throw fail(EXIT_USAGE, `unexpected argument: ${arg}`);
|
|
1892
1996
|
},
|
|
1893
|
-
{ help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, bridgeTier: false, autonomy: false, check: false, cwd: undefined, skipNext: false },
|
|
1997
|
+
{ help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, bridgeTier: false, autonomy: false, worktrees: false, check: false, cwd: undefined, skipNext: false },
|
|
1894
1998
|
);
|
|
1999
|
+
const worktreesConflict = [[parsed.autonomy, FLAG_AUTONOMY], [parsed.kitTools, FLAG_KIT_TOOLS],
|
|
2000
|
+
[parsed.bridgeTier, FLAG_BRIDGE_TIER], [parsed.acceptEdits, FLAG_ACCEPT_EDITS]].find(([set]) => parsed.worktrees && set);
|
|
2001
|
+
if (worktreesConflict) throw fail(EXIT_USAGE, `${FLAG_WORKTREES} is its own mode writing ${SETTINGS_LOCAL_FILE} alone — it cannot be combined with ${worktreesConflict[1]}`);
|
|
1895
2002
|
|
|
1896
2003
|
if (parsed.dryRunFlag && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_DRY_RUN} and ${FLAG_APPLY} cannot be used together`);
|
|
1897
2004
|
// The --autonomy render is a SEPARATE mode from the allowlist seeding: the allowlist-only flags
|
|
@@ -1911,6 +2018,7 @@ export const parseArgs = (argv) => {
|
|
|
1911
2018
|
kitTools: parsed.kitTools,
|
|
1912
2019
|
bridgeTier: parsed.bridgeTier,
|
|
1913
2020
|
autonomy: parsed.autonomy,
|
|
2021
|
+
worktrees: parsed.worktrees,
|
|
1914
2022
|
check: parsed.check,
|
|
1915
2023
|
cwd: parsed.cwd,
|
|
1916
2024
|
};
|
|
@@ -1939,7 +2047,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
1939
2047
|
return EXIT_OK;
|
|
1940
2048
|
}
|
|
1941
2049
|
const result = writeVelocityProfile(
|
|
1942
|
-
{ cwd, acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools, bridgeTier: args.bridgeTier },
|
|
2050
|
+
{ cwd, acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools, bridgeTier: args.bridgeTier, worktrees: args.worktrees },
|
|
1943
2051
|
deps,
|
|
1944
2052
|
);
|
|
1945
2053
|
log(formatVelocityProfileResult(result));
|