@ngockhoale/ukit 3.4.67 → 3.4.68
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 +12 -0
- package/manifests/documentation.yaml +33 -0
- package/manifests/instructionRules.yaml +10 -0
- package/manifests/platform.full.yaml +67 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +111 -2
- package/src/core/buildPlan.js +3 -1
- package/src/core/diffPlan.js +49 -1
- package/src/core/executionContracts.js +49 -8
- package/src/core/metadata.js +45 -0
- package/src/core/runInstallPipeline.js +30 -2
- package/src/core/uninstall.js +106 -2
- package/src/manifest/validateManifest.js +25 -1
- package/template_project/.claude/agents/code-reviewer.md +22 -9
- package/template_project/.claude/agents/handoff-planner.md +97 -8
- package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
- package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
- package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
- package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
- package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
- package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
- package/template_project/.claude/hooks/handoff-resume.sh +141 -1
- package/template_project/.claude/hooks/record-execution.mjs +140 -9
- package/template_project/.claude/hooks/skill-router.sh +173 -8
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
- package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
- package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
- package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
- package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
- package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
- package/template_project/.claude/ukit/index/route-task.mjs +180 -11
- package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
- package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
- package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
- package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
- package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
- package/template_project/.codex/README.md +25 -0
- package/template_project/.omp/RULES.md +22 -22
- package/template_project/.omp/agents/code-reviewer.md +22 -9
- package/template_project/.omp/agents/handoff-planner.md +102 -12
- package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
- package/template_project/AGENTS.md +5 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
- package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
- package/template_project/instructions/core.md +5 -1
- package/template_project/instructions/layout.yaml +2 -0
- package/template_project/instructions/overlays/omp-rules.md +22 -22
- package/template_project/ukit/storage/config.json +1 -1
|
@@ -22,6 +22,8 @@ Dispositions (exactly one per row):
|
|
|
22
22
|
|
|
23
23
|
Prohibitions are confirmed exclusions: a source "must not" / "never" rule is recorded as a confirmed exclusion with its rationale. If it is enforceable in scope, it is a `feasible` or `adapted` row whose acceptance includes a referenced negative test (a test that proves the forbidden behaviour is absent). No new taxonomy is needed for this.
|
|
24
24
|
|
|
25
|
+
`deferred` is never a catch-all: material the source itself marks as proposal, example, or rejected (and explicit non-goals) is recorded as `NOTES.md` entries with that kind (`proposal`/`example`/`rejected` — see Source-only notes), never squeezed into `deferred`. `deferred` means intentionally-later work and always names a queue entry; forcing non-work into it queues backlog that no cycle should ever drain.
|
|
26
|
+
|
|
25
27
|
The `10` limit is a dispatch and context window, not a classification. Queued drafts carry no runnable rows; their INDEX rows are `queued_blueprint`.
|
|
26
28
|
|
|
27
29
|
Footer (required, last lines of the file):
|
|
@@ -86,6 +88,14 @@ A blueprint without the line keeps the legacy fold-in behaviour. The contract is
|
|
|
86
88
|
|
|
87
89
|
Before chunking, persist the original verbatim: `${OUTPUT_ROOT}SOURCE.md` holds the original path, the `sha256` of the original file's bytes (not of this wrapper or any metadata header), and an unmodified copy of those bytes. For chat or pasted input with no file, record the durable verbatim message origin (message id or transcript path plus the exact text) in place of the path. Gatherers and planners cite `SOURCE.md` lines; no model paraphrase replaces it. The reviewer starts its chain from `SOURCE.md` and re-hashes the original bytes.
|
|
88
90
|
|
|
91
|
+
Write it with the deterministic helper as ONE simple command, literal paths, no `cd`, no compound — never a hand-built wrapper:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
node .claude/ukit/index/source-snapshot.mjs <original-path> docs/AI_HANDOFF/SOURCE.md
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
(under a queued root, pass that root's SOURCE.md path). It records the origin path, the sha256 and a byte-identical copy, refuses to overwrite an existing snapshot, and `--check <snapshot> [original]` re-hashes the copy for the reviewer. If it cannot run (denial, budget), `PLAN.md` and `LEDGER.md` must state `source snapshot MISSING` explicitly — never cite only a mutable path as the source.
|
|
98
|
+
|
|
89
99
|
## Source-only notes
|
|
90
100
|
|
|
91
101
|
File: `${OUTPUT_ROOT}NOTES.md` (the semantic record is mandatory: every classification, exclusion, and rationale is recorded durably, and this file is the default home for the non-task items; an equivalent name is acceptable, and the file is written only when such items exist). Non-requirement material that a ledger row cannot carry. Each entry names its source (`doc §… L…`, or the human's words) and one kind:
|
|
@@ -28,7 +28,7 @@ This skill is **planning-only**. A create session that loads it edits docs and t
|
|
|
28
28
|
### Phase A — Gather (lite tier)
|
|
29
29
|
|
|
30
30
|
1. Read the document header and table of contents; unreadable or empty source → write ledger row `R-000 | blocker` naming the missing input, create no tasks, stop.
|
|
31
|
-
1a. **Source snapshot** — before chunking, write `${OUTPUT_ROOT}SOURCE.md` with the original path (or the durable verbatim message origin for chat input), the `sha256` of the original bytes, and a verbatim copy of the full source text (see [REFERENCE.md](./REFERENCE.md#source-snapshot)). Chunks and citations point into this copy, never into a paraphrase.
|
|
31
|
+
1a. **Source snapshot** — before chunking, write `${OUTPUT_ROOT}SOURCE.md` with the original path (or the durable verbatim message origin for chat input), the `sha256` of the original bytes, and a verbatim copy of the full source text (see [REFERENCE.md](./REFERENCE.md#source-snapshot)). Use the helper, ONE simple command, literal paths: `node .claude/ukit/index/source-snapshot.mjs <original-path> docs/AI_HANDOFF/SOURCE.md`; if it cannot run (denial, budget), state `source snapshot MISSING` explicitly in PLAN/LEDGER. Chunks and citations point into this copy, never into a paraphrase.
|
|
32
32
|
2. Split into chunks of at most 800 lines; one bounded gatherer per chunk extracts candidate requirements as `Source` (section/line) + normalized text. Dedupe by normalized text, keep the first source; contradictory requirements become a `blocker` row.
|
|
33
33
|
3. **Feasibility evidence** — per requirement, inspect the CURRENT source index-first (`query-index.mjs` / `resolve-context.mjs`, then read the code that would change) plus `git log`, `git diff` and `git status` of the touched areas. Record each finding in the ledger row as evidence (file:line or commit) using the block in [REFERENCE.md](./REFERENCE.md#feasibility-evidence). Gatherers collect; the smart tier decides.
|
|
34
34
|
4. **Related-project research (default on, bounded)** — per requirement cluster search the internet / GitHub for related projects and prior art (how others solved it, what to adopt or avoid) via `.claude/skills/research/SKILL.md`, under the sanitized-query and untrusted-source rules in [REFERENCE.md](./REFERENCE.md#research-rules). Feed the findings into each row's feasibility evidence. Skip only when no sanitized query is possible or no research tool exists, and record the skip reason in the ledger notes.
|
|
@@ -37,7 +37,7 @@ Tier (lite gatherers): on Claude Code start a read-only `general-purpose` subage
|
|
|
37
37
|
|
|
38
38
|
### Phase B — Decide, ledger, plan (smart tier)
|
|
39
39
|
|
|
40
|
-
1. Use the evidence to assign every extracted requirement exactly one disposition: `feasible`, `adapted`, `deferred`, or `blocker`. `adapted` lists 2-3 alternatives with the chosen one marked; `deferred` names its queue entry and a justification; `blocker` names the missing prerequisite or owner conflict.
|
|
40
|
+
1. Use the evidence to assign every extracted requirement exactly one disposition: `feasible`, `adapted`, `deferred`, or `blocker`. `adapted` lists 2-3 alternatives with the chosen one marked; `deferred` (intentionally later) names its queue entry and a justification; `blocker` names the missing prerequisite or owner conflict. Source-marked proposal/example/rejected items are `NOTES.md` entries with that kind, never `deferred`.
|
|
41
41
|
2. Only `feasible`/`adapted` rows map to executable tasks, and each maps to an executable acceptance criterion and a test or verification command. `deferred` rows need a justification and a queue entry, not a task. `blocker` rows need the missing prerequisite or owner decision named, not a task. Rows the source marks as proposal, example, or rejected are not promoted to tasks: they stay as classified NOTES entries with their rationale.
|
|
42
42
|
3. Write `${OUTPUT_ROOT}LEDGER.md` with the footer `Coverage: N/N rows dispositioned` and the sentence "disposition coverage is not implementation coverage". Never claim "100% implemented".
|
|
43
43
|
4. **Candidate plans** — draft 2-3 candidate whole-plans (different decomposition, risk, ordering), score them on the rubric in [REFERENCE.md](./REFERENCE.md#candidate-plan-rubric), pick the best, and record the choice plus rejected alternatives in `PLAN.md`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { allocateTaskWorktree } from '../runtime/worktree-allocation.mjs';
|
|
5
|
+
|
|
6
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
|
|
7
|
+
const [root, taskId, ...extra] = process.argv.slice(2);
|
|
8
|
+
if (!root || !taskId || extra.length) {
|
|
9
|
+
console.error('Usage: node allocate-task-worktree.mjs <project-root> <TASK-id>');
|
|
10
|
+
process.exitCode = 2;
|
|
11
|
+
} else {
|
|
12
|
+
try { console.log(JSON.stringify(await allocateTaskWorktree(root, taskId), null, 2)); }
|
|
13
|
+
catch (error) { console.error(`Allocation refused: ${error.message}`); process.exitCode = 2; }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// amend-guard.mjs — CR-10: overwriting PLAN.md / SPEC.md / LEDGER.md (or any planned file)
|
|
3
|
+
// requires a preserved copy under `amendments/rev-<NN>/<same relative path>` first.
|
|
4
|
+
//
|
|
5
|
+
// A valid copy must be: a regular, non-empty file at the SAME relative subpath as the file
|
|
6
|
+
// being overwritten (queued/foo/PLAN.md needs amendments/rev-NN/queued/foo/PLAN.md, never a
|
|
7
|
+
// root copy), byte-identical to the current file (the copy is the prior version, taken
|
|
8
|
+
// right before the rewrite), and not a symlink. A brand-new target (absent on disk) needs
|
|
9
|
+
// no copy, but a queued/<slug> directory that already exists is a collision and needs one.
|
|
10
|
+
//
|
|
11
|
+
// Mechanical scope: this checker verifies the COPY exists before a rewrite. It is invoked
|
|
12
|
+
// by the create/planner instructions before composing a revised plan (prose enforcement —
|
|
13
|
+
// hosts have no Write-time hook for docs paths).
|
|
14
|
+
//
|
|
15
|
+
// Usage:
|
|
16
|
+
// node .claude/ukit/index/amend-guard.mjs <handoff-root> <FILE.md> [FILE.md ...]
|
|
17
|
+
//
|
|
18
|
+
// Output:
|
|
19
|
+
// `AMEND_GUARD: ok` when every named file is either brand-new or has a valid copy;
|
|
20
|
+
// otherwise one `overwrite blocked: <FILE> has no amendments/rev-<NN> copy — copy the prior
|
|
21
|
+
// version to the next unused rev dir first` line per blocked file. Exit 0 = safe, 1 = blocked, 2 = usage.
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
function isDirectRun() {
|
|
29
|
+
try {
|
|
30
|
+
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const GUARDED = new Set(['PLAN.md', 'SPEC.md', 'LEDGER.md', 'INDEX.md', 'NOTES.md', 'SOURCE.md']);
|
|
37
|
+
|
|
38
|
+
function lstatSafe(file) {
|
|
39
|
+
try {
|
|
40
|
+
return fs.lstatSync(file);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A valid preserved copy: same relative subpath under some rev-NN, regular file (no symlink),
|
|
47
|
+
// non-empty, and — when `currentBuf` is given — byte-identical to the file it protects.
|
|
48
|
+
// `currentBuf === null` accepts any valid copy (used for a queued/<slug> collision check).
|
|
49
|
+
function hasValidCopy(root, rel, currentBuf) {
|
|
50
|
+
const amendmentsDir = path.join(root, 'amendments');
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdirSync(amendmentsDir);
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return entries
|
|
58
|
+
.filter((e) => /^rev-\d+$/.test(e))
|
|
59
|
+
.some((rev) => {
|
|
60
|
+
const copy = path.join(amendmentsDir, rev, rel);
|
|
61
|
+
const st = lstatSafe(copy);
|
|
62
|
+
if (!st || !st.isFile() || st.size === 0) return false;
|
|
63
|
+
if (currentBuf === null) return true;
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(copy).equals(currentBuf);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function main() {
|
|
73
|
+
const root = process.argv[2];
|
|
74
|
+
const files = process.argv.slice(3);
|
|
75
|
+
if (!root || files.length === 0) {
|
|
76
|
+
console.log('AMEND_GUARD: usage error — expected `<root> <FILE.md> [FILE.md ...]`');
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
const blocked = [];
|
|
80
|
+
for (const rel of files) {
|
|
81
|
+
const base = path.basename(rel);
|
|
82
|
+
if (!GUARDED.has(base)) {
|
|
83
|
+
console.log(`AMEND_GUARD: usage error — ${base} is not a guarded planning file (${[...GUARDED].join(', ')})`);
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
const target = path.join(root, rel);
|
|
87
|
+
const st = lstatSafe(target);
|
|
88
|
+
if (!st) {
|
|
89
|
+
// Brand-new file: nothing is overwritten — unless its queued/<slug> unit already exists.
|
|
90
|
+
const unitDir = path.dirname(target);
|
|
91
|
+
const isQueuedUnit = path.basename(path.dirname(unitDir)) === 'queued';
|
|
92
|
+
const unitExists = isQueuedUnit && fs.existsSync(unitDir) && fs.readdirSync(unitDir).length > 0;
|
|
93
|
+
if (unitExists && !hasValidCopy(root, rel, null)) blocked.push(rel);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!st.isFile()) {
|
|
97
|
+
blocked.push(rel);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!hasValidCopy(root, rel, fs.readFileSync(target))) {
|
|
101
|
+
blocked.push(rel);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (blocked.length > 0) {
|
|
105
|
+
for (const rel of blocked) {
|
|
106
|
+
console.log(`overwrite blocked: ${rel} has no amendments/rev-<NN> copy — copy the prior version to the next unused rev dir first`);
|
|
107
|
+
}
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
console.log('AMEND_GUARD: ok');
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isDirectRun()) {
|
|
115
|
+
main();
|
|
116
|
+
}
|
|
@@ -161,7 +161,7 @@ const sha256File = (p) => crypto.createHash('sha256').update(fs.readFileSync(p))
|
|
|
161
161
|
const RECEIPT_KEYS = [
|
|
162
162
|
'schemaVersion', 'taskId', 'baseCommit', 'sourceRoot', 'destinationRoot',
|
|
163
163
|
'sourceBranch', 'sourceHead', 'destinationHeadBefore', 'sourceSnapshotTree',
|
|
164
|
-
'resultTree', 'patchSha256', 'scopeSha256', 'changedPaths', 'createdAt', 'status',
|
|
164
|
+
'resultTree', 'patchSha256', 'scopeSha256', 'changedPaths', 'createdAt', 'status', 'adoption',
|
|
165
165
|
];
|
|
166
166
|
const APPROVAL_KEYS = [
|
|
167
167
|
'schemaVersion', 'taskId', 'receiptSha256', 'verdict', 'executorModel',
|
|
@@ -46,6 +46,8 @@ import os from 'node:os';
|
|
|
46
46
|
import path from 'node:path';
|
|
47
47
|
import crypto from 'node:crypto';
|
|
48
48
|
import { spawnSync } from 'node:child_process';
|
|
49
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
50
|
+
import { reconcileTaskRecord } from '../runtime/task-record-reconciliation.mjs';
|
|
49
51
|
|
|
50
52
|
const GIT_TIMEOUT_MS = (() => {
|
|
51
53
|
const raw = Number(process.env.UKIT_LAND_GIT_TIMEOUT_MS);
|
|
@@ -57,12 +59,32 @@ const USAGE = `land-worktree.mjs — task-scoped base-relative worktree landing
|
|
|
57
59
|
|
|
58
60
|
Usage:
|
|
59
61
|
node land-worktree.mjs --scope <json-path> --receipt <new-json-path>
|
|
62
|
+
node land-worktree.mjs --adopt-applied --scope <json-path> --receipt <new-json-path>
|
|
63
|
+
node land-worktree.mjs --reconcile-metadata <proof-json> --scope <json-path> --receipt <new-json-path>
|
|
60
64
|
node land-worktree.mjs --help
|
|
61
65
|
|
|
62
66
|
Scope JSON (schemaVersion 1): taskId, baseCommit, sourceRoot, destinationRoot,
|
|
63
67
|
sourceHead, destinationHead, targetPaths[].
|
|
64
68
|
The receipt path must be a NEW file outside the source worktree and outside the
|
|
65
|
-
target set.
|
|
69
|
+
target set. Landing is refused (worker-not-terminated) while any delegated worker claim exists
|
|
70
|
+
for the task in the destination claims store (claims-unreadable if the store cannot be read); the
|
|
71
|
+
check and the landing mutation run under the same claims lock, so no claim transition can interleave.
|
|
72
|
+
|
|
73
|
+
--adopt-applied reconciles a landing whose bytes are ALREADY committed in the destination HEAD
|
|
74
|
+
without a receipt: nothing is applied; every delta path's blob+mode in the destination HEAD must equal
|
|
75
|
+
the source snapshot (else adopt-mismatch), and the receipt is explicitly marked adoption
|
|
76
|
+
"already-applied". Same worker-claim fence, scope and cleanliness checks as a normal landing.
|
|
77
|
+
|
|
78
|
+
--reconcile-metadata is a separate, no-apply history comparison. Proof schemaVersion:1 holds
|
|
79
|
+
records[{path,status?,additions[{text,evidence}]}] and evidence[{id,kind:"commit",commit,path,sha256}
|
|
80
|
+
or {id,kind:"file",path,sha256}]. Commit evidence must be retained destination history; file evidence
|
|
81
|
+
is a caller-pinned audit artifact, not independent semantic approval. Only this task record can have
|
|
82
|
+
evidenced blocked-status/append-only review or recovery progression; requirements/AC/dependencies
|
|
83
|
+
and existing verdict bytes remain exact. Audit provenance before supplying proof. Other delta paths
|
|
84
|
+
must be byte-identical. Receipt status is reconciled, NEVER landed, completion or cleanup authority.
|
|
85
|
+
Both modes retain the full scope; do not omit changed paths to make equality pass.
|
|
86
|
+
|
|
87
|
+
exit 0 landed/no-op/reconciled; exit 2 refusal/validation; exit 1 runtime error.`;
|
|
66
88
|
|
|
67
89
|
// --- bounded process helpers ------------------------------------------------
|
|
68
90
|
|
|
@@ -105,12 +127,14 @@ function emit(status, taskId, reason, changedPaths, receiptPath) {
|
|
|
105
127
|
function refuse(taskId, reason, diagnostic) {
|
|
106
128
|
if (diagnostic) process.stderr.write(`land-worktree: ${reason}: ${diagnostic}\n`);
|
|
107
129
|
emit('refused', taskId, reason, [], null);
|
|
130
|
+
if (inClaimsLock) throw new LandExit(2);
|
|
108
131
|
process.exit(2);
|
|
109
132
|
}
|
|
110
133
|
|
|
111
134
|
function runtimeError(taskId, reason, diagnostic) {
|
|
112
135
|
if (diagnostic) process.stderr.write(`land-worktree: ${reason}: ${diagnostic}\n`);
|
|
113
136
|
emit('error', taskId, reason, [], null);
|
|
137
|
+
if (inClaimsLock) throw new LandExit(1);
|
|
114
138
|
process.exit(1);
|
|
115
139
|
}
|
|
116
140
|
|
|
@@ -119,11 +143,21 @@ function runtimeError(taskId, reason, diagnostic) {
|
|
|
119
143
|
const argv = process.argv.slice(2);
|
|
120
144
|
let scopePath = null;
|
|
121
145
|
let receiptPath = null;
|
|
146
|
+
let adoptApplied = false;
|
|
147
|
+
let reconciliationPath = null;
|
|
148
|
+
let reconciliationPreflight = false;
|
|
122
149
|
for (let i = 0; i < argv.length; i += 1) {
|
|
123
150
|
const a = argv[i];
|
|
124
151
|
if (a === '--help' || a === '-h') {
|
|
125
152
|
process.stdout.write(`${USAGE}\n`);
|
|
126
153
|
process.exit(0);
|
|
154
|
+
} else if (a === '--preflight') {
|
|
155
|
+
reconciliationPreflight = true;
|
|
156
|
+
} else if (a === '--reconcile-metadata') {
|
|
157
|
+
reconciliationPath = argv[++i];
|
|
158
|
+
if (!reconciliationPath) { process.stderr.write('land-worktree: missing reconciliation proof\n'); process.exit(2); }
|
|
159
|
+
} else if (a === '--adopt-applied') {
|
|
160
|
+
adoptApplied = true;
|
|
127
161
|
} else if (a === '--scope') {
|
|
128
162
|
scopePath = argv[++i];
|
|
129
163
|
if (scopePath === undefined) { process.stderr.write('land-worktree: missing value for --scope\n'); process.exit(2); }
|
|
@@ -135,11 +169,45 @@ for (let i = 0; i < argv.length; i += 1) {
|
|
|
135
169
|
process.exit(2);
|
|
136
170
|
}
|
|
137
171
|
}
|
|
172
|
+
if (reconciliationPreflight && !reconciliationPath) {
|
|
173
|
+
process.stderr.write('land-worktree: --preflight requires --reconcile-metadata\n');
|
|
174
|
+
process.exit(2);
|
|
175
|
+
}
|
|
138
176
|
if (!scopePath || !receiptPath) {
|
|
139
177
|
process.stderr.write(`land-worktree: --scope and --receipt are both required\n${USAGE}\n`);
|
|
140
178
|
process.exit(2);
|
|
141
179
|
}
|
|
142
180
|
|
|
181
|
+
// Stale-worker landing authority. The trusted source is the hook-written claims store in the
|
|
182
|
+
// destination project (never caller-supplied flags): while ANY delegated worker claim for the task
|
|
183
|
+
// exists -- active, or expired (lease expiry is not proof the worker ended) -- landing is refused
|
|
184
|
+
// (worker-not-terminated). An unreadable store or unloadable runtime refuses too (fail closed).
|
|
185
|
+
// The check runs INSIDE the claims lock and the lock is held through apply+verify+receipt, so a
|
|
186
|
+
// claim transition (grant/takeover/release) cannot interleave with the landing mutation.
|
|
187
|
+
let inClaimsLock = false;
|
|
188
|
+
class LandExit extends Error { constructor(code) { super('land-exit'); this.exitCode = code; } }
|
|
189
|
+
const RUNTIME_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'runtime');
|
|
190
|
+
const LAND_LOCK_BUDGET_MS = 5000;
|
|
191
|
+
|
|
192
|
+
async function barrier(name) {
|
|
193
|
+
const dir = process.env.UKIT_LAND_BARRIER_DIR; // deterministic race-test hook; inert when unset
|
|
194
|
+
if (!dir) return;
|
|
195
|
+
try { fs.writeFileSync(path.join(dir, `${name}.reached`), ''); } catch { return; } // bad dir == unset
|
|
196
|
+
const go = path.join(dir, `${name}.go`);
|
|
197
|
+
const end = Date.now() + 30000;
|
|
198
|
+
while (!fs.existsSync(go) && Date.now() < end) await new Promise((r) => setTimeout(r, 10));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function workerAuthorityClear(taskId, root) {
|
|
202
|
+
try {
|
|
203
|
+
const mod = await import(pathToFileURL(path.join(RUNTIME_DIR, 'resumable-run.mjs')).href);
|
|
204
|
+
const r = await mod.readTaskClaim(root, taskId);
|
|
205
|
+
if (r.unreadable) return 'claims-unreadable';
|
|
206
|
+
if (r.claimed && String(r.claim.owner || '').startsWith('deleg:')) return 'worker-not-terminated';
|
|
207
|
+
return null;
|
|
208
|
+
} catch { return 'claims-unreadable'; }
|
|
209
|
+
}
|
|
210
|
+
|
|
143
211
|
// --- scope validation --------------------------------------------------------
|
|
144
212
|
|
|
145
213
|
const SCOPE_KEYS = [
|
|
@@ -221,7 +289,7 @@ const isInsideOrEqual = (child, parent) => {
|
|
|
221
289
|
|
|
222
290
|
// --- main --------------------------------------------------------------------
|
|
223
291
|
|
|
224
|
-
function main() {
|
|
292
|
+
async function main() {
|
|
225
293
|
const scope = readScope();
|
|
226
294
|
const { taskId, targetPaths } = scope;
|
|
227
295
|
const targets = new Set(targetPaths);
|
|
@@ -361,11 +429,142 @@ function main() {
|
|
|
361
429
|
const patchSha256 = crypto.createHash('sha256').update(patchBuf).digest('hex');
|
|
362
430
|
const scopeSha256 = crypto.createHash('sha256').update(fs.readFileSync(scopePath)).digest('hex');
|
|
363
431
|
|
|
432
|
+
// Everything from here to the receipt runs under the claims lock with the authority checked
|
|
433
|
+
// inside it (fence + mutation are one critical section; see workerAuthorityClear).
|
|
434
|
+
await barrier('pre-lock');
|
|
435
|
+
const lockMod = await import(pathToFileURL(path.join(RUNTIME_DIR, 'async-lock.mjs')).href).catch(() => null);
|
|
436
|
+
const claimsMod = await import(pathToFileURL(path.join(RUNTIME_DIR, 'resumable-run.mjs')).href).catch(() => null);
|
|
437
|
+
if (!lockMod || !claimsMod) refuse(taskId, 'claims-unreadable', 'claims runtime unavailable');
|
|
438
|
+
let exitCode = 0;
|
|
439
|
+
try {
|
|
440
|
+
fs.mkdirSync(path.dirname(claimsMod.taskClaimsPath(destinationRoot)), { recursive: true });
|
|
441
|
+
const outcome = await lockMod.withAsyncLock(claimsMod.taskClaimsPath(destinationRoot), { deadlineMs: LAND_LOCK_BUDGET_MS }, async () => {
|
|
442
|
+
inClaimsLock = true;
|
|
443
|
+
try {
|
|
444
|
+
const blocked = await workerAuthorityClear(taskId, destinationRoot);
|
|
445
|
+
if (blocked) refuse(taskId, blocked, 'a delegated worker claim exists for this task');
|
|
446
|
+
await barrier('in-lock');
|
|
447
|
+
landLocked();
|
|
448
|
+
} finally { inClaimsLock = false; }
|
|
449
|
+
});
|
|
450
|
+
if (!outcome.ok) refuse(taskId, 'claims-busy', 'could not take the claims lock');
|
|
451
|
+
} catch (e) {
|
|
452
|
+
if (!(e instanceof LandExit)) throw e;
|
|
453
|
+
exitCode = e.exitCode;
|
|
454
|
+
}
|
|
455
|
+
process.exit(exitCode);
|
|
456
|
+
|
|
457
|
+
function reconcileLocked() {
|
|
458
|
+
const stable = () => {
|
|
459
|
+
if (gitText(sourceRoot, ['rev-parse', 'HEAD']).stdout.trim() !== srcHead
|
|
460
|
+
|| gitText(destinationRoot, ['rev-parse', 'HEAD']).stdout.trim() !== dstHead) refuse(taskId, 'head-drift', 'HEAD moved during reconciliation');
|
|
461
|
+
checkDestinationClean(destinationRoot, targetPaths, taskId);
|
|
462
|
+
if (computeWorktreeTree(sourceRoot) !== snapshotTree) refuse(taskId, 'source-drift', 'source snapshot moved during reconciliation');
|
|
463
|
+
};
|
|
464
|
+
stable();
|
|
465
|
+
if (adoptApplied) refuse(taskId, 'invalid-proof', 'reconciliation and adoption are separate operations');
|
|
466
|
+
let proof;
|
|
467
|
+
try { proof = JSON.parse(fs.readFileSync(reconciliationPath, 'utf8')); } catch { refuse(taskId, 'invalid-proof', 'proof unreadable'); }
|
|
468
|
+
if (proof?.schemaVersion !== 1 || !Array.isArray(proof.records) || !Array.isArray(proof.evidence)) refuse(taskId, 'invalid-proof', 'invalid proof schema');
|
|
469
|
+
const evidence = new Map();
|
|
470
|
+
const evidenceRefs = [];
|
|
471
|
+
for (const e of proof.evidence) {
|
|
472
|
+
if (!e || typeof e.id !== 'string' || evidence.has(e.id) || !/^[a-f0-9]{64}$/.test(e.sha256 || '')) refuse(taskId, 'invalid-proof', 'invalid evidence identity');
|
|
473
|
+
let bytes;
|
|
474
|
+
if (e.kind === 'commit') {
|
|
475
|
+
if (!/^[a-f0-9]{40}$/.test(e.commit || '') || !targetPaths.includes(e.path)
|
|
476
|
+
|| gitText(destinationRoot, ['merge-base', '--is-ancestor', e.commit, dstHead]).status !== 0) refuse(taskId, 'invalid-proof', 'evidence commit is not retained destination history');
|
|
477
|
+
const r = gitBytes(destinationRoot, ['show', `${e.commit}:${e.path}`]);
|
|
478
|
+
if (r.status !== 0) refuse(taskId, 'invalid-proof', 'evidence blob unavailable');
|
|
479
|
+
bytes = r.stdout;
|
|
480
|
+
} else if (e.kind === 'file') {
|
|
481
|
+
// File evidence is explicitly scoped to the pinned proof's audit directory.
|
|
482
|
+
// This authenticates declared bytes, not the narrative's semantic provenance.
|
|
483
|
+
try {
|
|
484
|
+
const auditDir = path.dirname(fs.realpathSync(reconciliationPath));
|
|
485
|
+
const evidencePath = fs.realpathSync(e.path);
|
|
486
|
+
if (path.dirname(evidencePath) !== auditDir || !fs.statSync(evidencePath).isFile()) refuse(taskId, 'invalid-proof', 'file evidence must be colocated with the proof in its audit directory');
|
|
487
|
+
bytes = fs.readFileSync(evidencePath);
|
|
488
|
+
} catch (e) {
|
|
489
|
+
if (e instanceof LandExit) throw e;
|
|
490
|
+
refuse(taskId, 'invalid-proof', 'evidence file unavailable');
|
|
491
|
+
}
|
|
492
|
+
} else refuse(taskId, 'invalid-proof', 'unknown evidence kind');
|
|
493
|
+
if (crypto.createHash('sha256').update(bytes).digest('hex') !== e.sha256) refuse(taskId, 'invalid-proof', 'evidence hash mismatch');
|
|
494
|
+
evidence.set(e.id, bytes.toString('utf8'));
|
|
495
|
+
evidenceRefs.push(e);
|
|
496
|
+
}
|
|
497
|
+
const records = new Map();
|
|
498
|
+
for (const r of proof.records) {
|
|
499
|
+
if (r?.path !== `docs/AI_HANDOFF/tasks/${taskId}.md` || records.has(r.path)) refuse(taskId, 'invalid-proof', 'only this exact task record may have a history progression');
|
|
500
|
+
records.set(r.path, r);
|
|
501
|
+
}
|
|
502
|
+
// The scope must describe the WHOLE delta, never omit a mismatching record.
|
|
503
|
+
if (targetPaths.length !== deltaPaths.length || targetPaths.some((p) => !deltaPaths.includes(p))) refuse(taskId, 'scope-drift', 'reconciliation scope must equal the complete source delta');
|
|
504
|
+
const rows = [];
|
|
505
|
+
for (const p of deltaPaths) {
|
|
506
|
+
const entry = (root, tree) => {
|
|
507
|
+
const r = gitText(root, ['ls-tree', '-z', tree, '--', p]);
|
|
508
|
+
if (r.status !== 0) refuse(taskId, 'reconcile-unreadable', 'tree entry unavailable');
|
|
509
|
+
const m = r.stdout.match(/^(\d+) (\w+) ([a-f0-9]+)\t/);
|
|
510
|
+
return m ? { mode: m[1], type: m[2], blob: m[3] } : null;
|
|
511
|
+
};
|
|
512
|
+
const b = entry(sourceRoot, base), s = entry(sourceRoot, snapshotTree), d = entry(destinationRoot, dstHead);
|
|
513
|
+
const row = { path: p, base: b, source: s, destination: d, result: d, kind: 'byte-identical' };
|
|
514
|
+
if (JSON.stringify(s) !== JSON.stringify(d)) {
|
|
515
|
+
const r = records.get(p);
|
|
516
|
+
if (!r || !s || !d || s.mode !== '100644' || d.mode !== s.mode || s.type !== 'blob' || d.type !== 'blob') refuse(taskId, 'reconcile-mismatch', `unexplained mismatch at ${JSON.stringify(p)}`);
|
|
517
|
+
const text = (oid) => {
|
|
518
|
+
const result = gitBytes(sourceRoot, ['cat-file', 'blob', oid]);
|
|
519
|
+
if (result.status !== 0) refuse(taskId, 'reconcile-unreadable', 'blob unavailable');
|
|
520
|
+
const decoded = result.stdout.toString('utf8');
|
|
521
|
+
if (!Buffer.from(decoded).equals(result.stdout)) refuse(taskId, 'reconcile-unreadable', 'task record is not UTF-8');
|
|
522
|
+
return decoded;
|
|
523
|
+
};
|
|
524
|
+
try { row.progression = reconcileTaskRecord(text(s.blob), text(d.blob), r, evidence); }
|
|
525
|
+
catch (e) { refuse(taskId, 'reconcile-mismatch', e.message); }
|
|
526
|
+
row.kind = 'history-progression';
|
|
527
|
+
records.delete(p);
|
|
528
|
+
}
|
|
529
|
+
rows.push(row);
|
|
530
|
+
}
|
|
531
|
+
if (records.size) refuse(taskId, 'invalid-proof', 'unused metadata proof');
|
|
532
|
+
stable();
|
|
533
|
+
const receipt = { schemaVersion: 1, receiptKind: 'metadata-reconciliation', status: 'reconciled', taskId,
|
|
534
|
+
baseCommit: base, sourceHead: srcHead, destinationHead: dstHead, sourceSnapshotTree: snapshotTree,
|
|
535
|
+
sourceRoot, destinationRoot, scopeSha256, proofSha256: crypto.createHash('sha256').update(fs.readFileSync(reconciliationPath)).digest('hex'),
|
|
536
|
+
paths: rows, evidence: evidenceRefs, completion: false, cleanupEligible: false, createdAt: new Date().toISOString() };
|
|
537
|
+
if (reconciliationPreflight) {
|
|
538
|
+
process.stdout.write(`${JSON.stringify({ ...receipt, status: 'reconciliation-preflight', receiptPath: null })}\n`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
fs.mkdirSync(path.dirname(receiptAbs), { recursive: true });
|
|
543
|
+
fs.writeFileSync(receiptAbs, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx' });
|
|
544
|
+
} catch (e) { runtimeError(taskId, 'receipt-write-failed', e.code || e.message); }
|
|
545
|
+
emit('reconciled', taskId, 'history-preserved', deltaPaths, receiptAbs);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function landLocked() {
|
|
549
|
+
if (reconciliationPath) {
|
|
550
|
+
reconcileLocked();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
364
553
|
// A verified empty delta is a landed no-op: identity check only, no apply.
|
|
554
|
+
if (adoptApplied) {
|
|
555
|
+
for (const p of deltaPaths) {
|
|
556
|
+
if (treeEntry(sourceRoot, snapshotTree, p) !== treeEntry(destinationRoot, 'HEAD', p)) {
|
|
557
|
+
refuse(taskId, 'adopt-mismatch', `destination HEAD does not already hold the source snapshot at ${JSON.stringify(p)}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
writeReceipt(receiptAbs, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, computeWorktreeTree(destinationRoot), patchSha256, scopeSha256, deltaPaths);
|
|
561
|
+
emit('landed', taskId, 'adopted', deltaPaths, receiptAbs);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
365
564
|
if (deltaPaths.length === 0) {
|
|
366
565
|
writeReceipt(receiptAbs, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, snapshotTree, patchSha256, scopeSha256, deltaPaths);
|
|
367
566
|
emit('landed', taskId, 'no-op', deltaPaths, receiptAbs);
|
|
368
|
-
|
|
567
|
+
return;
|
|
369
568
|
}
|
|
370
569
|
|
|
371
570
|
const tmpPatch = path.join(os.tmpdir(), `ukit-land-${process.pid}-${crypto.randomBytes(6).toString('hex')}.patch`);
|
|
@@ -408,7 +607,7 @@ function main() {
|
|
|
408
607
|
|
|
409
608
|
writeReceipt(receiptAbs, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, dstTree, patchSha256, scopeSha256, deltaPaths);
|
|
410
609
|
emit('landed', taskId, 'ok', deltaPaths, receiptAbs);
|
|
411
|
-
|
|
610
|
+
}
|
|
412
611
|
}
|
|
413
612
|
|
|
414
613
|
// --- helpers -----------------------------------------------------------------
|
|
@@ -436,7 +635,9 @@ function computeWorktreeTree(root) {
|
|
|
436
635
|
const idx = path.join(os.tmpdir(), `ukit-land-${process.pid}-${crypto.randomBytes(6).toString('hex')}.index`);
|
|
437
636
|
try {
|
|
438
637
|
const env = { GIT_INDEX_FILE: idx };
|
|
439
|
-
|
|
638
|
+
// The claims lock this landing holds lives in the destination; it is never landed content. It is dropped from the temp index afterwards: an exclude pathspec makes `git add` fail when
|
|
639
|
+
// the destination .gitignore ignores that path.
|
|
640
|
+
for (const args of [['read-tree', 'HEAD'], ['add', '-A', '--', '.'], ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', '.ukit/storage/runs/claims.json.lock']]) {
|
|
440
641
|
const r = gitText(root, args, env);
|
|
441
642
|
if (r.status !== 0) runtimeError(null, 'verify-failed', r.stderr.split('\n')[0]);
|
|
442
643
|
}
|
|
@@ -483,6 +684,7 @@ function writeReceipt(absPath, taskId, base, sourceRoot, destinationRoot, srcHea
|
|
|
483
684
|
changedPaths: changedPaths.slice(),
|
|
484
685
|
createdAt: new Date().toISOString(),
|
|
485
686
|
status: 'landed',
|
|
687
|
+
...(adoptApplied ? { adoption: 'already-applied' } : {}),
|
|
486
688
|
};
|
|
487
689
|
try {
|
|
488
690
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
@@ -495,31 +697,51 @@ function writeReceipt(absPath, taskId, base, sourceRoot, destinationRoot, srcHea
|
|
|
495
697
|
}
|
|
496
698
|
|
|
497
699
|
function checkDestinationClean(destinationRoot, targetPaths, taskId) {
|
|
498
|
-
|
|
700
|
+
// --untracked-files=all enumerates every untracked file individually (no
|
|
701
|
+
// whole-directory collapse), so each tolerated entry is an exact path that
|
|
702
|
+
// verify's delta check can exclude precisely.
|
|
703
|
+
const st = gitText(destinationRoot, ['status', '--porcelain', '-z', '--untracked-files=all']);
|
|
499
704
|
if (st.status !== 0) refuse(taskId, 'destination-unreadable', 'could not read the destination status');
|
|
500
705
|
const entries = parsePorcelainZ(st.stdout);
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
const collides = (p) => protectedPaths.has(p) || [...protectedPaths].some((d) => p.startsWith(`${d}/`));
|
|
706
|
+
// Refuse a TRUE overlap with a target only: the target path itself, untracked
|
|
707
|
+
// content under a target path (a directory occupying the target's name), or
|
|
708
|
+
// an untracked file occupying a needed ancestor directory of a target. An
|
|
709
|
+
// entry that merely shares an ancestor directory with a target (e.g.
|
|
710
|
+
// "docs/AI_HANDOFF/RUN.md" beside "docs/AI_HANDOFF/tasks/TASK-001.md") is
|
|
711
|
+
// unrelated and tolerated.
|
|
712
|
+
const collides = (p) => targetPaths.some((t) => t === p || p.startsWith(`${t}/`) || t.startsWith(`${p}/`));
|
|
509
713
|
const tolerated = new Set();
|
|
714
|
+
const dirty = [];
|
|
510
715
|
|
|
511
716
|
for (const { xy, path: p } of entries) {
|
|
512
717
|
if (xy === '??') {
|
|
513
|
-
// Untracked: refuse only a target-path collision; unrelated files tolerated.
|
|
514
718
|
if (collides(p)) refuse(taskId, 'destination-collision', `untracked file collides with a target path: ${JSON.stringify(p)}`);
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
|
|
719
|
+
// An untracked nested repository / linked worktree (e.g. .worktrees/task-001) is
|
|
720
|
+
// reported by status as "dir/" but recorded by `git add -A` as the gitlink "dir";
|
|
721
|
+
// tolerate the gitlink spelling too so verify does not call it an unexpected landing.
|
|
722
|
+
tolerated.add(p);
|
|
723
|
+
if (p.length > 1 && p.endsWith('/')) tolerated.add(p.slice(0, -1));
|
|
518
724
|
} else {
|
|
519
|
-
// Any tracked modification or staged change anywhere refuses
|
|
520
|
-
|
|
725
|
+
// Any tracked modification or staged change anywhere refuses (FR-M4-002:
|
|
726
|
+
// "tracked-clean destination" — a patch applied over unrelated tracked
|
|
727
|
+
// edits would bake them into the verify snapshot and receipt resultTree).
|
|
728
|
+
dirty.push(p);
|
|
521
729
|
}
|
|
522
730
|
}
|
|
731
|
+
if (dirty.length > 0) {
|
|
732
|
+
// Actionable refusal: name every dirty path (bounded) and the supported
|
|
733
|
+
// recovery — the owner commits or stashes those paths, or the coordinator
|
|
734
|
+
// lands into a clean destination worktree after checkpointing landed paths.
|
|
735
|
+
const listed = dirty.slice(0, 8).map((p) => JSON.stringify(p)).join(', ');
|
|
736
|
+
const more = dirty.length > 8 ? ` (+${dirty.length - 8} more)` : '';
|
|
737
|
+
refuse(
|
|
738
|
+
taskId,
|
|
739
|
+
'destination-dirty',
|
|
740
|
+
`tracked modification(s) at ${listed}${more} — the destination must be tracked-clean`
|
|
741
|
+
+ ' (no uncommitted or staged tracked change anywhere). Recovery: the owner'
|
|
742
|
+
+ ' commits or stashes these paths, or land into a clean destination worktree.',
|
|
743
|
+
);
|
|
744
|
+
}
|
|
523
745
|
return tolerated;
|
|
524
746
|
}
|
|
525
747
|
|
|
@@ -540,4 +762,4 @@ function parsePorcelainZ(raw) {
|
|
|
540
762
|
return out;
|
|
541
763
|
}
|
|
542
764
|
|
|
543
|
-
main();
|
|
765
|
+
await main();
|