@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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// ensure-executor.mjs — the EIGHTH upgrade ensure, fifth in the fixed order right after `vehicles`:
|
|
2
|
-
// the placed executor vehicle (.claude/agents/executor.md)
|
|
3
|
-
//
|
|
4
|
-
// body
|
|
2
|
+
// the placed executor vehicle (.claude/agents/executor.md) judged against the body derived from
|
|
3
|
+
// docs/ai/vehicles.json. It never writes: the agents writer PLACES the vehicle on consent, and a placed
|
|
4
|
+
// body that differs is reported `pending` — the advisor's executor-vehicle.differing item re-derives it.
|
|
5
5
|
//
|
|
6
|
-
// The state table, in the order the cells are proven
|
|
6
|
+
// The state table, in the order the cells are proven:
|
|
7
7
|
// | cell | .claude, .claude/agents | placed path | docs/ai/vehicles.json | answer | read before answering |
|
|
8
8
|
// |------|-------------------------|-------------|-----------------------|--------|-----------------------|
|
|
9
9
|
// | 0 | a symlink or not a directory | any | any | failed / wrong-node-kind | nothing |
|
|
@@ -12,30 +12,27 @@
|
|
|
12
12
|
// | 3 | directories | regular file | unreadable (malformed, read or lstat error) | failed / vehicle-settings-unreadable | the settings only |
|
|
13
13
|
// | 4 | directories | regular file | present or absent | failed / bundle-unreadable when the bundled template cannot be read | settings, bundle |
|
|
14
14
|
// | 5 | directories | regular file, bytes equal to the derived body | present or absent | already-current | settings, bundle, placed file |
|
|
15
|
-
// | 6 | directories | regular file, bytes differ | present or absent |
|
|
15
|
+
// | 6 | directories | regular file, bytes differ (executorBodyDiffers) | present or absent | pending, on the dry run and the run alike | settings, bundle, placed file |
|
|
16
16
|
// | 7 | an lstat of `.claude`, `.claude/agents` or the placed path throwing other than ENOENT, or the read of the placed file throwing | | | NOT caught: the error propagates to the CLI's catch-all (`unexpected-error`) | |
|
|
17
17
|
//
|
|
18
18
|
// Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { lstatSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { assertDirSafe, AGENTS_DIR, CLAUDE_DIR, CHEAP_AGENTS_SYMLINK, EXECUTOR_VEHICLE_REL, executorTemplate, readExecutorPosture } from './cheap-agents-read.mjs';
|
|
22
|
+
import { composeFailure, composeOutcome, probeSeedTarget } from './ensure-ops.mjs';
|
|
23
|
+
import { assertDirSafe, AGENTS_DIR, CLAUDE_DIR, CHEAP_AGENTS_SYMLINK, EXECUTOR_VEHICLE_REL, executorBodyDiffers, executorTemplate, readExecutorPosture } from './cheap-agents-read.mjs';
|
|
25
24
|
import { VEHICLES_REL } from './vehicle-settings.mjs';
|
|
26
25
|
|
|
27
26
|
export const EXECUTOR_OP = 'executor';
|
|
28
27
|
|
|
29
28
|
const BUNDLE_DIR = ['references', 'agents'];
|
|
30
|
-
const ENCODING = 'utf8';
|
|
31
29
|
|
|
32
30
|
const ok = (token, lines) => composeOutcome(EXECUTOR_OP, token, lines, false);
|
|
33
31
|
const loud = (cause, ...lines) => composeFailure(EXECUTOR_OP, cause, ...lines);
|
|
34
32
|
const causeOf = (err) => String((err && err.message) || err);
|
|
35
33
|
|
|
36
|
-
export const ensureExecutor = ({ cwd, kitRoot,
|
|
34
|
+
export const ensureExecutor = ({ cwd, kitRoot, deps = {} }) => {
|
|
37
35
|
const lstat = deps.lstat ?? lstatSync;
|
|
38
|
-
const read = deps.readFile ?? readFileSync;
|
|
39
36
|
|
|
40
37
|
try {
|
|
41
38
|
assertDirSafe(join(cwd, CLAUDE_DIR), CLAUDE_DIR, { lstat });
|
|
@@ -59,29 +56,15 @@ export const ensureExecutor = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
|
59
56
|
return loud('vehicle-settings-unreadable', `${EXECUTOR_VEHICLE_REL}: ${reason} — nothing written; fix ${VEHICLES_REL} by hand, then re-run`);
|
|
60
57
|
}
|
|
61
58
|
|
|
62
|
-
|
|
59
|
+
const bundleDeps = { ...deps, bundleDir: join(kitRoot, ...BUNDLE_DIR) };
|
|
63
60
|
try {
|
|
64
|
-
|
|
61
|
+
executorTemplate(posture, bundleDeps);
|
|
65
62
|
} catch (err) {
|
|
66
63
|
return loud('bundle-unreadable', `${EXECUTOR_VEHICLE_REL}: the bundled executor template could not be read, so nothing was written — reinstall the kit. ${causeOf(err)}`);
|
|
67
64
|
}
|
|
68
65
|
|
|
69
|
-
|
|
70
|
-
if (existing === body) {
|
|
66
|
+
if (!executorBodyDiffers(cwd, bundleDeps)) {
|
|
71
67
|
return ok('already-current', [`${EXECUTOR_VEHICLE_REL}: the body derived from ${VEHICLES_REL} (model=${posture.model}, effort=${posture.effort}, source ${posture.source}) — nothing written`]);
|
|
72
68
|
}
|
|
73
|
-
|
|
74
|
-
return ok('would-re-derive', [`${EXECUTOR_VEHICLE_REL}: differs from the body derived from ${VEHICLES_REL} — would be rewritten (a hand edit is replaced, never preserved)`]);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
let result;
|
|
78
|
-
try {
|
|
79
|
-
result = writeContainedFileAtomic(cwd, target, body, deps, { label: EXECUTOR_VEHICLE_REL });
|
|
80
|
-
} catch (err) {
|
|
81
|
-
return loud('write-refused', `${EXECUTOR_VEHICLE_REL}: ${causeOf(err)}`);
|
|
82
|
-
}
|
|
83
|
-
return ok('re-derived', [
|
|
84
|
-
`${EXECUTOR_VEHICLE_REL}: rewritten to the body derived from ${VEHICLES_REL} (model=${posture.model}, effort=${posture.effort}) — a hand edit was replaced; set the model in ${VEHICLES_REL}`,
|
|
85
|
-
...tmpNote(EXECUTOR_VEHICLE_REL, result.tmpLeftBehind),
|
|
86
|
-
]);
|
|
69
|
+
return ok('pending', [`${EXECUTOR_VEHICLE_REL}: differs from the body derived from ${VEHICLES_REL} — nothing written; the advisor's executor-vehicle.differing item re-derives it on your yes`]);
|
|
87
70
|
};
|
|
@@ -18,9 +18,8 @@ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', '
|
|
|
18
18
|
// ensure's pair refresh (reader or checker) — a deployed script on a body a release shipped,
|
|
19
19
|
// rewritten to the bundled one; a custom body is rewritten only by the replace run the user's yes
|
|
20
20
|
// starts (--replace-custom), never by a plain run.
|
|
21
|
-
|
|
22
|
-
export const
|
|
23
|
-
export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate', 'would-re-derive']);
|
|
21
|
+
export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated']);
|
|
22
|
+
export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate']);
|
|
24
23
|
|
|
25
24
|
// The CLOSED outcome vocabulary. Closed at RUNTIME, not by convention: composing an outcome with a
|
|
26
25
|
// token outside this list throws, so an op cannot quietly invent a word the mode doc has never heard
|
|
@@ -33,6 +32,7 @@ export const ENSURE_TOKENS = Object.freeze([
|
|
|
33
32
|
'already-current',
|
|
34
33
|
'already-present',
|
|
35
34
|
'not-placed',
|
|
35
|
+
'pending',
|
|
36
36
|
'customized-preserved',
|
|
37
37
|
'malformed-preserved',
|
|
38
38
|
'skipped-no-node-evidence',
|
|
@@ -80,7 +80,7 @@ export const RELAYED_ENSURE_TOKENS = Object.freeze([
|
|
|
80
80
|
'note-refreshed',
|
|
81
81
|
'refreshed',
|
|
82
82
|
'regenerated',
|
|
83
|
-
'
|
|
83
|
+
'pending',
|
|
84
84
|
'already-current',
|
|
85
85
|
'customized-preserved',
|
|
86
86
|
'malformed-preserved',
|
package/tools/git-hooks.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// git-hooks.mjs — the thin writer of the two git-hook items (`enforcement`, `commit-guard`): it copies
|
|
3
3
|
// the bundled scripts create-only and spawns the project's own scripts/install-git-hooks.mjs, which
|
|
4
4
|
// decides the hook at the write. The leaf decides nothing about the hook: the danger check binds the
|
|
5
|
-
// copies and the installer bytes this dry run answers, never git's hooks dir.
|
|
5
|
+
// copies and the installer bytes this dry run answers, never git's hooks dir. It is also the home of the
|
|
6
|
+
// one pre-commit reader, which the commit-guard apply runs after the installer to verify the arm.
|
|
6
7
|
import { lstatSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync } from 'node:fs';
|
|
7
8
|
import { createHash } from 'node:crypto';
|
|
8
9
|
import { spawnSync } from 'node:child_process';
|
|
@@ -11,6 +12,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
12
|
import { writeProjectFileCreateOnly } from './atomic-write.mjs';
|
|
12
13
|
import { NODE_EVIDENCE_SCRIPTS } from './node-evidence.mjs';
|
|
13
14
|
import { shellQuoteArg } from './repo-lex.mjs';
|
|
15
|
+
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
14
16
|
import { isDirectRun } from './direct-run.mjs';
|
|
15
17
|
|
|
16
18
|
const TOOLS = dirname(fileURLToPath(import.meta.url));
|
|
@@ -39,6 +41,34 @@ const nodeAt = (path, lstat) => {
|
|
|
39
41
|
const isPlainDir = (node) => node !== null && node.isDirectory() && !node.isSymbolicLink();
|
|
40
42
|
const isFile = (node) => node !== null && node.isFile() && !node.isSymbolicLink();
|
|
41
43
|
|
|
44
|
+
// The absolute hooks dir git itself reports (a linked worktree's own, core.hooksPath), or null outside a git tree.
|
|
45
|
+
export const resolveGitHooksPath = (projectDir, env = process.env) => {
|
|
46
|
+
const result = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: projectDir, env, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
47
|
+
if (result.error || result.status !== 0) return null;
|
|
48
|
+
return resolve(projectDir, result.stdout.toString('utf8').replace(/\r?\n$/, ''));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const INSTALLER_MARKER_RE = /:install-git-hooks\.mjs$/m;
|
|
52
|
+
const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
53
|
+
// Armed means the exact line the installer writes on the kit's own guard path; a commented or lookalike line is not.
|
|
54
|
+
export const canonicalGuardLineRe = (toolsDir = TOOLS) => new RegExp(`^node "${escapeRe(join(toolsDir, 'commit-guard.mjs'))}" --check$`, 'm');
|
|
55
|
+
|
|
56
|
+
// The one reader of git's pre-commit: whose hook it is, and whether the canonical guard line arms it.
|
|
57
|
+
export const readPreCommitState = (root, deps = {}) => {
|
|
58
|
+
const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root, deps.env);
|
|
59
|
+
if (hooksPath === null) return { state: 'no-hooks-dir', armed: false };
|
|
60
|
+
const path = join(hooksPath, 'pre-commit');
|
|
61
|
+
let hook;
|
|
62
|
+
try {
|
|
63
|
+
(deps.lstat ?? lstatSync)(path);
|
|
64
|
+
hook = String((deps.readFile ?? readFileSync)(path, 'utf8'));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code === 'ENOENT') return { state: 'absent', armed: false };
|
|
67
|
+
return { state: 'unreadable', code: error?.code ?? String(error?.message ?? error), path, armed: false };
|
|
68
|
+
}
|
|
69
|
+
return { state: INSTALLER_MARKER_RE.test(hook) ? 'managed' : 'unmanaged', armed: canonicalGuardLineRe(deps.toolsDir).test(hook) };
|
|
70
|
+
};
|
|
71
|
+
|
|
42
72
|
// The dry run: what the apply copies and the installer it runs, with the bytes of each.
|
|
43
73
|
export const planGitHooks = ({ root, variant, toolsDir = TOOLS, lstat = lstatSync, readFile = readFileSync }) => {
|
|
44
74
|
const scripts = join(root, SCRIPTS_REL);
|
|
@@ -115,7 +145,7 @@ const rollBack = (plan, created, io) => {
|
|
|
115
145
|
|
|
116
146
|
export const applyGitHooks = (plan, io = {}) => {
|
|
117
147
|
const resolved = { lstat: lstatSync, unlink: unlinkSync, rmdir: rmdirSync, readdir: readdirSync, env: process.env,
|
|
118
|
-
log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly,
|
|
148
|
+
log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly, toolsDir: TOOLS,
|
|
119
149
|
spawn: (argv, options) => spawnSync(argv[0], argv.slice(1), options), ...io };
|
|
120
150
|
const created = [];
|
|
121
151
|
const leftovers = [];
|
|
@@ -145,7 +175,15 @@ export const applyGitHooks = (plan, io = {}) => {
|
|
|
145
175
|
const child = resolved.spawn(plan.runs.argv, { cwd: plan.root, env: resolved.env, stdio: 'inherit' });
|
|
146
176
|
if (child.status !== 0) return fail([`installer exited ${child.status ?? child.signal ?? child.error?.code}`]);
|
|
147
177
|
for (const path of leftovers) resolved.error(`not removed: ${path}`);
|
|
148
|
-
return EXIT_OK;
|
|
178
|
+
if (plan.variant !== 'commit-guard') return EXIT_OK;
|
|
179
|
+
// An installer older than the arm ignores --commit-guard and still exits 0, so the arm is read back under its env.
|
|
180
|
+
const hooksDir = resolveGitHooksPath(plan.root, resolved.env);
|
|
181
|
+
const hook = readPreCommitState(plan.root, { env: resolved.env, toolsDir: resolved.toolsDir, gitHooksPath: () => hooksDir });
|
|
182
|
+
if (hook.state === 'no-hooks-dir' || hook.armed) return EXIT_OK;
|
|
183
|
+
const path = join(hooksDir, 'pre-commit');
|
|
184
|
+
resolved.error(hook.state === 'unreadable' ? `${path} is unreadable (${hook.code}) after the installer exited 0`
|
|
185
|
+
: `${path} carries no commit-guard line after the installer exited 0 — an installer older than the arm ignores --commit-guard; refreshing ${INSTALLER_REL} from the kit's references/scripts/ copy is your own step`);
|
|
186
|
+
return EXIT_INCOMPLETE;
|
|
149
187
|
};
|
|
150
188
|
|
|
151
189
|
export const parseArgs = (argv) => {
|
package/tools/hide-footprint.mjs
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
// Decisions (plan D1–D16): one managed fence, re-derived wholesale → re-run is byte-identical (zero
|
|
15
15
|
// diff); tracked → ASK; present-but-untracked high-risk (generic names) → ASK; everything else → HIDE;
|
|
16
16
|
// asks are excluded from the block unless opted in via `--include` (an asks-only, traversal/glob-free
|
|
17
|
-
// path). Add-local-first, then DETECT + REPORT the residual legacy global block —
|
|
18
|
-
//
|
|
19
|
-
// host's
|
|
17
|
+
// path). Add-local-first, then DETECT + REPORT the residual legacy global block — the hide never writes
|
|
18
|
+
// the global file; removal is the `--remove-global` mode alone, the advisor's footprint-global item
|
|
19
|
+
// (prints a restorable backup; another of the host's hidden repos may rely on the same lines). Windows is supported (text
|
|
20
20
|
// edit; forward-slash patterns; CRLF preserved via EOL detection). Dependency-free, Node >= 22.
|
|
21
21
|
|
|
22
22
|
import {
|
|
@@ -90,10 +90,13 @@ const canonicalize = (pattern) => {
|
|
|
90
90
|
// canonicalize alone misses glob children, so the consented line would be dropped → a silent un-hide.
|
|
91
91
|
const recognizeHideRule = (pattern) => canonicalize(pattern) ?? (matchesKnownGlob(pattern) ? normalizeSlashes(pattern) : null);
|
|
92
92
|
|
|
93
|
-
//
|
|
94
|
-
const
|
|
93
|
+
// The historical lines any repository's own ignore may carry: a removal plan needs one kit-specific line besides them.
|
|
94
|
+
const GENERIC_GLOBAL_PATHS = new Set(['/AGENTS.md', '/CLAUDE.md', '/docs/ai/', '/docs/plans/', '/.claude/skills/']);
|
|
95
|
+
|
|
96
|
+
// Slash-tolerant membership in a global path set (the HISTORICAL one by default).
|
|
97
|
+
const historicalMatch = (pattern, set = HISTORICAL_GLOBAL_PATHS) => {
|
|
95
98
|
const p = normalizeSlashes(pattern);
|
|
96
|
-
return
|
|
99
|
+
return set.has(p) || set.has(`${p}/`) || (p.endsWith('/') && set.has(p.slice(0, -1)));
|
|
97
100
|
};
|
|
98
101
|
|
|
99
102
|
// ── EOL + line model ────────────────────────────────────────────────────────────
|
|
@@ -144,8 +147,8 @@ const defaultGit = (args, { cwd, env }) => {
|
|
|
144
147
|
const runGit = (deps, dir, args) => (deps.git ?? defaultGit)(args, { cwd: dir, env: deps.env ?? process.env });
|
|
145
148
|
|
|
146
149
|
const fsOf = (deps = {}) => ({
|
|
147
|
-
readFile: deps.readFile ?? ((p) => readFileSync(p,
|
|
148
|
-
writeFile: deps.writeFile ?? ((p, c) => writeFileSync(p, c,
|
|
150
|
+
readFile: deps.readFile ?? ((p, encoding = 'utf8') => readFileSync(p, encoding)),
|
|
151
|
+
writeFile: deps.writeFile ?? ((p, c, encoding = 'utf8') => writeFileSync(p, c, encoding)),
|
|
149
152
|
stat: deps.stat ?? statSync,
|
|
150
153
|
readdir: deps.readdir ?? readdirSync,
|
|
151
154
|
});
|
|
@@ -362,45 +365,60 @@ const globalExcludesPath = (deps, dir, home) => {
|
|
|
362
365
|
if (r.status === 1) return null; // unset = NORMAL "nothing to migrate" (not a STOP)
|
|
363
366
|
if (r.status !== 0) throw stop(`git config --get core.excludesFile failed (exit ${r.status}): ${r.stderr.trim()}`);
|
|
364
367
|
const p = r.stdout.trim();
|
|
365
|
-
return p ? expandTilde(p, home) : null;
|
|
368
|
+
return p ? resolve(dir, expandTilde(p, home)) : null;
|
|
366
369
|
};
|
|
367
370
|
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
export const
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
let
|
|
371
|
+
// The pure core over the global file read as latin1: each recognised legacy line goes with its own
|
|
372
|
+
// terminator and every other byte stays; null without one kit-specific line among them.
|
|
373
|
+
export const globalRemovalOf = (text) => {
|
|
374
|
+
const parts = text.split(/(\r\n|\n|\r)/);
|
|
375
|
+
const removedLines = [];
|
|
376
|
+
let after = '';
|
|
377
|
+
let specific = false;
|
|
378
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
379
|
+
const t = parts[i].trim();
|
|
380
|
+
const pattern = lineToPattern(parts[i]);
|
|
381
|
+
const marker = isLegacyGlobalHeader(t) || isAd013Comment(t);
|
|
382
|
+
if (marker || (pattern && historicalMatch(pattern))) {
|
|
383
|
+
removedLines.push(parts[i]);
|
|
384
|
+
specific ||= marker || !historicalMatch(pattern, GENERIC_GLOBAL_PATHS);
|
|
385
|
+
} else after += parts[i] + (parts[i + 1] ?? '');
|
|
386
|
+
}
|
|
387
|
+
return specific ? { removedLines, after } : null;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// The one answer the hide's kept line, the advisor's probe, the danger check and --remove-global read.
|
|
391
|
+
export const planGlobalRemoval = (deps, root, { home }) => {
|
|
392
|
+
const source = globalExcludesPath(deps, root, home);
|
|
393
|
+
if (!source) return null;
|
|
394
|
+
let before;
|
|
375
395
|
try {
|
|
376
|
-
|
|
396
|
+
before = fsOf(deps).readFile(source, 'latin1');
|
|
377
397
|
} catch (err) {
|
|
378
|
-
if (err && err.code === 'ENOENT') return
|
|
379
|
-
throw stop(`cannot read global excludes ${
|
|
398
|
+
if (err && err.code === 'ENOENT') return null;
|
|
399
|
+
throw stop(`cannot read global excludes ${source} (${err.code ?? 'fs error'})`);
|
|
380
400
|
}
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
401
|
+
const removal = globalRemovalOf(before);
|
|
402
|
+
return removal && { source, before, ...removal };
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Detect + REPORT a residual legacy global block; the hide never writes the global file.
|
|
406
|
+
export const migrateFromGlobal = (deps, dir, { home }) => {
|
|
407
|
+
const plan = planGlobalRemoval(deps, dir, { home });
|
|
408
|
+
if (!plan) return { found: false, source: null, removedLines: [], action: 'none' };
|
|
409
|
+
return { found: true, source: plan.source, removedLines: plan.removedLines, action: 'kept' };
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// The --remove-global mode: no exclude file read, no local block written; a refused write is the lane's STOP.
|
|
413
|
+
const removeGlobal = (opts, deps) => {
|
|
414
|
+
const plan = planGlobalRemoval(deps, resolve(opts.dir ?? process.cwd()), { home: deps.home ?? os.homedir() });
|
|
415
|
+
if (!plan || opts.dryRun) return { plan, action: plan ? 'would-remove' : 'none' };
|
|
416
|
+
try {
|
|
417
|
+
fsOf(deps).writeFile(plan.source, plan.after, 'latin1');
|
|
418
|
+
} catch (err) {
|
|
419
|
+
throw stop(`cannot write the global excludes ${plan.source} (${err?.code ?? err?.message ?? err}) — the block stays`);
|
|
399
420
|
}
|
|
400
|
-
|
|
401
|
-
if (!removeGlobal) return { found: true, source: gp, removedLines: removed, action: 'kept' };
|
|
402
|
-
if (!dryRun) fsx.writeFile(gp, joinLines(kept, eol));
|
|
403
|
-
return { found: true, source: gp, removedLines: removed, action: 'removed', backup: removed.join('\n') };
|
|
421
|
+
return { plan, action: 'removed' };
|
|
404
422
|
};
|
|
405
423
|
|
|
406
424
|
// ── --include validation (D4 — only an asks[] path; no traversal/glob) ───────────
|
|
@@ -452,9 +470,9 @@ export const hideFootprint = (opts = {}, deps = {}) => {
|
|
|
452
470
|
const fenceBodyLines = fence.state === 'ok' ? lines.slice(fence.startIdx + 1, fence.endIdx) : [];
|
|
453
471
|
const after = fence.state === 'ok' ? lines.slice(fence.endIdx + 1) : [];
|
|
454
472
|
|
|
455
|
-
// ── unhide (D12): drop our fence; report
|
|
473
|
+
// ── unhide (D12): drop our fence; report the residual global block ──────
|
|
456
474
|
if (opts.unhide) {
|
|
457
|
-
const global = migrateFromGlobal(deps, dir, { home
|
|
475
|
+
const global = migrateFromGlobal(deps, dir, { home });
|
|
458
476
|
const newLines = [...before, ...after];
|
|
459
477
|
const newContent = joinLines(newLines, eol);
|
|
460
478
|
const changed = newContent !== content;
|
|
@@ -520,8 +538,8 @@ export const hideFootprint = (opts = {}, deps = {}) => {
|
|
|
520
538
|
throw stop(`hide verification failed — these were written but are not hidden by the project-local exclude: ${notHidden.map((v) => v.path).join(', ')}`);
|
|
521
539
|
}
|
|
522
540
|
|
|
523
|
-
// ── then detect
|
|
524
|
-
const global = migrateFromGlobal(deps, dir, { home
|
|
541
|
+
// ── then detect + report the residual legacy global block (D6) ─────────────────
|
|
542
|
+
const global = migrateFromGlobal(deps, dir, { home });
|
|
525
543
|
|
|
526
544
|
const action = !changed ? 'noop' : fence.state === 'ok' ? 'updated' : 'created';
|
|
527
545
|
return {
|
|
@@ -545,29 +563,29 @@ export const hideFootprint = (opts = {}, deps = {}) => {
|
|
|
545
563
|
|
|
546
564
|
// ── CLI ───────────────────────────────────────────────────────────────────────────
|
|
547
565
|
|
|
548
|
-
const USAGE = `usage: hide-footprint [--dir <project>] [--reconcile] [--include=<path>]... [--
|
|
566
|
+
const USAGE = `usage: hide-footprint [--dir <project>] [--reconcile] [--include=<path>]... [--unhide] [--dry-run] [--help]
|
|
567
|
+
hide-footprint --remove-global [--dir <project>] [--dry-run]
|
|
549
568
|
|
|
550
569
|
--dir <project> the target project (default: cwd)
|
|
551
570
|
--reconcile upgrade mode — infer visibility first; write zero bytes when VISIBLE, ASK when AMBIGUOUS
|
|
552
571
|
--include=<path> opt a surfaced ASK path into the hidden set (only an asks[] path; no glob/traversal)
|
|
553
|
-
--
|
|
554
|
-
--remove
|
|
555
|
-
--unhide remove the project-local managed block (residual global only with --remove-global)
|
|
572
|
+
--remove-global its own mode: remove the recognized legacy machine-global block (prints a restorable backup)
|
|
573
|
+
--unhide remove the project-local managed block
|
|
556
574
|
--dry-run print the plan; change nothing
|
|
557
575
|
--help, -h this help
|
|
558
576
|
|
|
559
|
-
Writes ONE managed block in the PROJECT-LOCAL .git/info/exclude
|
|
577
|
+
Writes ONE managed block in the PROJECT-LOCAL .git/info/exclude; a residual legacy machine-global block is
|
|
578
|
+
kept and reported, and only --remove-global (the advisor's footprint-global item) removes it.
|
|
560
579
|
Tracked files are never silently un-tracked — the tool prints the \`git rm --cached\` it will not run.`;
|
|
561
580
|
|
|
562
581
|
const parseArgs = (argv) => {
|
|
563
|
-
const out = { dir: undefined, reconcile: false, include: [], removeGlobal: false,
|
|
582
|
+
const out = { dir: undefined, reconcile: false, include: [], removeGlobal: false, unhide: false, dryRun: false, help: false, bad: null };
|
|
564
583
|
for (let i = 0; i < argv.length; i += 1) {
|
|
565
584
|
const a = argv[i];
|
|
566
585
|
if (a === '--help' || a === '-h') out.help = true;
|
|
567
586
|
else if (a === '--dry-run') out.dryRun = true;
|
|
568
587
|
else if (a === '--reconcile') out.reconcile = true;
|
|
569
588
|
else if (a === '--unhide') out.unhide = true;
|
|
570
|
-
else if (a === '--keep-global') out.keepGlobal = true;
|
|
571
589
|
else if (a === '--remove-global') out.removeGlobal = true;
|
|
572
590
|
else if (a === '--dir') {
|
|
573
591
|
const next = argv[i + 1];
|
|
@@ -580,15 +598,22 @@ const parseArgs = (argv) => {
|
|
|
580
598
|
else { out.include.push(next); i += 1; }
|
|
581
599
|
} else out.bad = `unknown argument: ${a}`;
|
|
582
600
|
}
|
|
583
|
-
if (out.
|
|
601
|
+
if (out.removeGlobal && !out.bad && (out.reconcile || out.unhide || out.include.length)) out.bad = '--remove-global is its own mode — it cannot be combined with --reconcile, --unhide or --include';
|
|
584
602
|
return out;
|
|
585
603
|
};
|
|
586
604
|
|
|
587
605
|
const fmtGlobal = (g) => {
|
|
588
|
-
if (!g || g.action
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
606
|
+
if (!g || g.action !== 'kept') return [];
|
|
607
|
+
return [` • residual legacy machine-global block in ${g.source} (${g.removedLines.filter((l) => l.trim() && !l.trim().startsWith('#')).length} path line(s)) — KEPT + reported; the advisor's footprint-global item removes it (with a printed backup)`];
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const formatRemoval = ({ plan, action }, dryRun) => {
|
|
611
|
+
const lines = [dryRun ? 'hide-footprint — DRY RUN (no changes)' : 'hide-footprint'];
|
|
612
|
+
if (action === 'none') return [...lines, ' • no legacy machine-global block to remove'].join('\n');
|
|
613
|
+
const n = plan.removedLines.length;
|
|
614
|
+
lines.push(action === 'removed' ? ` • removed the legacy machine-global block from ${plan.source} (backup of ${n} line(s) printed below)`
|
|
615
|
+
: ` • would remove the legacy machine-global block from ${plan.source} (${n} line(s)):`);
|
|
616
|
+
return [...lines, ...plan.removedLines.map((l) => ` | ${l}`)].join('\n');
|
|
592
617
|
};
|
|
593
618
|
|
|
594
619
|
export const formatReport = (r, dryRun) => {
|
|
@@ -624,10 +649,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
624
649
|
if (args.help) { log(USAGE); return 0; }
|
|
625
650
|
if (args.bad) { errlog(args.bad); errlog(USAGE); return 2; }
|
|
626
651
|
try {
|
|
627
|
-
|
|
628
|
-
{ dir: args.dir, dryRun: args.dryRun
|
|
629
|
-
|
|
630
|
-
|
|
652
|
+
if (args.removeGlobal) {
|
|
653
|
+
log(formatRemoval(removeGlobal({ dir: args.dir, dryRun: args.dryRun }, deps), args.dryRun));
|
|
654
|
+
return 0;
|
|
655
|
+
}
|
|
656
|
+
const result = hideFootprint({ dir: args.dir, dryRun: args.dryRun, reconcile: args.reconcile, include: args.include, unhide: args.unhide }, deps);
|
|
631
657
|
log(formatReport(result, args.dryRun));
|
|
632
658
|
return 0;
|
|
633
659
|
} catch (err) {
|
package/tools/init-project.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { preflightInit } from './init-preflight.mjs';
|
|
|
13
13
|
import { buildRecommendations, hostFacts } from './recommendations.mjs';
|
|
14
14
|
import { compareSemver } from './semver-lite.mjs';
|
|
15
15
|
import { EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
|
|
16
|
-
import { LANE_HARNESS, VARIANT_LANES, consoleArgv, isEnvDependent, isProtectedPath, laneOf, writesOf } from './write-lanes.mjs';
|
|
16
|
+
import { LANE_HARNESS, VARIANT_LANES, consoleArgv, isEnvDependent, isProtectedPath, isRejudged, laneOf, writesOf } from './write-lanes.mjs';
|
|
17
17
|
|
|
18
18
|
const INSIDE_LINE = 'you are inside an agent; run this in your own terminal';
|
|
19
19
|
const APPLY_PROMPT = 'apply? y/N: ';
|
|
@@ -216,18 +216,21 @@ const previewQueue = async (context, queue) => {
|
|
|
216
216
|
}
|
|
217
217
|
context.print(result.stdout);
|
|
218
218
|
const tuple = result.stdout.match(/--apply\s+(\S+)/)?.[1];
|
|
219
|
-
const facts = { ...context.facts, tuple };
|
|
219
|
+
const facts = { ...context.facts, env: context.spawnEnv, tuple };
|
|
220
|
+
let checkDigest = null;
|
|
220
221
|
if (laneOf(item.variant) === LANE_HARNESS) {
|
|
221
222
|
try {
|
|
222
|
-
const { lines, refused } = context.checkApply(item.variant, facts, {});
|
|
223
|
+
const { lines, refused, digest } = context.checkApply(item.variant, facts, {});
|
|
223
224
|
for (const line of lines) context.print(line);
|
|
224
225
|
if (refused.length > 0) throw new Error(refused.map(({ key }) => key).join(', '));
|
|
226
|
+
checkDigest = digest;
|
|
225
227
|
} catch (error) {
|
|
226
228
|
context.print(`not applied: ${item.variant} — the danger check refused: ${error.message}`);
|
|
227
229
|
continue;
|
|
228
230
|
}
|
|
229
231
|
}
|
|
230
|
-
batch.push({ ...item,
|
|
232
|
+
batch.push({ ...item, checkFacts: facts, checkDigest, applyArgv: consoleArgv(item.variant, facts).apply,
|
|
233
|
+
writes: writesOf(item.variant, facts) });
|
|
231
234
|
printNote(context, item.key);
|
|
232
235
|
}
|
|
233
236
|
for (const item of queue.filter(({ variant }) => consoleArgv(variant, context.facts) === null)) {
|
|
@@ -254,11 +257,24 @@ const takeRecord = (context, queue) => {
|
|
|
254
257
|
}
|
|
255
258
|
};
|
|
256
259
|
|
|
260
|
+
// A rejudged item applies only while its check, run again now, answers the digest its preview showed; else the reason.
|
|
261
|
+
const rejudgeFailure = (context, item) => {
|
|
262
|
+
try {
|
|
263
|
+
const { refused, digest } = context.checkApply(item.variant, item.checkFacts, {});
|
|
264
|
+
if (refused.length > 0) return `it refuses ${refused.map(({ key }) => key).join(', ')}`;
|
|
265
|
+
return digest === item.checkDigest ? null : 'its digest moved';
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return String(error?.message || error);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
257
271
|
const runPass = async (context, queue) => {
|
|
258
272
|
const record = takeRecord(context, queue);
|
|
259
273
|
if (record === null) return { code: 1, successes: 0, variants: [] };
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
274
|
+
const previewed = await previewQueue(context, queue);
|
|
275
|
+
if (previewed.length === 0) return { code: 0, successes: 0, variants: [] };
|
|
276
|
+
// Rejudged items go first: an earlier writer of the batch (one creating .claude/) would move their check.
|
|
277
|
+
const batch = [...previewed.filter(({ variant }) => isRejudged(variant)), ...previewed.filter(({ variant }) => !isRejudged(variant))];
|
|
262
278
|
const before = new Map(record);
|
|
263
279
|
const outcome = { code: 0, successes: 0, variants: batch.map((item) => item.variant) };
|
|
264
280
|
context.print(RUN_NONE);
|
|
@@ -271,6 +287,15 @@ const runPass = async (context, queue) => {
|
|
|
271
287
|
outcome.code = 1;
|
|
272
288
|
break;
|
|
273
289
|
}
|
|
290
|
+
const failure = isRejudged(item.variant) ? rejudgeFailure(context, item) : null;
|
|
291
|
+
if (failure !== null) {
|
|
292
|
+
context.print(`not applied: ${item.variant} — its danger check no longer answers the previewed result: ${failure}`);
|
|
293
|
+
for (const later of batch.slice(index + 1)) {
|
|
294
|
+
context.print(`not applied: ${later.variant} — an earlier item's danger check no longer answers the previewed result`);
|
|
295
|
+
}
|
|
296
|
+
outcome.code = 1;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
274
299
|
const result = await launch(context, item.applyArgv, ['inherit', 'pipe', 'inherit']);
|
|
275
300
|
if (result.stdout) context.print(result.stdout);
|
|
276
301
|
const success = result.valid && (result.status === 0 || (result.status === 5 && result.stdout.includes('install completed')));
|