simt-dev 0.7.0 → 0.8.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 +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/docs.mjs +88 -0
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +94 -20
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/sys/proc.mjs +151 -0
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The read-only half of `init`: the conflict scan and the pre-write escape hatch.
|
|
2
|
+
//
|
|
3
|
+
// Split out of init.mjs when it reached the repository ceiling. Neither function writes
|
|
4
|
+
// the manifest; both answer a question the command asks before it does.
|
|
5
|
+
|
|
6
|
+
import { mkdir, readFile, symlink } from 'node:fs/promises';
|
|
7
|
+
import { git as sysGit } from '../sys/git.mjs';
|
|
8
|
+
import { rulePaths, scanDocument, summarise } from '../projection/conflicts.mjs';
|
|
9
|
+
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
10
|
+
|
|
11
|
+
const BACKUP_TAG = 'backup-before-simt-dev';
|
|
12
|
+
const ADOPT_BRANCH = 'simt-dev/adopt';
|
|
13
|
+
|
|
14
|
+
async function scanRepo(root) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
for (const path of await rulePaths(root)) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = await readFile(path, 'utf8');
|
|
20
|
+
} catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const region = parseRegion(text);
|
|
24
|
+
const handWritten = region.status === 'ok' ? `${region.before}\n${region.after}` : text;
|
|
25
|
+
findings.push(...scanDocument(handWritten, path));
|
|
26
|
+
}
|
|
27
|
+
return findings;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The opt-in escape hatch before the first write, taken when --backup is passed
|
|
32
|
+
* (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
|
|
33
|
+
* are best-effort: a repo that is not a git repo, or has no commits yet, must still be
|
|
34
|
+
* adoptable. Failure is reported as a note rather than blocking, because refusing
|
|
35
|
+
* to initialise an unborn repo would be worse than proceeding without a backup.
|
|
36
|
+
*/
|
|
37
|
+
async function establishEscapeHatch(root) {
|
|
38
|
+
const result = { branch: null, tag: null, from: null, notes: [] };
|
|
39
|
+
const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
40
|
+
if (!isRepo.ok) {
|
|
41
|
+
result.notes.push('not a git work tree; no backup branch or tag created');
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dirty = await git(root, ['status', '--porcelain']);
|
|
46
|
+
if (dirty.ok && dirty.stdout.trim() !== '') {
|
|
47
|
+
result.notes.push('worktree already dirty; left untouched, existing changes are not staged');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const head = await git(root, ['rev-parse', '--verify', 'HEAD']);
|
|
51
|
+
if (!head.ok) {
|
|
52
|
+
result.notes.push('no commits yet; nothing to tag');
|
|
53
|
+
} else {
|
|
54
|
+
const hasTag = await git(root, ['rev-parse', '--verify', `refs/tags/${BACKUP_TAG}`]);
|
|
55
|
+
if (hasTag.ok) {
|
|
56
|
+
result.tag = BACKUP_TAG;
|
|
57
|
+
} else {
|
|
58
|
+
const tag = await git(root, ['tag', BACKUP_TAG]);
|
|
59
|
+
if (tag.ok) result.tag = BACKUP_TAG;
|
|
60
|
+
else result.notes.push(`could not create tag ${BACKUP_TAG}: ${tag.stderr.trim()}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
65
|
+
if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
|
|
66
|
+
// Recorded rather than re-read by the caller: only this function knows what the
|
|
67
|
+
// branch was before it moved it, and a second `rev-parse` would answer with the new
|
|
68
|
+
// value and make every "you were on X" line read as "you were on Y".
|
|
69
|
+
result.from = current.stdout.trim();
|
|
70
|
+
const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
|
|
71
|
+
if (branch.ok) result.branch = ADOPT_BRANCH;
|
|
72
|
+
else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
|
|
73
|
+
} else if (current.ok) {
|
|
74
|
+
result.branch = ADOPT_BRANCH;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function git(cwd, args) {
|
|
81
|
+
try {
|
|
82
|
+
const stdout = await sysGit(cwd, args);
|
|
83
|
+
return { ok: true, stdout, stderr: '' };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
export { ADOPT_BRANCH, BACKUP_TAG, establishEscapeHatch, git, scanRepo };
|
package/src/commands/init.mjs
CHANGED
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
// 6. write only with --yes
|
|
16
16
|
// Never commits, never pushes (ADR-001).
|
|
17
17
|
|
|
18
|
-
import { execFile } from 'node:child_process';
|
|
19
|
-
import { promisify } from 'node:util';
|
|
20
18
|
import { mkdir, readFile, symlink } from 'node:fs/promises';
|
|
21
19
|
import { commitJournal, ignoreJournal, journaledWriteFile } from '../journal.mjs';
|
|
20
|
+
import { applyPlan, change } from '../write/plan.mjs';
|
|
22
21
|
import { invalidateCheckCache } from '../status.mjs';
|
|
23
22
|
import { dirname, join } from 'node:path';
|
|
24
23
|
|
|
@@ -47,6 +46,7 @@ import { pointerState, pointerTargets } from '../projection/project.mjs';
|
|
|
47
46
|
import { surfacesLine } from '../projection/surfaces.mjs';
|
|
48
47
|
import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../projection/project.mjs';
|
|
49
48
|
import { planAgents, writeAgentPlan, writeSkillFile } from '../agents/render.mjs';
|
|
49
|
+
import { applyAgentHooks, planAgentHooks } from './agenthook.mjs';
|
|
50
50
|
import { computeLock } from '../projection/lock.mjs';
|
|
51
51
|
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
52
52
|
import { reindex, SQLITE_FILE } from '../index/store.mjs';
|
|
@@ -55,14 +55,13 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
55
55
|
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
56
56
|
import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
|
|
57
57
|
import { PACKAGE_NAME } from '../name.mjs';
|
|
58
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
59
|
+
import { git as sysGit } from '../sys/git.mjs';
|
|
60
|
+
import { ADOPT_BRANCH, BACKUP_TAG, establishEscapeHatch, git, scanRepo } from './init-scan.mjs';
|
|
58
61
|
|
|
59
|
-
const run = promisify(execFile);
|
|
60
|
-
|
|
61
|
-
const BACKUP_TAG = 'backup-before-simt-dev';
|
|
62
|
-
const ADOPT_BRANCH = 'simt-dev/adopt';
|
|
63
62
|
|
|
64
63
|
export async function initCommand(positional, flags) {
|
|
65
|
-
const root = flags
|
|
64
|
+
const root = commandRoot(flags, positional);
|
|
66
65
|
|
|
67
66
|
const detected = await fingerprint(root);
|
|
68
67
|
if (detected.error) {
|
|
@@ -170,10 +169,29 @@ export async function initCommand(positional, flags) {
|
|
|
170
169
|
const ci = await planWorkflow(root);
|
|
171
170
|
const dependabot = await planDependabot(root);
|
|
172
171
|
const tsconfigBase = await planTsconfigBase(root);
|
|
172
|
+
// `--no-agents` opts out of the agent files and the agent-hooks in one flag: they are
|
|
173
|
+
// the two halves of the same decision — which harnesses this adoption speaks to — and
|
|
174
|
+
// splitting them into two flags would let a reader ask for the files without the hooks
|
|
175
|
+
// and end up with an agent the repository's own gate does not enforce. The parser's
|
|
176
|
+
// `no-` convention delivers it as the positive `agents` set to false.
|
|
177
|
+
const noAgents = flags.agents === false;
|
|
173
178
|
// ADR-111. The one agent this tool ships, planned with the rest so the operator sees
|
|
174
179
|
// it in the same preview that lists everything else. Written in the apply path with
|
|
175
|
-
// everything else, journaled the same way.
|
|
176
|
-
|
|
180
|
+
// everything else, journaled the same way. `--harness` narrows it to one reader, the
|
|
181
|
+
// same axis as the agent-hooks below, so the files and the hooks speak to the same set
|
|
182
|
+
// of harnesses (the rule surfaces are a separate axis: `--surfaces`).
|
|
183
|
+
const agentPlan = noAgents ? [] : await planAgents(root, { harness: flags.harness });
|
|
184
|
+
// The agent-hooks entry, absorbed into this run so the adoption enforces its own
|
|
185
|
+
// projection while the agent works. Planned with the rest for the same reason, and
|
|
186
|
+
// written into this run's journal (committed with the rest, not a second journal).
|
|
187
|
+
// `--harness` narrows it to the same reader the agent files are narrowed to.
|
|
188
|
+
let agentHooks = null;
|
|
189
|
+
if (!noAgents) {
|
|
190
|
+
agentHooks = await planAgentHooks(root, { harness: flags.harness });
|
|
191
|
+
if (agentHooks.error) {
|
|
192
|
+
return fail(agentHooks.error, EXIT.USAGE, agentHooks.remedy);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
177
195
|
// Per-developer, and planned with the rest so the operator sees it in the same
|
|
178
196
|
// preview that lists everything else. `exists` short-circuits the write: a
|
|
179
197
|
// repository that already has one keeps it exactly as it is.
|
|
@@ -255,6 +273,18 @@ export async function initCommand(positional, flags) {
|
|
|
255
273
|
// plans strip their rendered bytes; the apply path writes from `agentPlan`, which
|
|
256
274
|
// keeps it.
|
|
257
275
|
agents: agentPlan.map(({ text, ...rest }) => rest),
|
|
276
|
+
// The agent-hooks entry the apply path will write, absorbed into this run so the
|
|
277
|
+
// adoption enforces its own projection while the agent works. `text` is stripped the
|
|
278
|
+
// same way; the apply path writes from `agentHooks`, which keeps it. `null` when
|
|
279
|
+
// `--no-agents` opted out of the hooks (and the agent files with them).
|
|
280
|
+
agentHooks: agentHooks
|
|
281
|
+
? {
|
|
282
|
+
plan: agentHooks.plan.map(({ text, ...rest }) => rest),
|
|
283
|
+
shim: agentHooks.shim.row,
|
|
284
|
+
notWritten: agentHooks.others,
|
|
285
|
+
refused: agentHooks.refused.map(({ text, ...rest }) => rest),
|
|
286
|
+
}
|
|
287
|
+
: null,
|
|
258
288
|
adoption,
|
|
259
289
|
manifest: { file: MANIFEST, action: manifestAction, path: manifestFile },
|
|
260
290
|
// Local state, not a repository file: gitignored, rebuildable from `git ls-files -s`,
|
|
@@ -349,6 +379,28 @@ export async function initCommand(positional, flags) {
|
|
|
349
379
|
);
|
|
350
380
|
}
|
|
351
381
|
|
|
382
|
+
// An unreadable hooks config is a refusal, not a failure to try harder: init inherits
|
|
383
|
+
// the standalone command's refusal and blocks the whole run, because writing the other
|
|
384
|
+
// hooks while one is unreadable would leave the adoption in a state where the report it
|
|
385
|
+
// just printed is no longer true. The remedy names the opt-out, because a repository
|
|
386
|
+
// that cannot be read by one harness can still be adopted without the agent layer.
|
|
387
|
+
if (agentHooks !== null && agentHooks.refused.length > 0) {
|
|
388
|
+
payload.ok = false;
|
|
389
|
+
payload.blockedBy = 'agent-hooks-refused';
|
|
390
|
+
if (isJson(flags)) {
|
|
391
|
+
emitJson(payload);
|
|
392
|
+
} else {
|
|
393
|
+
emit(render(payload, manifest, { npm, manager, version }));
|
|
394
|
+
note('');
|
|
395
|
+
note(`refused ${agentHooks.refused.length} hooks config(s) could not be read; nothing written`);
|
|
396
|
+
}
|
|
397
|
+
return fail(
|
|
398
|
+
`${root} has a hooks config init cannot read: ${agentHooks.refused.map((p) => `${p.path} (${p.error})`).join('; ')}`,
|
|
399
|
+
EXIT.FAIL,
|
|
400
|
+
`fix or remove the named config file, then rerun: ${CLI} init --yes | or opt out of the agent layer: ${CLI} init --no-agents --yes`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
352
404
|
// One malformed target refuses the whole adoption, not just that file. Writing the
|
|
353
405
|
// others first would leave the repo half-adopted with the half that failed having
|
|
354
406
|
// produced no change at all — indistinguishable, in the diff, from never running.
|
|
@@ -407,6 +459,7 @@ export async function initCommand(positional, flags) {
|
|
|
407
459
|
after: renderDependabot(payload.dependabot.facts, {
|
|
408
460
|
stack: payload.dependabot.stack,
|
|
409
461
|
own: payload.dependabot.own,
|
|
462
|
+
root: payload.dependabot.root,
|
|
410
463
|
}),
|
|
411
464
|
});
|
|
412
465
|
}
|
|
@@ -537,6 +590,7 @@ export async function initCommand(positional, flags) {
|
|
|
537
590
|
renderDependabot(payload.dependabot.facts, {
|
|
538
591
|
stack: payload.dependabot.stack,
|
|
539
592
|
own: payload.dependabot.own,
|
|
593
|
+
root: payload.dependabot.root,
|
|
540
594
|
}),
|
|
541
595
|
);
|
|
542
596
|
note(
|
|
@@ -547,26 +601,32 @@ export async function initCommand(positional, flags) {
|
|
|
547
601
|
// scaffolds the manifest and one region but leaves the `.mdc` rules for a later
|
|
548
602
|
// `sync` produces a repo whose `check` fails on files the user was never told
|
|
549
603
|
// about — which reads as a broken adoption rather than an unfinished one.
|
|
604
|
+
//
|
|
605
|
+
// The malformed guard runs before anything is applied. A target this run cannot write
|
|
606
|
+
// has to stop the run, not leave the earlier targets written and the late one reported.
|
|
607
|
+
const badTarget = projected.targets.find((t) => t.action === 'malformed');
|
|
608
|
+
if (badTarget) {
|
|
609
|
+
// Checked before any write above; reaching here means the guard moved.
|
|
610
|
+
return fail(
|
|
611
|
+
`${badTarget.file}: ${badTarget.reason}`,
|
|
612
|
+
EXIT.FAIL,
|
|
613
|
+
`fix the markers at ${badTarget.file}:${badTarget.line}, then rerun: ${CLI} init --yes`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
// `before` is read, not inferred from `target.action`: the engine derives create from
|
|
617
|
+
// missing before-bytes, and a writer that passes its own action is the bug it removes.
|
|
618
|
+
const targetChanges = await Promise.all(
|
|
619
|
+
projected.targets
|
|
620
|
+
.filter((t) => t.action !== 'unchanged')
|
|
621
|
+
.map(async (t) =>
|
|
622
|
+
change({ path: t.path, before: await readBefore(t.path), after: t.text, owner: 'init', reason: t.action }),
|
|
623
|
+
),
|
|
624
|
+
);
|
|
625
|
+
// The caller's journal, committed once below with the manifest and identity writes.
|
|
626
|
+
await applyPlan(root, targetChanges, { mode: { apply: true }, journal, commit: false });
|
|
550
627
|
for (const target of projected.targets) {
|
|
551
|
-
if (target.action === 'unchanged') {
|
|
552
|
-
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
if (target.action === 'malformed') {
|
|
556
|
-
// Checked before any write above; reaching here means the guard moved.
|
|
557
|
-
return fail(
|
|
558
|
-
`${target.file}: ${target.reason}`,
|
|
559
|
-
EXIT.FAIL,
|
|
560
|
-
`fix the markers at ${target.file}:${target.line}, then rerun: ${CLI} init --yes`,
|
|
561
|
-
);
|
|
562
|
-
}
|
|
563
|
-
// A per-rule target lives in `.cursor/rules/`, `.claude/rules/`,
|
|
564
|
-
// `.agents/rules/` or `.github/instructions/` — four directories a repo that had
|
|
565
|
-
// no such configuration at all will not have. Creating them is part of adopting,
|
|
566
|
-
// not a detail of writing the file.
|
|
567
|
-
await mkdir(dirname(target.path), { recursive: true });
|
|
568
|
-
await journaledWriteFile(root, journal, target.path, target.text);
|
|
569
|
-
note(`wrote ${target.file} (${target.action})`);
|
|
628
|
+
if (target.action === 'unchanged') note(`current ${target.file} (already up to date)`);
|
|
629
|
+
else note(`wrote ${target.file} (${target.action})`);
|
|
570
630
|
}
|
|
571
631
|
|
|
572
632
|
// Pointers, after the file they point at exists. A regular file already at one of
|
|
@@ -593,6 +653,20 @@ export async function initCommand(positional, flags) {
|
|
|
593
653
|
// which keeps the rendered bytes the display payload strips.
|
|
594
654
|
const agentWritten = await writeAgentPlan(root, journal, agentPlan);
|
|
595
655
|
for (const f of agentWritten) note(`wrote ${f} (agent)`);
|
|
656
|
+
// The agent-hooks entry, written into this run's journal and committed with the rest of
|
|
657
|
+
// the adoption. It is part of the adoption, not a second run: a second journal would
|
|
658
|
+
// make `undo` restore the hooks separately from the files they enforce, and the reader
|
|
659
|
+
// would not know the two moves belong together.
|
|
660
|
+
if (agentHooks !== null) {
|
|
661
|
+
const { written, shimWritten } = await applyAgentHooks(root, journal, {
|
|
662
|
+
plan: agentHooks.plan,
|
|
663
|
+
shim: agentHooks.shim,
|
|
664
|
+
owner: 'init',
|
|
665
|
+
});
|
|
666
|
+
for (const p of written) note(`wrote ${p.path} (agent-hooks)`);
|
|
667
|
+
if (shimWritten) note(`wrote ${agentHooks.shim.row.path} (agent-hooks shim)`);
|
|
668
|
+
if (written.length === 0 && !shimWritten) note('agent-hooks every documented config already carried this tools entry');
|
|
669
|
+
}
|
|
596
670
|
await commitJournal(root, 'init', journal);
|
|
597
671
|
// The tree changed, so the last `check` no longer describes it: the line
|
|
598
672
|
// goes back to "no cache" rather than reporting the tree as it was (R48).
|
|
@@ -644,81 +718,6 @@ export async function initCommand(positional, flags) {
|
|
|
644
718
|
/** Scan whichever rule files exist, named or globbed. A missing file is not a
|
|
645
719
|
* finding. Generated regions are skipped: quoting a ruling is not a conflict with
|
|
646
720
|
* it, and the region is rewritten by `sync` anyway. */
|
|
647
|
-
async function scanRepo(root) {
|
|
648
|
-
const findings = [];
|
|
649
|
-
for (const path of await rulePaths(root)) {
|
|
650
|
-
let text;
|
|
651
|
-
try {
|
|
652
|
-
text = await readFile(path, 'utf8');
|
|
653
|
-
} catch {
|
|
654
|
-
continue;
|
|
655
|
-
}
|
|
656
|
-
const region = parseRegion(text);
|
|
657
|
-
const handWritten = region.status === 'ok' ? `${region.before}\n${region.after}` : text;
|
|
658
|
-
findings.push(...scanDocument(handWritten, path));
|
|
659
|
-
}
|
|
660
|
-
return findings;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* The opt-in escape hatch before the first write, taken when --backup is passed
|
|
665
|
-
* (ADR-063 amended plan 0002 §8.2 item 3 from "always" to on request). Both operations
|
|
666
|
-
* are best-effort: a repo that is not a git repo, or has no commits yet, must still be
|
|
667
|
-
* adoptable. Failure is reported as a note rather than blocking, because refusing
|
|
668
|
-
* to initialise an unborn repo would be worse than proceeding without a backup.
|
|
669
|
-
*/
|
|
670
|
-
async function establishEscapeHatch(root) {
|
|
671
|
-
const result = { branch: null, tag: null, from: null, notes: [] };
|
|
672
|
-
const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
673
|
-
if (!isRepo.ok) {
|
|
674
|
-
result.notes.push('not a git work tree; no backup branch or tag created');
|
|
675
|
-
return result;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
const dirty = await git(root, ['status', '--porcelain']);
|
|
679
|
-
if (dirty.ok && dirty.stdout.trim() !== '') {
|
|
680
|
-
result.notes.push('worktree already dirty; left untouched, existing changes are not staged');
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
const head = await git(root, ['rev-parse', '--verify', 'HEAD']);
|
|
684
|
-
if (!head.ok) {
|
|
685
|
-
result.notes.push('no commits yet; nothing to tag');
|
|
686
|
-
} else {
|
|
687
|
-
const hasTag = await git(root, ['rev-parse', '--verify', `refs/tags/${BACKUP_TAG}`]);
|
|
688
|
-
if (hasTag.ok) {
|
|
689
|
-
result.tag = BACKUP_TAG;
|
|
690
|
-
} else {
|
|
691
|
-
const tag = await git(root, ['tag', BACKUP_TAG]);
|
|
692
|
-
if (tag.ok) result.tag = BACKUP_TAG;
|
|
693
|
-
else result.notes.push(`could not create tag ${BACKUP_TAG}: ${tag.stderr.trim()}`);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
698
|
-
if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
|
|
699
|
-
// Recorded rather than re-read by the caller: only this function knows what the
|
|
700
|
-
// branch was before it moved it, and a second `rev-parse` would answer with the new
|
|
701
|
-
// value and make every "you were on X" line read as "you were on Y".
|
|
702
|
-
result.from = current.stdout.trim();
|
|
703
|
-
const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
|
|
704
|
-
if (branch.ok) result.branch = ADOPT_BRANCH;
|
|
705
|
-
else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
|
|
706
|
-
} else if (current.ok) {
|
|
707
|
-
result.branch = ADOPT_BRANCH;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
return result;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
async function git(cwd, args) {
|
|
714
|
-
try {
|
|
715
|
-
const { stdout, stderr } = await run('git', args, { cwd });
|
|
716
|
-
return { ok: true, stdout, stderr };
|
|
717
|
-
} catch (error) {
|
|
718
|
-
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
721
|
/** Every line is a decision with a reason. Nothing is silent. */
|
|
723
722
|
function render(payload, manifest, { npm = true, manager = null, version } = {}) {
|
|
724
723
|
const lines = [
|
|
@@ -829,6 +828,26 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
|
|
|
829
828
|
lines.push(` ${a.action.padEnd(9)} ${a.path} agent (ADR-111)`);
|
|
830
829
|
}
|
|
831
830
|
|
|
831
|
+
// The agent-hooks entry, absorbed into the run so the adoption enforces its own
|
|
832
|
+
// projection while the agent works. Printed with the same shape the standalone command
|
|
833
|
+
// uses: one line per config the tool writes, the shim, and the readers it narrows past.
|
|
834
|
+
// `--no-agents` suppresses the section and says so, because a line that appears after
|
|
835
|
+
// the run without being in the plan is the same silent write as any other.
|
|
836
|
+
if (payload.agentHooks === null) {
|
|
837
|
+
lines.push(` ${'skip'.padEnd(9)} agent-hooks opted out with --no-agents; the agent files are skipped with it`);
|
|
838
|
+
} else {
|
|
839
|
+
for (const p of payload.agentHooks.plan) {
|
|
840
|
+
lines.push(` ${p.action.padEnd(9)} ${p.path} agent-hooks${p.error ? ` (${p.error})` : ''}`);
|
|
841
|
+
}
|
|
842
|
+
const shim = payload.agentHooks.shim;
|
|
843
|
+
lines.push(` ${shim.action.padEnd(9)} ${shim.path} agent-hooks shim`);
|
|
844
|
+
for (const o of payload.agentHooks.notWritten ?? []) {
|
|
845
|
+
if (o.state === 'skipped') {
|
|
846
|
+
lines.push(` ${'skip'.padEnd(9)} ${o.id} ${o.note}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
832
851
|
// The CI gate is listed as its own kind because it is the one plan entry whose
|
|
833
852
|
// absence is a decision rather than a default. Skipped states its reason inline so
|
|
834
853
|
// a reader can tell "this repo does not use GitHub Actions" from "this repo was
|
|
@@ -862,7 +881,7 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
|
|
|
862
881
|
// produced by two hand-written renderers.
|
|
863
882
|
const db = payload.dependabot;
|
|
864
883
|
if (db && db.action === 'create') {
|
|
865
|
-
lines.push(` ${'create'.padEnd(9)} ${db.file} ${renderDependabot(db.facts, { stack: db.stack, own: db.own }).length} bytes`);
|
|
884
|
+
lines.push(` ${'create'.padEnd(9)} ${db.file} ${renderDependabot(db.facts, { stack: db.stack, own: db.own, root: db.root }).length} bytes`);
|
|
866
885
|
lines.push(
|
|
867
886
|
db.own
|
|
868
887
|
? ` watches ${db.ecosystems.join(', ')}; ${CLI} ci --yes refreshes this copy when the template changes`
|
|
@@ -911,4 +930,4 @@ function render(payload, manifest, { npm = true, manager = null, version } = {})
|
|
|
911
930
|
}
|
|
912
931
|
if (summary.total === 0) lines.push('', 'CONFLICTS none detected');
|
|
913
932
|
return lines.filter((l) => l !== undefined).join('\n');
|
|
914
|
-
}
|
|
933
|
+
}
|
package/src/commands/lease.mjs
CHANGED
|
@@ -20,18 +20,22 @@ import {
|
|
|
20
20
|
DEFAULT_TTL_SECONDS,
|
|
21
21
|
activeLeases,
|
|
22
22
|
canonicalLeasePath,
|
|
23
|
+
flattenLease,
|
|
24
|
+
leaseCovers,
|
|
23
25
|
readLeases,
|
|
24
26
|
withLeaseLock,
|
|
25
27
|
writeLeases,
|
|
26
28
|
} from '../lease.mjs';
|
|
27
29
|
import { coreIgnoreCase } from '../repo-root.mjs';
|
|
30
|
+
import { noteEvent } from '../state/events.mjs';
|
|
28
31
|
import { SESSION_ENV } from '../agenthooks/policy.mjs';
|
|
29
32
|
import { CLI } from '../name.mjs';
|
|
33
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
30
34
|
|
|
31
35
|
const ISO = (ms) => new Date(ms).toISOString();
|
|
32
36
|
|
|
33
37
|
export async function leaseCommand(args, flags) {
|
|
34
|
-
const root = flags
|
|
38
|
+
const root = commandRoot(flags);
|
|
35
39
|
const [sub, ...rest] = args;
|
|
36
40
|
// The denial tells a blocked session to ask the holder to release the path,
|
|
37
41
|
// so releasing is a subcommand, not a missing flag (review 0003 Q20).
|
|
@@ -59,6 +63,8 @@ export async function leaseCommand(args, flags) {
|
|
|
59
63
|
|
|
60
64
|
const wanted = [];
|
|
61
65
|
const seen = new Set();
|
|
66
|
+
// A trailing slash asks for the directory (Q74): one claim covering every path under it.
|
|
67
|
+
const kinds = new Map();
|
|
62
68
|
// The claim is stored in the one spelling the hook's comparison will produce:
|
|
63
69
|
// a claim taken as `src/Keep.mjs` and an edit that spells `src/keep.mjs` must
|
|
64
70
|
// be the same claim on a case-insensitive volume, so the stored path is
|
|
@@ -72,19 +78,36 @@ export async function leaseCommand(args, flags) {
|
|
|
72
78
|
if (!seen.has(rel)) {
|
|
73
79
|
seen.add(rel);
|
|
74
80
|
wanted.push(rel);
|
|
81
|
+
kinds.set(rel, String(arg).endsWith('/') ? 'dir' : 'path');
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
// Older claim files hold the pre-canonical spelling, so a stored path is
|
|
79
86
|
// canonicalised before it is compared: a claim written before the resolver
|
|
80
87
|
// existed must still conflict with a claim taken for the same file.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
// A claim and a target overlap when either covers the other: claiming `src/` while
|
|
89
|
+
// `src/a.mjs` is held is the conflict the directory claim exists to catch (Q74).
|
|
90
|
+
const overlaps = (l) =>
|
|
91
|
+
wanted.some(
|
|
92
|
+
(rel) =>
|
|
93
|
+
leaseCovers(l, rel) ||
|
|
94
|
+
(kinds.get(rel) === 'dir' && (l.path === rel || l.path.startsWith(`${rel}/`))),
|
|
95
|
+
);
|
|
96
|
+
const newClaim = (expiresAt, held) =>
|
|
97
|
+
wanted.map((path) => {
|
|
98
|
+
const fences = held.filter((l) => leaseCovers(l, path)).map((l) => Number(l.fence) || 0);
|
|
99
|
+
return flattenLease({
|
|
100
|
+
resource: { kind: kinds.get(path) ?? 'path', id: path },
|
|
101
|
+
holder: { session, harness },
|
|
102
|
+
fence: 1 + (fences.length === 0 ? 0 : Math.max(...fences)),
|
|
103
|
+
acquiredAt: Date.now(),
|
|
104
|
+
expiresAt,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
84
107
|
|
|
85
108
|
const mode = writeMode(flags);
|
|
86
109
|
if (!mode.apply) {
|
|
87
|
-
const mine = newClaim(Date.now() + ttl * 1000);
|
|
110
|
+
const mine = newClaim(Date.now() + ttl * 1000, []);
|
|
88
111
|
const apply = `run: ${CLI} lease ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`;
|
|
89
112
|
if (isJson(flags)) {
|
|
90
113
|
emitJson({ ok: true, command: 'lease', root, dryRun: true, session, ttl, leases: mine, remedy: apply });
|
|
@@ -97,7 +120,7 @@ export async function leaseCommand(args, flags) {
|
|
|
97
120
|
...mine.map((l) => `would lease ${l.path} until ${ISO(l.expiresAt)}`),
|
|
98
121
|
`apply ${apply}`,
|
|
99
122
|
];
|
|
100
|
-
emit(lines.join('\n')
|
|
123
|
+
emit(lines.join('\n'));
|
|
101
124
|
return EXIT.OK;
|
|
102
125
|
}
|
|
103
126
|
|
|
@@ -108,12 +131,12 @@ export async function leaseCommand(args, flags) {
|
|
|
108
131
|
const read = await readLeases(root);
|
|
109
132
|
if (read.error !== null) return { error: read.error, path: read.path };
|
|
110
133
|
const held = activeLeases(read);
|
|
111
|
-
const conflicts = held.filter((l) =>
|
|
134
|
+
const conflicts = held.filter((l) => overlaps(l) && l.session !== session);
|
|
112
135
|
if (conflicts.length > 0) return { conflicts };
|
|
113
136
|
// Keep every other live claim, refresh ours, and let an expired one fall out:
|
|
114
137
|
// the write is also the only sweep the file ever gets.
|
|
115
|
-
const others = held.filter((l) => !
|
|
116
|
-
const mine = newClaim(Date.now() + ttl * 1000);
|
|
138
|
+
const others = held.filter((l) => !overlaps(l));
|
|
139
|
+
const mine = newClaim(Date.now() + ttl * 1000, held);
|
|
117
140
|
const path = await writeLeases(root, [...others, ...mine]);
|
|
118
141
|
return { mine, path };
|
|
119
142
|
});
|
|
@@ -128,6 +151,15 @@ export async function leaseCommand(args, flags) {
|
|
|
128
151
|
);
|
|
129
152
|
}
|
|
130
153
|
const { mine, path } = claimed;
|
|
154
|
+
// Coordination state a hook acts on belongs in the log a UI can tail, not only in a
|
|
155
|
+
// file a person has to open (Q73).
|
|
156
|
+
await noteEvent(root, {
|
|
157
|
+
kind: 'lease.acquired',
|
|
158
|
+
session,
|
|
159
|
+
harness: harness ?? null,
|
|
160
|
+
subject: mine.map((l) => l.path).join(','),
|
|
161
|
+
data: { ttl, fence: mine[0]?.fence ?? null },
|
|
162
|
+
});
|
|
131
163
|
|
|
132
164
|
if (isJson(flags)) {
|
|
133
165
|
emitJson({ ok: true, command: 'lease', root, session, ttl, dryRun: false, leases: mine, file: path });
|
|
@@ -139,7 +171,7 @@ export async function leaseCommand(args, flags) {
|
|
|
139
171
|
`ttl ${ttl}s`,
|
|
140
172
|
...mine.map((l) => `LEASED ${l.path} until ${ISO(l.expiresAt)}`),
|
|
141
173
|
];
|
|
142
|
-
emit(lines.join('\n')
|
|
174
|
+
emit(lines.join('\n'));
|
|
143
175
|
return EXIT.OK;
|
|
144
176
|
}
|
|
145
177
|
|
|
@@ -151,6 +183,7 @@ function resolveTargets(root, args, session, verb) {
|
|
|
151
183
|
const ignoreCase = coreIgnoreCase(root);
|
|
152
184
|
const wanted = [];
|
|
153
185
|
const seen = new Set();
|
|
186
|
+
const kinds = new Map();
|
|
154
187
|
for (const arg of args) {
|
|
155
188
|
const rel = canonicalLeasePath(root, arg, { ignoreCase });
|
|
156
189
|
if (rel === null) {
|
|
@@ -159,9 +192,10 @@ function resolveTargets(root, args, session, verb) {
|
|
|
159
192
|
if (!seen.has(rel)) {
|
|
160
193
|
seen.add(rel);
|
|
161
194
|
wanted.push(rel);
|
|
195
|
+
kinds.set(rel, String(arg).endsWith('/') ? 'dir' : 'path');
|
|
162
196
|
}
|
|
163
197
|
}
|
|
164
|
-
return { wanted, seen, ignoreCase };
|
|
198
|
+
return { wanted, seen, ignoreCase, kinds };
|
|
165
199
|
}
|
|
166
200
|
|
|
167
201
|
function claimSession(flags) {
|
|
@@ -177,23 +211,28 @@ async function releaseLeases(root, args, flags) {
|
|
|
177
211
|
if (sessionError !== undefined) return sessionError;
|
|
178
212
|
const resolved = resolveTargets(root, args, session, 'release');
|
|
179
213
|
if (resolved.error !== undefined) return resolved.error;
|
|
180
|
-
const { wanted, seen, ignoreCase } = resolved;
|
|
181
|
-
const
|
|
214
|
+
const { wanted, seen, ignoreCase, kinds } = resolved;
|
|
215
|
+
const overlaps = (l) =>
|
|
216
|
+
wanted.some(
|
|
217
|
+
(rel) =>
|
|
218
|
+
leaseCovers(l, rel) ||
|
|
219
|
+
(kinds.get(rel) === 'dir' && (l.path === rel || l.path.startsWith(`${rel}/`))),
|
|
220
|
+
);
|
|
182
221
|
const mode = writeMode(flags);
|
|
183
222
|
|
|
184
223
|
const released = await withLeaseLock(root, async () => {
|
|
185
224
|
const read = await readLeases(root);
|
|
186
225
|
if (read.error !== null) return { error: read.error, path: read.path };
|
|
187
226
|
const held = activeLeases(read);
|
|
188
|
-
const foreign = held.filter((l) =>
|
|
227
|
+
const foreign = held.filter((l) => overlaps(l) && l.session !== session);
|
|
189
228
|
if (foreign.length > 0) {
|
|
190
229
|
return {
|
|
191
230
|
conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
|
|
192
231
|
};
|
|
193
232
|
}
|
|
194
|
-
const dropped = held.filter((l) =>
|
|
233
|
+
const dropped = held.filter((l) => overlaps(l));
|
|
195
234
|
if (!mode.apply) return { preview: dropped };
|
|
196
|
-
const kept = held.filter((l) => !
|
|
235
|
+
const kept = held.filter((l) => !overlaps(l));
|
|
197
236
|
const path = await writeLeases(root, kept);
|
|
198
237
|
return { dropped, path };
|
|
199
238
|
});
|
|
@@ -212,9 +251,15 @@ async function releaseLeases(root, args, flags) {
|
|
|
212
251
|
`apply run: ${CLI} lease release ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`,
|
|
213
252
|
];
|
|
214
253
|
if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: true, session, leases: released.preview });
|
|
215
|
-
else emit(lines.join('\n')
|
|
254
|
+
else emit(lines.join('\n'));
|
|
216
255
|
return EXIT.OK;
|
|
217
256
|
}
|
|
257
|
+
await noteEvent(root, {
|
|
258
|
+
kind: 'lease.released',
|
|
259
|
+
session,
|
|
260
|
+
subject: released.dropped.map((l) => l.path).join(','),
|
|
261
|
+
data: { released: released.dropped.length },
|
|
262
|
+
});
|
|
218
263
|
for (const l of released.dropped) note(`note released ${l.path} (was held by ${l.session})`);
|
|
219
264
|
if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: false, session, leases: released.dropped });
|
|
220
265
|
else {
|
|
@@ -225,7 +270,6 @@ async function releaseLeases(root, args, flags) {
|
|
|
225
270
|
...released.dropped.map((l) => `RELEASED ${l.path}`),
|
|
226
271
|
...(released.dropped.length === 0 ? ['(no active claims on those paths)'] : []),
|
|
227
272
|
].join('\n'),
|
|
228
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
229
273
|
);
|
|
230
274
|
}
|
|
231
275
|
return EXIT.OK;
|
|
@@ -236,27 +280,32 @@ async function renewLeases(root, args, flags) {
|
|
|
236
280
|
if (sessionError !== undefined) return sessionError;
|
|
237
281
|
const resolved = resolveTargets(root, args, session, 'renew');
|
|
238
282
|
if (resolved.error !== undefined) return resolved.error;
|
|
239
|
-
const { wanted, seen, ignoreCase } = resolved;
|
|
283
|
+
const { wanted, seen, ignoreCase, kinds } = resolved;
|
|
240
284
|
const ttl = flags.ttl === undefined ? DEFAULT_TTL_SECONDS : Number(flags.ttl);
|
|
241
285
|
if (!Number.isInteger(ttl) || ttl < 1) {
|
|
242
286
|
return fail(`--ttl must be a positive number of seconds, received ${flags.ttl}`, EXIT.USAGE, `example: run: ${CLI} lease renew <paths> --ttl 900`);
|
|
243
287
|
}
|
|
244
|
-
const
|
|
288
|
+
const overlaps = (l) =>
|
|
289
|
+
wanted.some(
|
|
290
|
+
(rel) =>
|
|
291
|
+
leaseCovers(l, rel) ||
|
|
292
|
+
(kinds.get(rel) === 'dir' && (l.path === rel || l.path.startsWith(`${rel}/`))),
|
|
293
|
+
);
|
|
245
294
|
const mode = writeMode(flags);
|
|
246
295
|
|
|
247
296
|
const renewed = await withLeaseLock(root, async () => {
|
|
248
297
|
const read = await readLeases(root);
|
|
249
298
|
if (read.error !== null) return { error: read.error, path: read.path };
|
|
250
299
|
const held = activeLeases(read);
|
|
251
|
-
const foreign = held.filter((l) =>
|
|
300
|
+
const foreign = held.filter((l) => overlaps(l) && l.session !== session);
|
|
252
301
|
if (foreign.length > 0) {
|
|
253
302
|
return {
|
|
254
303
|
conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
|
|
255
304
|
};
|
|
256
305
|
}
|
|
257
306
|
const expiresAt = Date.now() + ttl * 1000;
|
|
258
|
-
const mine = held.map((l) => (
|
|
259
|
-
const touched = held.filter((l) =>
|
|
307
|
+
const mine = held.map((l) => (overlaps(l) ? { ...l, expiresAt } : l));
|
|
308
|
+
const touched = held.filter((l) => overlaps(l));
|
|
260
309
|
if (!mode.apply) return { preview: touched.map((l) => ({ ...l, expiresAt })) };
|
|
261
310
|
const path = await writeLeases(root, mine);
|
|
262
311
|
return { touched: touched.map((l) => ({ ...l, expiresAt })), path };
|
|
@@ -278,7 +327,6 @@ async function renewLeases(root, args, flags) {
|
|
|
278
327
|
...rows.map((l) => `${mode.apply ? 'RENEWED ' : 'would renew '} ${l.path} until ${ISO(l.expiresAt)}`),
|
|
279
328
|
...(rows.length === 0 ? ['(no active claims on those paths)'] : []),
|
|
280
329
|
].join('\n'),
|
|
281
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
282
330
|
);
|
|
283
331
|
}
|
|
284
332
|
return EXIT.OK;
|
|
@@ -299,6 +347,6 @@ async function listLeases(root, flags) {
|
|
|
299
347
|
...held.map((l) => `LEASED ${l.path} ${l.session}${l.harness ? `/${l.harness}` : ''} until ${ISO(l.expiresAt)}`),
|
|
300
348
|
];
|
|
301
349
|
if (held.length === 0) lines.push(`(no active leases)`);
|
|
302
|
-
emit(lines.join('\n')
|
|
350
|
+
emit(lines.join('\n'));
|
|
303
351
|
return EXIT.OK;
|
|
304
352
|
}
|