@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
package/src/fanout.ts
ADDED
|
@@ -0,0 +1,1473 @@
|
|
|
1
|
+
// `workit fanout plan` (G1): the slice registry for parallel work.
|
|
2
|
+
//
|
|
3
|
+
// A plan names the slices one lead fans out: each has an id, a branch, a base,
|
|
4
|
+
// a worktree, a model TIER, its dependencies and a brief (GOAL, SCOPE as a
|
|
5
|
+
// file-scope manifest, ACCEPTANCE, VERIFY, FORBIDDEN). The file lives at
|
|
6
|
+
// `<store>/fanouts/<name>.json` (D13: under the git common dir, shared by
|
|
7
|
+
// every worktree, surviving worktree removal), next to the stacks, and each
|
|
8
|
+
// plan appends an observed `fanout.planned` ledger row.
|
|
9
|
+
//
|
|
10
|
+
// Planning refuses (`invalid_input`) a brief with an empty or placeholder
|
|
11
|
+
// field, unknown or cyclic dependencies and malformed scopes, and refuses
|
|
12
|
+
// (`blocked`) two slices that may write the same file unless that is
|
|
13
|
+
// resolved: exactly one slice lists the file under `owns`, or the slices are
|
|
14
|
+
// serialized by a dependency. Overlap is evaluated on the trunk's tracked
|
|
15
|
+
// files plus one sample path per glob (`src/new/**` -> `src/new/<any>`), so
|
|
16
|
+
// directories nobody has created yet are compared too, and paths differing
|
|
17
|
+
// only in case count as one file. Each unresolved overlap carries one
|
|
18
|
+
// deterministic suggestion: an owner for known shared files (lockfiles,
|
|
19
|
+
// manifests, barrels, CI config), else a dependency that serializes them.
|
|
20
|
+
//
|
|
21
|
+
// Each slice's `hash` covers what it is (goal, acceptance, verify, forbidden,
|
|
22
|
+
// scope, owns, branch, base, dependencies), not how it is briefed (tier,
|
|
23
|
+
// reason, timebox, context, issues) or where its worktree goes. A re-plan that changes a
|
|
24
|
+
// slice starts a new run for that slice alone: its `hashSince` moves to now,
|
|
25
|
+
// and only ledger rows recorded under its hash (worktree rows carry
|
|
26
|
+
// `sliceHash`) or since then link a gone branch's merged PR to it or count as
|
|
27
|
+
// its worktree. Liveness still reads every row. Its siblings keep their runs.
|
|
28
|
+
//
|
|
29
|
+
// `fanIn: "integration"` is the one-PR mode: the trunk is an integration
|
|
30
|
+
// branch (never origin's default branch), workers merge its tip into their
|
|
31
|
+
// branch before reporting, and the lead fast-forwards it.
|
|
32
|
+
//
|
|
33
|
+
// The CLI never spawns, waits or wakes anything: it only records and checks.
|
|
34
|
+
import { spawnSync } from "node:child_process";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
import fs from "node:fs";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
|
|
39
|
+
import {
|
|
40
|
+
activeStanding,
|
|
41
|
+
appendObserved,
|
|
42
|
+
isDelegateSession,
|
|
43
|
+
defaultBase,
|
|
44
|
+
readLedger,
|
|
45
|
+
storeRoot,
|
|
46
|
+
type LedgerActor,
|
|
47
|
+
} from "./ledger";
|
|
48
|
+
import { CHECKS_FILE, lightCommand, loadCheckConfig, splitCommand } from "./check-config";
|
|
49
|
+
import { heavyCommand } from "./core/heavy";
|
|
50
|
+
import { resolveResourceSettings } from "./core/resources";
|
|
51
|
+
import { worktreesDir } from "./core/workit-paths.mjs";
|
|
52
|
+
import { resolveWorkspace, workspaceDelegation, type DelegationMode } from "./core/workspaces";
|
|
53
|
+
import { stackFileName } from "./stack";
|
|
54
|
+
|
|
55
|
+
export const FANOUT_VERSION = 1;
|
|
56
|
+
export const TIERS = ["mundane", "standard", "hard"] as const;
|
|
57
|
+
export type Tier = (typeof TIERS)[number];
|
|
58
|
+
|
|
59
|
+
export type Slice = {
|
|
60
|
+
id: string;
|
|
61
|
+
branch: string;
|
|
62
|
+
/** The branch it is cut from and diffed against: the trunk, or its single dependency's branch. */
|
|
63
|
+
base: string;
|
|
64
|
+
worktree: string;
|
|
65
|
+
tier: Tier;
|
|
66
|
+
/** Why this work is delegated; required under `delegation: inline-first`. */
|
|
67
|
+
reason: Reason | null;
|
|
68
|
+
dependsOn: string[];
|
|
69
|
+
/** The file-scope manifest: globs (`*`, `**`, `?`, `{a,b}`) relative to the repo root. */
|
|
70
|
+
scope: string[];
|
|
71
|
+
/** Shared files this slice alone may write, even where another scope matches them. */
|
|
72
|
+
owns: string[];
|
|
73
|
+
goal: string;
|
|
74
|
+
acceptance: string[];
|
|
75
|
+
verify: string[];
|
|
76
|
+
forbidden: string[];
|
|
77
|
+
context: string | null;
|
|
78
|
+
timebox: string | null;
|
|
79
|
+
/** Issues the slice's PR links (`issue` in the plan: `#42`, `IRPT-74` or a URL). */
|
|
80
|
+
issues: string[];
|
|
81
|
+
/** sliceHash of this definition: which run of the slice a ledger row belongs to. */
|
|
82
|
+
hash: string;
|
|
83
|
+
/** When a slice with this hash was first planned: older rows are not its run. */
|
|
84
|
+
hashSince: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Why a slice is delegated at all (the brief's REASON line). */
|
|
88
|
+
export const REASONS = ["explore", "parallel", "backstop", "high-risk-verify"] as const;
|
|
89
|
+
export type Reason = (typeof REASONS)[number];
|
|
90
|
+
|
|
91
|
+
export const FAN_IN_MODES = ["prs", "integration"] as const;
|
|
92
|
+
export type FanInMode = (typeof FAN_IN_MODES)[number];
|
|
93
|
+
|
|
94
|
+
export type FanoutFile = {
|
|
95
|
+
v: number;
|
|
96
|
+
name: string;
|
|
97
|
+
trunk: string;
|
|
98
|
+
/** prs: one PR per slice (default); integration: slices fast-forward one integration branch. */
|
|
99
|
+
fanIn: FanInMode;
|
|
100
|
+
/** Extra shared-file globs on top of DEFAULT_SHARED. */
|
|
101
|
+
shared: string[];
|
|
102
|
+
slices: Slice[];
|
|
103
|
+
/** The lead: the session that made the plan or took it over (`--take-lead`). */
|
|
104
|
+
leadSession: string | null;
|
|
105
|
+
/** Leads it took over from: their standing orders stay in force until cleared. */
|
|
106
|
+
formerLeads: string[];
|
|
107
|
+
createdAt: string;
|
|
108
|
+
updatedAt: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type FanoutError = {
|
|
112
|
+
ok: false;
|
|
113
|
+
code: "blocked" | "busy" | "failed" | "unavailable" | "not_found" | "invalid_input";
|
|
114
|
+
error: string;
|
|
115
|
+
unblock?: string;
|
|
116
|
+
data?: Record<string, unknown>;
|
|
117
|
+
};
|
|
118
|
+
export type FanoutResult<T> = { ok: true; data: T } | FanoutError;
|
|
119
|
+
|
|
120
|
+
export const fanoutFail = (
|
|
121
|
+
code: FanoutError["code"],
|
|
122
|
+
error: string,
|
|
123
|
+
unblock?: string,
|
|
124
|
+
data?: Record<string, unknown>,
|
|
125
|
+
): FanoutError => ({
|
|
126
|
+
ok: false,
|
|
127
|
+
code,
|
|
128
|
+
error,
|
|
129
|
+
...(unblock ? { unblock } : {}),
|
|
130
|
+
...(data ? { data } : {}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/** Files that every slice tends to touch; overlap on them is resolved by an owner. */
|
|
134
|
+
export const DEFAULT_SHARED: readonly string[] = [
|
|
135
|
+
"**/package.json",
|
|
136
|
+
"**/{package-lock.json,npm-shrinkwrap.json,bun.lock,bun.lockb,pnpm-lock.yaml,pnpm-workspace.yaml,yarn.lock}",
|
|
137
|
+
"**/{Cargo.lock,go.mod,go.sum,poetry.lock,uv.lock,Gemfile.lock,composer.lock}",
|
|
138
|
+
"**/index.{ts,tsx,js,jsx,mjs,cjs}",
|
|
139
|
+
"**/tsconfig.json",
|
|
140
|
+
".github/**",
|
|
141
|
+
".gitlab-ci.yml",
|
|
142
|
+
"CHANGELOG.md",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// scope globs
|
|
147
|
+
|
|
148
|
+
const escapeRegExp = (text: string): string => text.replace(/[.+^$()|[\]\\{}*?]/gu, "\\$&");
|
|
149
|
+
/** The pattern with `\x` escapes resolved: what a wildcard-free pattern names. */
|
|
150
|
+
const unescape = (pattern: string): string => pattern.replace(/\\(.)/gu, "$1");
|
|
151
|
+
/** Has an unescaped `*`, `?` or `{`. */
|
|
152
|
+
const hasWildcard = (pattern: string): boolean => /[*?{]/u.test(pattern.replace(/\\./gu, ""));
|
|
153
|
+
|
|
154
|
+
/** Brace alternatives expanded (`src/{a,b}/x` -> `src/a/x`, `src/b/x`), capped. */
|
|
155
|
+
function expandBraces(pattern: string, limit = 64): string[] {
|
|
156
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
157
|
+
if (pattern[index] === "\\") {
|
|
158
|
+
index += 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const close = pattern.indexOf("}", index);
|
|
162
|
+
if (pattern[index] !== "{" || close < index) continue;
|
|
163
|
+
const head = pattern.slice(0, index);
|
|
164
|
+
const tail = pattern.slice(close + 1);
|
|
165
|
+
const out: string[] = [];
|
|
166
|
+
for (const alternative of pattern.slice(index + 1, close).split(","))
|
|
167
|
+
for (const rest of expandBraces(tail, limit)) {
|
|
168
|
+
if (out.length >= limit) return out;
|
|
169
|
+
out.push(`${head}${alternative}${rest}`);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
return [pattern];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Concrete paths the pattern matches, one per brace alternative, standing in
|
|
178
|
+
* for files nobody has created yet: `src/new/**` -> `src/new/<any>`,
|
|
179
|
+
* `src/{a,b}/*.ts` -> `src/a/<any>.ts`, `src/b/<any>.ts`.
|
|
180
|
+
*/
|
|
181
|
+
export function scopeSamples(pattern: string): string[] {
|
|
182
|
+
// A plain path covers what is below it too, so `src/new` and `src/new/**`
|
|
183
|
+
// overlap through `src/new/<any>` without a second sample.
|
|
184
|
+
if (!hasWildcard(pattern)) return [unescape(pattern)];
|
|
185
|
+
return [...new Set(expandBraces(pattern).map(sampleOne))];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sampleOne(pattern: string): string {
|
|
189
|
+
let out = "";
|
|
190
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
191
|
+
const char = pattern[index];
|
|
192
|
+
if (char === "\\" && index + 1 < pattern.length) {
|
|
193
|
+
out += pattern[index + 1];
|
|
194
|
+
index += 1;
|
|
195
|
+
} else if (char === "*" && pattern[index + 1] === "*") {
|
|
196
|
+
const atSegmentStart = index === 0 || pattern[index - 1] === "/";
|
|
197
|
+
if (atSegmentStart && pattern[index + 2] === "/") index += 2;
|
|
198
|
+
else {
|
|
199
|
+
out += "<any>";
|
|
200
|
+
index += 1;
|
|
201
|
+
}
|
|
202
|
+
} else if (char === "*") out += "<any>";
|
|
203
|
+
else if (char === "?") out += "x";
|
|
204
|
+
else out += char;
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function globSource(pattern: string): string {
|
|
210
|
+
let out = "";
|
|
211
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
212
|
+
const char = pattern[index];
|
|
213
|
+
if (char === "\\" && index + 1 < pattern.length) {
|
|
214
|
+
out += escapeRegExp(pattern[index + 1]);
|
|
215
|
+
index += 1;
|
|
216
|
+
} else if (char === "*" && pattern[index + 1] === "*") {
|
|
217
|
+
const atSegmentStart = index === 0 || pattern[index - 1] === "/";
|
|
218
|
+
if (atSegmentStart && pattern[index + 2] === "/") {
|
|
219
|
+
out += "(?:[^/]*/)*";
|
|
220
|
+
index += 2;
|
|
221
|
+
} else {
|
|
222
|
+
out += ".*";
|
|
223
|
+
index += 1;
|
|
224
|
+
}
|
|
225
|
+
} else if (char === "*") out += "[^/]*";
|
|
226
|
+
else if (char === "?") out += "[^/]";
|
|
227
|
+
else if (char === "{" && pattern.indexOf("}", index) > index) {
|
|
228
|
+
// An unclosed brace is a literal `{` (stored plans are not trusted).
|
|
229
|
+
const close = pattern.indexOf("}", index);
|
|
230
|
+
const alternatives = pattern
|
|
231
|
+
.slice(index + 1, close)
|
|
232
|
+
.split(",")
|
|
233
|
+
.map(globSource);
|
|
234
|
+
out += `(?:${alternatives.join("|")})`;
|
|
235
|
+
index = close;
|
|
236
|
+
} else out += escapeRegExp(char);
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const compiled = new Map<string, RegExp>();
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Does `file` (repo-relative, `/`-separated) fall under `pattern`? Dotfiles
|
|
245
|
+
* match like any other name, the same on every runtime. A pattern without
|
|
246
|
+
* wildcards also covers everything below it, so `src/api` is a directory
|
|
247
|
+
* scope as well as a file.
|
|
248
|
+
*/
|
|
249
|
+
export function matchesScope(pattern: string, file: string): boolean {
|
|
250
|
+
let regex = compiled.get(pattern);
|
|
251
|
+
if (!regex) {
|
|
252
|
+
regex = new RegExp(`^${globSource(pattern)}$`, "u");
|
|
253
|
+
compiled.set(pattern, regex);
|
|
254
|
+
}
|
|
255
|
+
return regex.test(file) || (!hasWildcard(pattern) && file.startsWith(`${unescape(pattern)}/`));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const matchesAny = (patterns: readonly string[], file: string): boolean =>
|
|
259
|
+
patterns.some((pattern) => matchesScope(pattern, file));
|
|
260
|
+
|
|
261
|
+
/** A scope entry in canonical form, or why it is refused. */
|
|
262
|
+
function normalizePattern(raw: string): { pattern: string } | { error: string } {
|
|
263
|
+
// Only `\\[` and `\\]` are escapes (`app/\\[id\\]`); any other backslash is
|
|
264
|
+
// a Windows separator. Where both readings are possible, refuse.
|
|
265
|
+
let pattern = raw.trim();
|
|
266
|
+
if (pattern.includes("\\")) {
|
|
267
|
+
if (/\\[*?{}]/u.test(pattern))
|
|
268
|
+
return {
|
|
269
|
+
error:
|
|
270
|
+
"has \\*, \\?, \\{ or \\}: only \\[ and \\] are escapes; use / as the separator (src/**/*.ts)",
|
|
271
|
+
};
|
|
272
|
+
const brackets = /\\[[\]]/u.test(pattern);
|
|
273
|
+
if (brackets && pattern.replace(/\\[[\]]/gu, "").includes("\\"))
|
|
274
|
+
return {
|
|
275
|
+
error:
|
|
276
|
+
"mixes \\[ or \\] escapes with other backslashes, which is ambiguous: use / as the separator (app/\\[id\\]/page.tsx)",
|
|
277
|
+
};
|
|
278
|
+
if (!brackets) pattern = pattern.replaceAll("\\", "/");
|
|
279
|
+
}
|
|
280
|
+
while (pattern.startsWith("./")) pattern = pattern.slice(2);
|
|
281
|
+
if (pattern === "." || pattern === "") pattern = raw.trim() ? "**" : "";
|
|
282
|
+
if (pattern.endsWith("/")) pattern = `${pattern}**`;
|
|
283
|
+
if (!pattern) return { error: "is empty" };
|
|
284
|
+
if (pattern.startsWith("/") || /^[A-Za-z]:/u.test(pattern))
|
|
285
|
+
return { error: "must be relative to the repository root" };
|
|
286
|
+
if (pattern.split("/").includes("..")) return { error: "must not contain .." };
|
|
287
|
+
const bare = pattern.replace(/\\./gu, "");
|
|
288
|
+
if (/[[\]]/u.test(bare))
|
|
289
|
+
return {
|
|
290
|
+
error:
|
|
291
|
+
'has an unescaped [ or ]: write \\[ and \\] for literal brackets (app/\\[id\\]/page.tsx; in JSON "app/\\\\[id\\\\]/page.tsx")',
|
|
292
|
+
};
|
|
293
|
+
if (bare.startsWith("!")) return { error: "uses ! (negation is not supported)" };
|
|
294
|
+
let depth = 0;
|
|
295
|
+
for (const char of bare) {
|
|
296
|
+
if (char === "{") depth += 1;
|
|
297
|
+
if (char === "}") depth -= 1;
|
|
298
|
+
if (depth < 0 || depth > 1) return { error: "has unbalanced or nested braces" };
|
|
299
|
+
}
|
|
300
|
+
if (depth !== 0) return { error: "has unbalanced or nested braces" };
|
|
301
|
+
return { pattern };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The slice may write `file`: its scope or owns matches, and no other slice owns it. */
|
|
305
|
+
export function inScope(plan: FanoutFile, slice: Slice, file: string): boolean {
|
|
306
|
+
if (matchesAny(slice.owns, file)) return true;
|
|
307
|
+
if (!matchesAny(slice.scope, file)) return false;
|
|
308
|
+
return !plan.slices.some((other) => other.id !== slice.id && matchesAny(other.owns, file));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// parse (input and stored file; D17: unknown keys are ignored)
|
|
313
|
+
|
|
314
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
315
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
316
|
+
|
|
317
|
+
const PLACEHOLDER = /^(?:<[^>]*>|tbd|todo|fixme|\?+|\.\.\.|…)$/iu;
|
|
318
|
+
const filled = (text: string): boolean => text.trim() !== "" && !PLACEHOLDER.test(text.trim());
|
|
319
|
+
|
|
320
|
+
/** A string or list of strings; null when absent or of another type. */
|
|
321
|
+
const textList = (value: unknown): string[] | null => {
|
|
322
|
+
if (typeof value === "string") return [value];
|
|
323
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
|
|
324
|
+
return null;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
328
|
+
|
|
329
|
+
export type PlanDefaults = {
|
|
330
|
+
trunk: string;
|
|
331
|
+
/** The main checkout: a relative `worktree` is resolved against it at plan time. */
|
|
332
|
+
root: string;
|
|
333
|
+
/** Where default worktrees go: `<repo>-wt/` next to the main checkout. */
|
|
334
|
+
worktreeRoot: string;
|
|
335
|
+
/** Under inline-first (the default when absent), a slice needs a reason. */
|
|
336
|
+
delegation?: DelegationMode;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
type Parsed = { ok: true; plan: FanoutFile } | { ok: false; problems: string[] };
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Validate a plan document. Every problem is reported at once, so one edit
|
|
343
|
+
* fixes them all; the brief fields GOAL, SCOPE, ACCEPTANCE, VERIFY and
|
|
344
|
+
* FORBIDDEN must be non-empty and not a template placeholder.
|
|
345
|
+
*/
|
|
346
|
+
export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string | null): Parsed {
|
|
347
|
+
const problems: string[] = [];
|
|
348
|
+
if (!isRecord(raw)) return { ok: false, problems: ["the plan must be a JSON object"] };
|
|
349
|
+
const planName = name ?? (typeof raw.name === "string" ? raw.name.trim() : "");
|
|
350
|
+
if (!planName || planName.length > 200 || /\p{Cc}/u.test(planName))
|
|
351
|
+
problems.push('name: pass --name or set a 1-200 character "name"');
|
|
352
|
+
const trunk =
|
|
353
|
+
typeof raw.trunk === "string" && raw.trunk.trim() ? raw.trunk.trim() : defaults.trunk;
|
|
354
|
+
const shared: string[] = [];
|
|
355
|
+
for (const entry of textList(raw.shared ?? []) ?? ["<not a list>"]) {
|
|
356
|
+
const normalized = normalizePattern(entry);
|
|
357
|
+
if ("error" in normalized) problems.push(`shared "${entry}" ${normalized.error}`);
|
|
358
|
+
else shared.push(normalized.pattern);
|
|
359
|
+
}
|
|
360
|
+
const fanIn = raw.fanIn ?? "prs";
|
|
361
|
+
if (!(FAN_IN_MODES as readonly unknown[]).includes(fanIn))
|
|
362
|
+
problems.push(`fanIn must be one of ${FAN_IN_MODES.join(", ")}`);
|
|
363
|
+
if (!Array.isArray(raw.slices) || raw.slices.length === 0)
|
|
364
|
+
return { ok: false, problems: [...problems, "slices: list at least one slice"] };
|
|
365
|
+
|
|
366
|
+
const slices: Slice[] = [];
|
|
367
|
+
const seenIds = new Set<string>();
|
|
368
|
+
const seenBranches = new Map<string, string>();
|
|
369
|
+
raw.slices.forEach((item, index) => {
|
|
370
|
+
const where =
|
|
371
|
+
isRecord(item) && typeof item.id === "string" ? `slice ${item.id}` : `slice #${index + 1}`;
|
|
372
|
+
if (!isRecord(item)) {
|
|
373
|
+
problems.push(`${where}: must be an object`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const id = typeof item.id === "string" ? item.id : "";
|
|
377
|
+
if (!ID.test(id)) problems.push(`${where}: id must match ${ID.source}`);
|
|
378
|
+
else if (seenIds.has(id)) problems.push(`${where}: duplicate id`);
|
|
379
|
+
seenIds.add(id);
|
|
380
|
+
const branch = typeof item.branch === "string" ? item.branch.trim() : "";
|
|
381
|
+
if (!branch) problems.push(`${where}: branch is empty`);
|
|
382
|
+
else if (seenBranches.has(branch))
|
|
383
|
+
problems.push(`${where}: branch ${branch} is also slice ${seenBranches.get(branch)}'s`);
|
|
384
|
+
else seenBranches.set(branch, id);
|
|
385
|
+
if (branch === trunk) problems.push(`${where}: branch must not be the trunk ${trunk}`);
|
|
386
|
+
|
|
387
|
+
const goal = typeof item.goal === "string" ? item.goal.trim() : "";
|
|
388
|
+
if (!filled(goal)) problems.push(`${where}: goal is empty`);
|
|
389
|
+
const brief = (field: string): string[] => {
|
|
390
|
+
const list = textList(item[field]);
|
|
391
|
+
const kept = (list ?? []).map((entry) => entry.trim()).filter(filled);
|
|
392
|
+
if (kept.length === 0) problems.push(`${where}: ${field} is empty`);
|
|
393
|
+
return kept;
|
|
394
|
+
};
|
|
395
|
+
const acceptance = brief("acceptance");
|
|
396
|
+
const verify = brief("verify");
|
|
397
|
+
const forbidden = brief("forbidden");
|
|
398
|
+
const scope: string[] = [];
|
|
399
|
+
const owns: string[] = [];
|
|
400
|
+
for (const [field, out] of [
|
|
401
|
+
["scope", scope],
|
|
402
|
+
["owns", owns],
|
|
403
|
+
] as const) {
|
|
404
|
+
const list = textList(item[field] ?? []);
|
|
405
|
+
if (list === null) {
|
|
406
|
+
problems.push(`${where}: ${field} must be a list of globs`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
for (const entry of list.filter(filled)) {
|
|
410
|
+
const normalized = normalizePattern(entry);
|
|
411
|
+
if ("error" in normalized)
|
|
412
|
+
problems.push(`${where}: ${field} "${entry}" ${normalized.error}`);
|
|
413
|
+
else out.push(normalized.pattern);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (scope.length === 0 && textList(item.scope ?? []) !== null)
|
|
417
|
+
problems.push(`${where}: scope is empty`);
|
|
418
|
+
const tier = item.tier;
|
|
419
|
+
if (typeof tier !== "string" || !(TIERS as readonly string[]).includes(tier))
|
|
420
|
+
problems.push(`${where}: tier must be one of ${TIERS.join(", ")}`);
|
|
421
|
+
const rawReason = typeof item.reason === "string" ? item.reason.trim() : item.reason;
|
|
422
|
+
let reason: Reason | null = null;
|
|
423
|
+
if (rawReason === undefined || rawReason === null || rawReason === "") {
|
|
424
|
+
if ((defaults.delegation ?? "inline-first") === "inline-first")
|
|
425
|
+
problems.push(
|
|
426
|
+
`${where}: reason is empty; delegation is inline-first, so name why this is delegated: ${REASONS.join(", ")} (or set delegation to lead-orchestrates in the workspace)`,
|
|
427
|
+
);
|
|
428
|
+
} else if ((REASONS as readonly unknown[]).includes(rawReason)) reason = rawReason as Reason;
|
|
429
|
+
else problems.push(`${where}: reason must be one of ${REASONS.join(", ")}`);
|
|
430
|
+
const dependsOn = textList(item.dependsOn ?? []);
|
|
431
|
+
if (dependsOn === null) problems.push(`${where}: dependsOn must be a list of slice ids`);
|
|
432
|
+
const issues = textList(item.issue ?? []);
|
|
433
|
+
if (issues === null) problems.push(`${where}: issue must be an issue id or a list of them`);
|
|
434
|
+
const optional = (field: string): string | null =>
|
|
435
|
+
typeof item[field] === "string" && filled(item[field]) ? item[field].trim() : null;
|
|
436
|
+
slices.push({
|
|
437
|
+
id,
|
|
438
|
+
branch,
|
|
439
|
+
base: optional("base") ?? "",
|
|
440
|
+
worktree: path.resolve(
|
|
441
|
+
defaults.root,
|
|
442
|
+
optional("worktree") ?? path.join(defaults.worktreeRoot, id),
|
|
443
|
+
),
|
|
444
|
+
tier: tier as Tier,
|
|
445
|
+
reason,
|
|
446
|
+
dependsOn: [...new Set(dependsOn ?? [])],
|
|
447
|
+
scope,
|
|
448
|
+
owns,
|
|
449
|
+
goal,
|
|
450
|
+
acceptance,
|
|
451
|
+
verify,
|
|
452
|
+
forbidden,
|
|
453
|
+
context: optional("context"),
|
|
454
|
+
timebox: optional("timebox"),
|
|
455
|
+
issues: [...new Set((issues ?? []).map((entry) => entry.trim()).filter(filled))],
|
|
456
|
+
hash: "",
|
|
457
|
+
hashSince: "",
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
462
|
+
const byBranch = new Map(slices.map((slice) => [slice.branch, slice]));
|
|
463
|
+
for (const slice of slices) {
|
|
464
|
+
for (const dep of slice.dependsOn) {
|
|
465
|
+
if (dep === slice.id) problems.push(`slice ${slice.id}: depends on itself`);
|
|
466
|
+
else if (!byId.has(dep)) problems.push(`slice ${slice.id}: depends on unknown slice ${dep}`);
|
|
467
|
+
}
|
|
468
|
+
const explicitBase = slice.base;
|
|
469
|
+
if (explicitBase) {
|
|
470
|
+
const parent = byBranch.get(explicitBase);
|
|
471
|
+
if (parent && !slice.dependsOn.includes(parent.id))
|
|
472
|
+
problems.push(
|
|
473
|
+
`slice ${slice.id}: base ${explicitBase} is slice ${parent.id}'s branch; add "${parent.id}" to its dependsOn`,
|
|
474
|
+
);
|
|
475
|
+
} else {
|
|
476
|
+
const single = slice.dependsOn.length === 1 ? byId.get(slice.dependsOn[0]) : undefined;
|
|
477
|
+
slice.base = single ? single.branch : trunk;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const cycle = findCycle(slices);
|
|
481
|
+
if (cycle) problems.push(`dependency cycle: ${cycle.join(" -> ")}`);
|
|
482
|
+
if (problems.length) return { ok: false, problems };
|
|
483
|
+
return {
|
|
484
|
+
ok: true,
|
|
485
|
+
plan: {
|
|
486
|
+
v: FANOUT_VERSION,
|
|
487
|
+
name: planName,
|
|
488
|
+
trunk,
|
|
489
|
+
fanIn: fanIn as FanInMode,
|
|
490
|
+
shared,
|
|
491
|
+
slices,
|
|
492
|
+
leadSession: null,
|
|
493
|
+
formerLeads: [],
|
|
494
|
+
createdAt: "",
|
|
495
|
+
updatedAt: "",
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function findCycle(slices: readonly Slice[]): string[] | null {
|
|
501
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
502
|
+
const state = new Map<string, "open" | "done">();
|
|
503
|
+
const trail: string[] = [];
|
|
504
|
+
const visit = (id: string): string[] | null => {
|
|
505
|
+
if (state.get(id) === "done") return null;
|
|
506
|
+
if (state.get(id) === "open") return [...trail.slice(trail.indexOf(id)), id];
|
|
507
|
+
state.set(id, "open");
|
|
508
|
+
trail.push(id);
|
|
509
|
+
for (const dep of byId.get(id)?.dependsOn ?? []) {
|
|
510
|
+
if (!byId.has(dep) || dep === id) continue;
|
|
511
|
+
const found = visit(dep);
|
|
512
|
+
if (found) return found;
|
|
513
|
+
}
|
|
514
|
+
trail.pop();
|
|
515
|
+
state.set(id, "done");
|
|
516
|
+
return null;
|
|
517
|
+
};
|
|
518
|
+
for (const slice of slices) {
|
|
519
|
+
const found = visit(slice.id);
|
|
520
|
+
if (found) return found;
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// ordering
|
|
527
|
+
|
|
528
|
+
/** Dependencies first; among the ready ones, the order the plan lists them. */
|
|
529
|
+
export function landingOrder(slices: readonly Slice[]): string[] {
|
|
530
|
+
const order: string[] = [];
|
|
531
|
+
const placed = new Set<string>();
|
|
532
|
+
const known = new Set(slices.map((slice) => slice.id));
|
|
533
|
+
while (order.length < slices.length) {
|
|
534
|
+
const next = slices.find(
|
|
535
|
+
(slice) =>
|
|
536
|
+
!placed.has(slice.id) && slice.dependsOn.every((dep) => placed.has(dep) || !known.has(dep)),
|
|
537
|
+
);
|
|
538
|
+
if (!next) break;
|
|
539
|
+
order.push(next.id);
|
|
540
|
+
placed.add(next.id);
|
|
541
|
+
}
|
|
542
|
+
return order;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Slices that can run at the same time: wave n waits only for waves before it. */
|
|
546
|
+
export function waves(slices: readonly Slice[]): string[][] {
|
|
547
|
+
const level = new Map<string, number>();
|
|
548
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
549
|
+
for (const id of landingOrder(slices)) {
|
|
550
|
+
const deps = byId.get(id)?.dependsOn ?? [];
|
|
551
|
+
level.set(id, deps.length ? Math.max(...deps.map((dep) => level.get(dep) ?? 0)) + 1 : 0);
|
|
552
|
+
}
|
|
553
|
+
const out: string[][] = [];
|
|
554
|
+
for (const slice of slices) (out[level.get(slice.id) ?? 0] ??= []).push(slice.id);
|
|
555
|
+
return out.filter(Boolean);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Every slice `id` waits for, directly or through another dependency. */
|
|
559
|
+
function ancestors(slices: readonly Slice[]): Map<string, Set<string>> {
|
|
560
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
561
|
+
const out = new Map<string, Set<string>>();
|
|
562
|
+
for (const id of landingOrder(slices)) {
|
|
563
|
+
const set = new Set<string>();
|
|
564
|
+
for (const dep of byId.get(id)?.dependsOn ?? []) {
|
|
565
|
+
set.add(dep);
|
|
566
|
+
for (const deeper of out.get(dep) ?? []) set.add(deeper);
|
|
567
|
+
}
|
|
568
|
+
out.set(id, set);
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// overlap
|
|
575
|
+
|
|
576
|
+
export type Overlap = {
|
|
577
|
+
path: string;
|
|
578
|
+
slices: string[];
|
|
579
|
+
resolution: "owner" | "serialized" | "regenerated";
|
|
580
|
+
owner?: string;
|
|
581
|
+
/** `regenerated`: the writer the lead runs at fan-in, after each rebase. */
|
|
582
|
+
writer?: string;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const CODE = /\.(?:[cm]?[jt]sx?)$/u;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Tracked files a root package.json `write:*` script regenerates, with the
|
|
589
|
+
* command that does (#432): a non-code tracked path the script's source
|
|
590
|
+
* names (`scripts/write-budget.ts` names docs/qualification/context-budget.json).
|
|
591
|
+
* Every slice may touch them; the lead runs the writer at fan-in instead.
|
|
592
|
+
*/
|
|
593
|
+
export function regeneratedFiles(root: string, tracked: readonly string[]): Map<string, string> {
|
|
594
|
+
const found = new Map<string, string>();
|
|
595
|
+
let scripts: Record<string, unknown>;
|
|
596
|
+
try {
|
|
597
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")) as {
|
|
598
|
+
scripts?: Record<string, unknown>;
|
|
599
|
+
};
|
|
600
|
+
scripts = manifest.scripts ?? {};
|
|
601
|
+
} catch {
|
|
602
|
+
return found;
|
|
603
|
+
}
|
|
604
|
+
const runner = fs.existsSync(path.join(root, "bun.lock"))
|
|
605
|
+
? "bun run"
|
|
606
|
+
: fs.existsSync(path.join(root, "pnpm-lock.yaml"))
|
|
607
|
+
? "pnpm run"
|
|
608
|
+
: fs.existsSync(path.join(root, "yarn.lock"))
|
|
609
|
+
? "yarn run"
|
|
610
|
+
: "npm run";
|
|
611
|
+
const outputs = tracked.filter((file) => !CODE.test(file));
|
|
612
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
613
|
+
if (!name.startsWith("write:") || typeof command !== "string") continue;
|
|
614
|
+
for (const file of command.match(/[\w./-]+\.[cm]?[jt]s\b/gu) ?? []) {
|
|
615
|
+
let source: string;
|
|
616
|
+
try {
|
|
617
|
+
source = fs.readFileSync(path.join(root, file), "utf8");
|
|
618
|
+
} catch {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
for (const output of outputs)
|
|
622
|
+
if (!found.has(output) && source.includes(output)) found.set(output, `${runner} ${name}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return found;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
type OwnerSuggestion = { kind: "owner"; slice: string; paths: string[]; text: string };
|
|
629
|
+
type Suggestion =
|
|
630
|
+
| OwnerSuggestion
|
|
631
|
+
| { kind: "serialize"; edges: Array<{ slice: string; dependsOn: string }>; text: string };
|
|
632
|
+
|
|
633
|
+
export type OverlapConflict = {
|
|
634
|
+
slices: string[];
|
|
635
|
+
paths: string[];
|
|
636
|
+
/** Every path is a known shared file (lockfile, manifest, barrel, CI config). */
|
|
637
|
+
shared: boolean;
|
|
638
|
+
reason: "overlap" | "several_owners";
|
|
639
|
+
suggestion: Suggestion;
|
|
640
|
+
alternative?: Suggestion;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const quoteList = (items: readonly string[]): string => items.map((item) => `"${item}"`).join(", ");
|
|
644
|
+
|
|
645
|
+
const ownerSuggestion = (slice: string, paths: string[]): OwnerSuggestion => ({
|
|
646
|
+
kind: "owner",
|
|
647
|
+
slice,
|
|
648
|
+
paths,
|
|
649
|
+
text: `add ${quoteList(paths)} to "owns" of slice ${slice}`,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const serializeSuggestion = (ids: readonly string[]): Suggestion => {
|
|
653
|
+
const edges = ids.slice(1).map((slice, index) => ({ slice, dependsOn: ids[index] }));
|
|
654
|
+
return {
|
|
655
|
+
kind: "serialize",
|
|
656
|
+
edges,
|
|
657
|
+
text: edges
|
|
658
|
+
.map((edge) => `add "${edge.dependsOn}" to "dependsOn" of slice ${edge.slice}`)
|
|
659
|
+
.join("; "),
|
|
660
|
+
};
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Which files more than one slice may write, given the candidate paths.
|
|
665
|
+
* Resolved overlaps (one owner, or every pair serialized by a dependency)
|
|
666
|
+
* are listed; the rest are conflicts grouped by the slices involved.
|
|
667
|
+
*/
|
|
668
|
+
export function analyzeOverlap(
|
|
669
|
+
plan: FanoutFile,
|
|
670
|
+
candidates: Iterable<string>,
|
|
671
|
+
regenerated: ReadonlyMap<string, string> = new Map(),
|
|
672
|
+
): { overlaps: Overlap[]; conflicts: OverlapConflict[] } {
|
|
673
|
+
const shared = [...DEFAULT_SHARED, ...plan.shared];
|
|
674
|
+
const before = ancestors(plan.slices);
|
|
675
|
+
const related = (a: string, b: string): boolean =>
|
|
676
|
+
Boolean(before.get(a)?.has(b) || before.get(b)?.has(a));
|
|
677
|
+
const overlaps: Overlap[] = [];
|
|
678
|
+
const groups = new Map<string, OverlapConflict & { members: Slice[] }>();
|
|
679
|
+
// Paths that differ only in case are one file on case-insensitive checkouts.
|
|
680
|
+
const byCase = new Map<string, string[]>();
|
|
681
|
+
for (const file of [...new Set(candidates)].toSorted())
|
|
682
|
+
(
|
|
683
|
+
byCase.get(file.toLowerCase()) ?? byCase.set(file.toLowerCase(), []).get(file.toLowerCase())
|
|
684
|
+
)?.push(file);
|
|
685
|
+
for (const spellings of byCase.values()) {
|
|
686
|
+
const file = spellings.join(" ~ ");
|
|
687
|
+
const any = (patterns: readonly string[]) =>
|
|
688
|
+
spellings.some((spelling) => matchesAny(patterns, spelling));
|
|
689
|
+
const matching = plan.slices.filter((slice) => any(slice.scope) || any(slice.owns));
|
|
690
|
+
if (matching.length < 2) continue;
|
|
691
|
+
const ids = matching.map((slice) => slice.id);
|
|
692
|
+
const owners = matching.filter((slice) => any(slice.owns));
|
|
693
|
+
if (owners.length === 1) {
|
|
694
|
+
overlaps.push({ path: file, slices: ids, resolution: "owner", owner: owners[0].id });
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const writer = spellings.map((spelling) => regenerated.get(spelling)).find(Boolean);
|
|
698
|
+
if (writer) {
|
|
699
|
+
overlaps.push({ path: file, slices: ids, resolution: "regenerated", writer });
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
if (
|
|
703
|
+
owners.length === 0 &&
|
|
704
|
+
matching.every((a, i) => matching.slice(i + 1).every((b) => related(a.id, b.id)))
|
|
705
|
+
) {
|
|
706
|
+
overlaps.push({ path: file, slices: ids, resolution: "serialized" });
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const members = owners.length > 1 ? owners : matching;
|
|
710
|
+
const reason = owners.length > 1 ? "several_owners" : "overlap";
|
|
711
|
+
const isShared = any(shared);
|
|
712
|
+
const key = `${reason}|${isShared}|${members.map((slice) => slice.id).join(",")}`;
|
|
713
|
+
const group = groups.get(key);
|
|
714
|
+
if (group) group.paths.push(file);
|
|
715
|
+
else
|
|
716
|
+
groups.set(key, {
|
|
717
|
+
slices: members.map((slice) => slice.id),
|
|
718
|
+
paths: [file],
|
|
719
|
+
shared: isShared,
|
|
720
|
+
reason,
|
|
721
|
+
members,
|
|
722
|
+
suggestion: ownerSuggestion(members[0].id, []),
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
const conflicts = [...groups.values()].map(({ members, ...group }) => {
|
|
726
|
+
// The owner: the first slice (plan order) that names one of the paths
|
|
727
|
+
// literally, else the first slice. Serializing keeps plan order.
|
|
728
|
+
const literal = members.find((slice) =>
|
|
729
|
+
slice.scope.some((pattern) => group.paths.includes(pattern)),
|
|
730
|
+
);
|
|
731
|
+
const owner = ownerSuggestion((literal ?? members[0]).id, group.paths);
|
|
732
|
+
if (group.reason === "several_owners")
|
|
733
|
+
return {
|
|
734
|
+
...group,
|
|
735
|
+
suggestion: {
|
|
736
|
+
...owner,
|
|
737
|
+
text: `keep ${quoteList(group.paths)} in "owns" of slice ${owner.slice} only`,
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
const serialize = serializeSuggestion(group.slices);
|
|
741
|
+
return group.shared
|
|
742
|
+
? { ...group, suggestion: owner, alternative: serialize }
|
|
743
|
+
: { ...group, suggestion: serialize, alternative: owner };
|
|
744
|
+
});
|
|
745
|
+
return { overlaps, conflicts };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// case-only collisions with the trunk
|
|
750
|
+
|
|
751
|
+
export type CaseCollision = { slice: string; path: string; trunk: string };
|
|
752
|
+
|
|
753
|
+
/** Trunk paths (files and the directories above them) by lower case. */
|
|
754
|
+
export function caseIndex(files: Iterable<string>): Map<string, Set<string>> {
|
|
755
|
+
const index = new Map<string, Set<string>>();
|
|
756
|
+
for (const file of files) {
|
|
757
|
+
const parts = file.split("/");
|
|
758
|
+
for (let end = 1; end <= parts.length; end += 1) {
|
|
759
|
+
const prefix = parts.slice(0, end).join("/");
|
|
760
|
+
const key = prefix.toLowerCase();
|
|
761
|
+
(index.get(key) ?? index.set(key, new Set()).get(key))?.add(prefix);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return index;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The shallowest part of `file` (a directory or the file itself) that the
|
|
769
|
+
* trunk spells differently only in case: one path on macOS and Windows
|
|
770
|
+
* checkouts, two in git. Null when there is none.
|
|
771
|
+
*/
|
|
772
|
+
export function caseClash(
|
|
773
|
+
index: ReadonlyMap<string, ReadonlySet<string>>,
|
|
774
|
+
file: string,
|
|
775
|
+
): { path: string; trunk: string } | null {
|
|
776
|
+
const parts = file.split("/");
|
|
777
|
+
for (let end = 1; end <= parts.length; end += 1) {
|
|
778
|
+
const prefix = parts.slice(0, end).join("/");
|
|
779
|
+
if (prefix.includes("<any>")) return null;
|
|
780
|
+
const spellings = index.get(prefix.toLowerCase());
|
|
781
|
+
if (spellings && !spellings.has(prefix))
|
|
782
|
+
return { path: prefix, trunk: [...spellings].toSorted()[0] };
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// git
|
|
789
|
+
|
|
790
|
+
type GitRun = { ok: boolean; status: number | null; stdout: string; stderr: string };
|
|
791
|
+
|
|
792
|
+
export const gitRun = (
|
|
793
|
+
cwd: string,
|
|
794
|
+
args: string[],
|
|
795
|
+
timeoutMs: number = GIT_TIMEOUTS.local,
|
|
796
|
+
): GitRun => {
|
|
797
|
+
const run = spawnSync("git", args, {
|
|
798
|
+
cwd,
|
|
799
|
+
encoding: "utf8",
|
|
800
|
+
timeout: timeoutMs,
|
|
801
|
+
killSignal: "SIGKILL",
|
|
802
|
+
windowsHide: true,
|
|
803
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
804
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C", LANGUAGE: "C" },
|
|
805
|
+
});
|
|
806
|
+
return {
|
|
807
|
+
ok: run.status === 0,
|
|
808
|
+
status: run.status,
|
|
809
|
+
stdout: run.stdout ?? "",
|
|
810
|
+
stderr: run.error ? String(run.error.message) : (run.stderr ?? ""),
|
|
811
|
+
};
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
export const nulList = (text: string): string[] => text.split("\0").filter(Boolean);
|
|
815
|
+
|
|
816
|
+
/** The commit a branch name points at: the local branch, else its remote-tracking ref. */
|
|
817
|
+
export function branchRef(cwd: string, branch: string): string | null {
|
|
818
|
+
if (!branch || branch.startsWith("-")) return null;
|
|
819
|
+
const local = resolveRef(cwd, `refs/heads/${branch}`);
|
|
820
|
+
if (local) return local;
|
|
821
|
+
const remote = pushRemoteName(cwd) ?? "origin";
|
|
822
|
+
return resolveRef(cwd, `refs/remotes/${remote}/${branch}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** The trunk as the forge sees it (remote-tracking ref) when known, else the local branch. */
|
|
826
|
+
export function trunkRef(cwd: string, trunk: string): string | null {
|
|
827
|
+
if (!trunk || trunk.startsWith("-")) return null;
|
|
828
|
+
const remote = pushRemoteName(cwd) ?? "origin";
|
|
829
|
+
for (const ref of [`refs/remotes/${remote}/${trunk}`, `refs/heads/${trunk}`, trunk])
|
|
830
|
+
if (resolveRef(cwd, ref)) return ref;
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The trunk name when the plan does not give one: origin's default branch, else main. */
|
|
835
|
+
export function defaultTrunk(cwd: string): string {
|
|
836
|
+
const base = defaultBase(cwd);
|
|
837
|
+
return base ? base.replace(/^origin\//u, "") : "main";
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** The main checkout (beside `.git`), else `cwd`. */
|
|
841
|
+
export function mainCheckout(cwd: string): string {
|
|
842
|
+
const common = gitCommonDir(cwd);
|
|
843
|
+
return common && path.basename(common) === ".git" ? path.dirname(common) : path.resolve(cwd);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** A path in one comparable form: real (when it exists), `/`-separated, case folded where the filesystem ignores case. */
|
|
847
|
+
const comparablePath = (target: string): string => {
|
|
848
|
+
let real: string;
|
|
849
|
+
try {
|
|
850
|
+
real = fs.realpathSync.native(target);
|
|
851
|
+
} catch {
|
|
852
|
+
real = path.resolve(target);
|
|
853
|
+
}
|
|
854
|
+
const slashed = real.replace(/\\/gu, "/").replace(/\/+$/u, "");
|
|
855
|
+
return process.platform === "win32" || process.platform === "darwin"
|
|
856
|
+
? slashed.toLowerCase()
|
|
857
|
+
: slashed;
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Some worktree in `dir` belongs to another repository (its `.git` file points
|
|
862
|
+
* outside `common`, git's dir for this repo), or is a clone of its own.
|
|
863
|
+
*/
|
|
864
|
+
const heldByOtherRepo = (dir: string, common: string): boolean => {
|
|
865
|
+
let children: string[];
|
|
866
|
+
try {
|
|
867
|
+
children = fs.readdirSync(dir);
|
|
868
|
+
} catch {
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
const own = `${comparablePath(common)}/`;
|
|
872
|
+
return children.some((child) => {
|
|
873
|
+
const marker = path.join(dir, child, ".git");
|
|
874
|
+
let text: string;
|
|
875
|
+
try {
|
|
876
|
+
if (fs.statSync(marker).isDirectory()) return true;
|
|
877
|
+
text = fs.readFileSync(marker, "utf8");
|
|
878
|
+
} catch {
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
const admin = /^gitdir:\s*(.+)$/mu.exec(text)?.[1]?.trim();
|
|
882
|
+
return !admin || !`${comparablePath(path.resolve(dir, child, admin))}/`.startsWith(own);
|
|
883
|
+
});
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* The managed root for this checkout's slices: `<state>/worktrees/<repo>`. When
|
|
888
|
+
* another repository with the same name already has worktrees there, this one
|
|
889
|
+
* gets `<repo>-<hash of its path>`, so two checkouts never share a dir.
|
|
890
|
+
*/
|
|
891
|
+
export function worktreeRoot(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
892
|
+
const main = mainCheckout(cwd);
|
|
893
|
+
const name = path.basename(main);
|
|
894
|
+
const base = realBase(worktreesDir(env));
|
|
895
|
+
const plain = path.join(base, name);
|
|
896
|
+
const common = gitCommonDir(cwd);
|
|
897
|
+
if (!common) return plain;
|
|
898
|
+
const tag = createHash("sha256").update(comparablePath(main)).digest("hex").slice(0, 8);
|
|
899
|
+
const tagged = path.join(base, `${name}-${tag}`);
|
|
900
|
+
// Once this checkout has its tagged dir, keep it: a name that flips back to
|
|
901
|
+
// `plain` when the other repository leaves would split one checkout's slices.
|
|
902
|
+
if (fs.existsSync(tagged) || heldByOtherRepo(plain, common)) return tagged;
|
|
903
|
+
return plain;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* `dir` through the real path of its nearest existing ancestor, so the root a
|
|
908
|
+
* plan names matches what git reports (macOS: /var -> /private/var).
|
|
909
|
+
*/
|
|
910
|
+
const realBase = (dir: string): string => {
|
|
911
|
+
const rest: string[] = [];
|
|
912
|
+
let probe = path.resolve(dir);
|
|
913
|
+
for (;;) {
|
|
914
|
+
try {
|
|
915
|
+
return path.join(fs.realpathSync.native(probe), ...rest.toReversed());
|
|
916
|
+
} catch {
|
|
917
|
+
const parent = path.dirname(probe);
|
|
918
|
+
if (parent === probe) return path.resolve(dir);
|
|
919
|
+
rest.push(path.basename(probe));
|
|
920
|
+
probe = parent;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
/** Whose standing orders count: the lead and those it took over from; null before leads. */
|
|
926
|
+
export const planLeads = (
|
|
927
|
+
plan: Pick<FanoutFile, "leadSession" | "formerLeads">,
|
|
928
|
+
): string[] | null => (plan.leadSession ? [plan.leadSession, ...plan.formerLeads] : null);
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* sha256 (16 hex) of the slice's definition. Brief-only fields (tier,
|
|
932
|
+
* reason, timebox, context, issues) and the worktree path are left out, so fixing a typo in
|
|
933
|
+
* them never re-dispatches landed work.
|
|
934
|
+
*/
|
|
935
|
+
export function sliceHash(slice: Slice): string {
|
|
936
|
+
const {
|
|
937
|
+
worktree: _worktree,
|
|
938
|
+
hash: _hash,
|
|
939
|
+
hashSince: _since,
|
|
940
|
+
tier: _tier,
|
|
941
|
+
reason: _reason,
|
|
942
|
+
timebox: _timebox,
|
|
943
|
+
context: _context,
|
|
944
|
+
issues: _issues,
|
|
945
|
+
...definition
|
|
946
|
+
} = slice;
|
|
947
|
+
return createHash("sha256").update(JSON.stringify(definition)).digest("hex").slice(0, 16);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ---------------------------------------------------------------------------
|
|
951
|
+
// store
|
|
952
|
+
|
|
953
|
+
export function fanoutsDir(cwd: string): FanoutResult<string> {
|
|
954
|
+
const root = storeRoot(cwd);
|
|
955
|
+
if (!root.ok) return fanoutFail(root.code, root.error, root.unblock);
|
|
956
|
+
return { ok: true, data: path.join(root.value.root, "fanouts") };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Tolerant read (D17) that still re-validates what the checks rely on: every
|
|
961
|
+
* glob normalizes, every dependency is a known slice, no cycle. A file that
|
|
962
|
+
* fails is "not a valid plan", never half-trusted.
|
|
963
|
+
*/
|
|
964
|
+
function parseStored(raw: unknown): FanoutFile | null {
|
|
965
|
+
if (!isRecord(raw) || typeof raw.name !== "string" || typeof raw.trunk !== "string") return null;
|
|
966
|
+
if (!Array.isArray(raw.slices)) return null;
|
|
967
|
+
const list = (value: unknown): string[] =>
|
|
968
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
969
|
+
const text = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
970
|
+
let globsValid = true;
|
|
971
|
+
const globs = (value: unknown): string[] =>
|
|
972
|
+
list(value).map((entry) => {
|
|
973
|
+
const normalized = normalizePattern(entry);
|
|
974
|
+
if ("error" in normalized) {
|
|
975
|
+
globsValid = false;
|
|
976
|
+
return entry;
|
|
977
|
+
}
|
|
978
|
+
return normalized.pattern;
|
|
979
|
+
});
|
|
980
|
+
const slices: Slice[] = [];
|
|
981
|
+
for (const item of raw.slices) {
|
|
982
|
+
if (!isRecord(item) || typeof item.id !== "string" || typeof item.branch !== "string")
|
|
983
|
+
return null;
|
|
984
|
+
slices.push({
|
|
985
|
+
id: item.id,
|
|
986
|
+
branch: item.branch,
|
|
987
|
+
base: text(item.base) || raw.trunk,
|
|
988
|
+
worktree: text(item.worktree),
|
|
989
|
+
tier: (TIERS as readonly unknown[]).includes(item.tier) ? (item.tier as Tier) : "standard",
|
|
990
|
+
reason: (REASONS as readonly unknown[]).includes(item.reason)
|
|
991
|
+
? (item.reason as Reason)
|
|
992
|
+
: null,
|
|
993
|
+
dependsOn: list(item.dependsOn),
|
|
994
|
+
scope: globs(item.scope),
|
|
995
|
+
owns: globs(item.owns),
|
|
996
|
+
goal: text(item.goal),
|
|
997
|
+
acceptance: list(item.acceptance),
|
|
998
|
+
verify: list(item.verify),
|
|
999
|
+
forbidden: list(item.forbidden),
|
|
1000
|
+
context: typeof item.context === "string" ? item.context : null,
|
|
1001
|
+
timebox: typeof item.timebox === "string" ? item.timebox : null,
|
|
1002
|
+
issues: list(item.issues),
|
|
1003
|
+
hash: text(item.hash),
|
|
1004
|
+
// A plan written before slice hashes: its run began at createdAt.
|
|
1005
|
+
hashSince: text(item.hashSince) || text(raw.createdAt),
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
for (const slice of slices) slice.hash ||= sliceHash(slice);
|
|
1009
|
+
const shared = globs(raw.shared);
|
|
1010
|
+
const ids = new Set(slices.map((slice) => slice.id));
|
|
1011
|
+
if (
|
|
1012
|
+
!globsValid ||
|
|
1013
|
+
ids.size !== slices.length ||
|
|
1014
|
+
slices.some((slice) => slice.dependsOn.some((dep) => dep === slice.id || !ids.has(dep))) ||
|
|
1015
|
+
findCycle(slices)
|
|
1016
|
+
)
|
|
1017
|
+
return null;
|
|
1018
|
+
const fanIn = raw.fanIn === "integration" ? "integration" : "prs";
|
|
1019
|
+
const createdAt = text(raw.createdAt);
|
|
1020
|
+
return {
|
|
1021
|
+
v: typeof raw.v === "number" ? raw.v : FANOUT_VERSION,
|
|
1022
|
+
name: raw.name,
|
|
1023
|
+
trunk: raw.trunk,
|
|
1024
|
+
fanIn,
|
|
1025
|
+
shared,
|
|
1026
|
+
slices,
|
|
1027
|
+
leadSession: typeof raw.leadSession === "string" && raw.leadSession ? raw.leadSession : null,
|
|
1028
|
+
formerLeads: list(raw.formerLeads),
|
|
1029
|
+
createdAt,
|
|
1030
|
+
updatedAt: text(raw.updatedAt),
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
export function readFanout(cwd: string, name: string): FanoutResult<FanoutFile | null> {
|
|
1035
|
+
const dir = fanoutsDir(cwd);
|
|
1036
|
+
if (!dir.ok) return dir;
|
|
1037
|
+
const file = path.join(dir.data, stackFileName(name));
|
|
1038
|
+
let parsed: FanoutFile | null = null;
|
|
1039
|
+
try {
|
|
1040
|
+
parsed = parseStored(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, data: null };
|
|
1043
|
+
parsed = null;
|
|
1044
|
+
}
|
|
1045
|
+
return parsed
|
|
1046
|
+
? { ok: true, data: parsed }
|
|
1047
|
+
: fanoutFail(
|
|
1048
|
+
"failed",
|
|
1049
|
+
`fanout file ${file} is not a valid plan`,
|
|
1050
|
+
`workit fanout plan <plan.json> --name ${name} # rewrite it`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function listFanouts(cwd: string): FanoutResult<FanoutFile[]> {
|
|
1055
|
+
const dir = fanoutsDir(cwd);
|
|
1056
|
+
if (!dir.ok) return dir;
|
|
1057
|
+
let names: string[] = [];
|
|
1058
|
+
try {
|
|
1059
|
+
names = fs.readdirSync(dir.data).filter((entry) => entry.endsWith(".json"));
|
|
1060
|
+
} catch {
|
|
1061
|
+
return { ok: true, data: [] };
|
|
1062
|
+
}
|
|
1063
|
+
const out: FanoutFile[] = [];
|
|
1064
|
+
for (const entry of names) {
|
|
1065
|
+
let parsed: FanoutFile | null = null;
|
|
1066
|
+
try {
|
|
1067
|
+
parsed = parseStored(JSON.parse(fs.readFileSync(path.join(dir.data, entry), "utf8")));
|
|
1068
|
+
} catch {
|
|
1069
|
+
parsed = null;
|
|
1070
|
+
}
|
|
1071
|
+
if (parsed && stackFileName(parsed.name) === entry) out.push(parsed);
|
|
1072
|
+
}
|
|
1073
|
+
return { ok: true, data: out.toSorted((a, b) => a.name.localeCompare(b.name)) };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* The plan `name`, else the one with the current branch as a slice, else the
|
|
1078
|
+
* one `session` leads, else the only one. Several left: the error names
|
|
1079
|
+
* `--fanout <name>` and the candidates (#641).
|
|
1080
|
+
*/
|
|
1081
|
+
export function selectFanout(
|
|
1082
|
+
cwd: string,
|
|
1083
|
+
name: string | null,
|
|
1084
|
+
current: string | null,
|
|
1085
|
+
session: string | null = null,
|
|
1086
|
+
): FanoutResult<FanoutFile> {
|
|
1087
|
+
if (name) {
|
|
1088
|
+
const read = readFanout(cwd, name);
|
|
1089
|
+
if (!read.ok) return read;
|
|
1090
|
+
return read.data
|
|
1091
|
+
? { ok: true, data: read.data }
|
|
1092
|
+
: fanoutFail("not_found", `no fanout plan named ${name}`, "workit fanout plan <plan.json>");
|
|
1093
|
+
}
|
|
1094
|
+
const all = listFanouts(cwd);
|
|
1095
|
+
if (!all.ok) return all;
|
|
1096
|
+
const holding = all.data.filter((plan) => plan.slices.some((slice) => slice.branch === current));
|
|
1097
|
+
if (holding.length === 1) return { ok: true, data: holding[0] };
|
|
1098
|
+
const led = session ? all.data.filter((plan) => plan.leadSession === session) : [];
|
|
1099
|
+
if (holding.length === 0 && led.length === 1) return { ok: true, data: led[0] };
|
|
1100
|
+
if (all.data.length === 1) return { ok: true, data: all.data[0] };
|
|
1101
|
+
if (all.data.length === 0)
|
|
1102
|
+
return fanoutFail(
|
|
1103
|
+
"not_found",
|
|
1104
|
+
"no fanout plan is recorded here",
|
|
1105
|
+
"workit fanout plan <plan.json>",
|
|
1106
|
+
);
|
|
1107
|
+
const candidates = (holding.length > 1 ? holding : led.length > 1 ? led : all.data).map(
|
|
1108
|
+
(plan) => plan.name,
|
|
1109
|
+
);
|
|
1110
|
+
return fanoutFail(
|
|
1111
|
+
"invalid_input",
|
|
1112
|
+
`several fanout plans match; pick one with --fanout <name>: ${candidates.join(", ")}`,
|
|
1113
|
+
`pass --fanout <name>, one of: ${candidates.join(", ")}`,
|
|
1114
|
+
{ candidates },
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* The issues the fanout slice on `branch` links (its `issue` field), so a
|
|
1120
|
+
* worker's `workit pr create` links them unasked. A race attempt branch
|
|
1121
|
+
* `<branch>-try<n>` counts as its slice; no plan or no slice is none.
|
|
1122
|
+
*/
|
|
1123
|
+
export function sliceIssuesFor(cwd: string, branch: string | null): string[] {
|
|
1124
|
+
if (!branch) return [];
|
|
1125
|
+
const all = listFanouts(cwd);
|
|
1126
|
+
if (!all.ok) return [];
|
|
1127
|
+
const issues = all.data.flatMap((plan) =>
|
|
1128
|
+
plan.slices
|
|
1129
|
+
.filter(
|
|
1130
|
+
(slice) =>
|
|
1131
|
+
slice.branch === branch ||
|
|
1132
|
+
(branch.startsWith(`${slice.branch}-try`) &&
|
|
1133
|
+
/^\d+$/u.test(branch.slice(slice.branch.length + 4))),
|
|
1134
|
+
)
|
|
1135
|
+
.flatMap((slice) => slice.issues),
|
|
1136
|
+
);
|
|
1137
|
+
return [...new Set(issues)];
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function writeFanout(cwd: string, plan: FanoutFile): FanoutResult<string> {
|
|
1141
|
+
const dir = fanoutsDir(cwd);
|
|
1142
|
+
if (!dir.ok) return dir;
|
|
1143
|
+
const file = path.join(dir.data, stackFileName(plan.name));
|
|
1144
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
1145
|
+
try {
|
|
1146
|
+
fs.mkdirSync(dir.data, { recursive: true });
|
|
1147
|
+
fs.writeFileSync(temp, `${JSON.stringify(plan, null, 2)}\n`);
|
|
1148
|
+
fs.renameSync(temp, file);
|
|
1149
|
+
return { ok: true, data: file };
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
fs.rmSync(temp, { force: true });
|
|
1152
|
+
return fanoutFail("unavailable", `cannot write ${file}: ${(error as Error).message}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
// plan
|
|
1158
|
+
|
|
1159
|
+
export type PlanOutcome = {
|
|
1160
|
+
name: string;
|
|
1161
|
+
trunk: string;
|
|
1162
|
+
fanIn: FanInMode;
|
|
1163
|
+
file: string;
|
|
1164
|
+
created: boolean;
|
|
1165
|
+
slices: Array<
|
|
1166
|
+
Pick<Slice, "id" | "branch" | "base" | "worktree" | "tier" | "dependsOn" | "scope" | "owns">
|
|
1167
|
+
>;
|
|
1168
|
+
overlaps: Overlap[];
|
|
1169
|
+
waves: string[][];
|
|
1170
|
+
landingOrder: string[];
|
|
1171
|
+
notes: string[];
|
|
1172
|
+
/**
|
|
1173
|
+
* A new plan under a name that still has standing orders in force (an
|
|
1174
|
+
* earlier run's?), or a wave with more full-suite VERIFY runs than lease slots.
|
|
1175
|
+
*/
|
|
1176
|
+
warnings: string[];
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
/** Above this many slices in one wave, the lead queues the rest (4-6 in flight). */
|
|
1180
|
+
export const IN_FLIGHT_CAP = 6;
|
|
1181
|
+
|
|
1182
|
+
/** A word naming one file (`test/a.test.ts`), not a directory, glob or package pattern. */
|
|
1183
|
+
const FILE_WORD = /^[^*?[\]{}]*\.[A-Za-z0-9]+$/u;
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* The part of a VERIFY line that runs a whole suite under the heavy-check
|
|
1187
|
+
* lease, or null: `workit check <name>` for a heavy named check, or a heavy
|
|
1188
|
+
* command (raw, or after `workit check --heavy --`) that names no file.
|
|
1189
|
+
*/
|
|
1190
|
+
export function fullSuiteVerify(
|
|
1191
|
+
line: string,
|
|
1192
|
+
heavyChecks: readonly string[],
|
|
1193
|
+
cwd: string,
|
|
1194
|
+
): string | null {
|
|
1195
|
+
for (const part of line.split(/\s*(?:;|&&|\|\|)\s*/u)) {
|
|
1196
|
+
const parsed = splitCommand(part);
|
|
1197
|
+
const words = parsed instanceof Error ? part.trim().split(/\s+/u).filter(Boolean) : parsed;
|
|
1198
|
+
if (!words.length) continue;
|
|
1199
|
+
if (words[0] === "workit" && words[1] === "check") {
|
|
1200
|
+
const split = words.indexOf("--");
|
|
1201
|
+
if (split < 0) {
|
|
1202
|
+
const head = words.slice(2);
|
|
1203
|
+
const name = head.find(
|
|
1204
|
+
(word, index) =>
|
|
1205
|
+
!word.startsWith("-") &&
|
|
1206
|
+
!/^--(?:cwd|name|task|base|timeout|wait|reason)$/u.test(head[index - 1] ?? ""),
|
|
1207
|
+
);
|
|
1208
|
+
if (name && heavyChecks.includes(name)) return part.trim();
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
if (!words.slice(2, split).includes("--heavy")) continue;
|
|
1212
|
+
const command = words.slice(split + 1);
|
|
1213
|
+
if (untargetedHeavy(command, cwd)) return part.trim();
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (untargetedHeavy(words, cwd)) return part.trim();
|
|
1217
|
+
}
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const untargetedHeavy = (words: string[], cwd: string): boolean =>
|
|
1222
|
+
words.length > 0 &&
|
|
1223
|
+
!lightCommand(words) &&
|
|
1224
|
+
!words.slice(1).some((word) => !word.startsWith("-") && FILE_WORD.test(word)) &&
|
|
1225
|
+
heavyCommand(words.join(" "), { cwd }) !== null;
|
|
1226
|
+
|
|
1227
|
+
/** The machine's lease slots and heavy named checks, as `workit check` resolves them. */
|
|
1228
|
+
function leaseSettings(cwd: string): { slots: number; heavy: string[] } {
|
|
1229
|
+
let machine: unknown;
|
|
1230
|
+
try {
|
|
1231
|
+
machine = (resolveWorkspace(cwd) as Record<string, unknown> | null)?.resources;
|
|
1232
|
+
} catch {
|
|
1233
|
+
machine = undefined;
|
|
1234
|
+
}
|
|
1235
|
+
return resolveResourceSettings([
|
|
1236
|
+
{ label: "workspaces.json", trust: "machine", value: machine },
|
|
1237
|
+
{ label: CHECKS_FILE, trust: "repo", value: loadCheckConfig(cwd).resources },
|
|
1238
|
+
]).settings;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* One warning per wave in which more slices run a full suite in VERIFY than
|
|
1243
|
+
* the machine has heavy-check lease slots: the rest queue a whole suite run
|
|
1244
|
+
* each (#326, retro R6).
|
|
1245
|
+
*/
|
|
1246
|
+
export function leaseContention(
|
|
1247
|
+
slices: readonly Slice[],
|
|
1248
|
+
grouped: readonly string[][],
|
|
1249
|
+
lease: { slots: number; heavy: readonly string[] },
|
|
1250
|
+
cwd: string,
|
|
1251
|
+
): string[] {
|
|
1252
|
+
const warnings: string[] = [];
|
|
1253
|
+
grouped.forEach((wave, index) => {
|
|
1254
|
+
const heavy = wave.flatMap((id) => {
|
|
1255
|
+
const slice = slices.find((item) => item.id === id);
|
|
1256
|
+
const hit = slice?.verify
|
|
1257
|
+
.map((line) => fullSuiteVerify(line, lease.heavy, cwd))
|
|
1258
|
+
.find(Boolean);
|
|
1259
|
+
return hit ? [{ id, hit }] : [];
|
|
1260
|
+
});
|
|
1261
|
+
if (heavy.length <= lease.slots) return;
|
|
1262
|
+
const where = grouped.length > 1 ? `wave ${index + 1}: ` : "";
|
|
1263
|
+
const slots = `${lease.slots} heavy-check lease slot${lease.slots === 1 ? "" : "s"}`;
|
|
1264
|
+
warnings.push(
|
|
1265
|
+
`${where}${heavy.length} slices (${heavy.map((item) => item.id).join(", ")}) run a full suite in VERIFY (\`${heavy[0].hit}\`), but this machine has ${slots}: ${heavy.length - lease.slots} of them queue a whole suite run behind the others. Have each slice verify with its targeted test files (\`workit check --name <slice>-tests -- <test runner> <its test files>\`, no lease) and run the full suite once at fan-in`,
|
|
1266
|
+
);
|
|
1267
|
+
});
|
|
1268
|
+
return warnings;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
export function planFanout(
|
|
1272
|
+
cwd: string,
|
|
1273
|
+
input: {
|
|
1274
|
+
raw: unknown;
|
|
1275
|
+
name: string | null;
|
|
1276
|
+
trunk: string | null;
|
|
1277
|
+
actor: LedgerActor;
|
|
1278
|
+
/** Record this session as the lead (a lead resuming in a new session). */
|
|
1279
|
+
takeLead?: boolean;
|
|
1280
|
+
},
|
|
1281
|
+
now: Date = new Date(),
|
|
1282
|
+
): FanoutResult<PlanOutcome> {
|
|
1283
|
+
const dir = fanoutsDir(cwd);
|
|
1284
|
+
if (!dir.ok) return dir;
|
|
1285
|
+
const session = input.actor.session;
|
|
1286
|
+
if (input.takeLead && !session)
|
|
1287
|
+
return fanoutFail(
|
|
1288
|
+
"blocked",
|
|
1289
|
+
"--take-lead needs a session: WORKIT_SESSION_ID is not set",
|
|
1290
|
+
"export WORKIT_SESSION_ID=<your session id>, then plan again with --take-lead",
|
|
1291
|
+
);
|
|
1292
|
+
if (input.takeLead && isDelegateSession(session))
|
|
1293
|
+
return fanoutFail(
|
|
1294
|
+
"blocked",
|
|
1295
|
+
`session ${session} is a worker or verifier id; only a lead takes the lead`,
|
|
1296
|
+
"run --take-lead from the lead's own session",
|
|
1297
|
+
);
|
|
1298
|
+
const parsed = parsePlan(
|
|
1299
|
+
input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
|
|
1300
|
+
{
|
|
1301
|
+
trunk: input.trunk ?? defaultTrunk(cwd),
|
|
1302
|
+
root: mainCheckout(cwd),
|
|
1303
|
+
worktreeRoot: worktreeRoot(cwd),
|
|
1304
|
+
delegation: workspaceDelegation(cwd),
|
|
1305
|
+
},
|
|
1306
|
+
input.name,
|
|
1307
|
+
);
|
|
1308
|
+
if (!parsed.ok)
|
|
1309
|
+
return fanoutFail(
|
|
1310
|
+
"invalid_input",
|
|
1311
|
+
`the plan is incomplete: ${parsed.problems[0]}${parsed.problems.length > 1 ? ` (+${parsed.problems.length - 1} more)` : ""}`,
|
|
1312
|
+
"fill every listed field, then run workit fanout plan again",
|
|
1313
|
+
{ problems: parsed.problems },
|
|
1314
|
+
);
|
|
1315
|
+
const plan = parsed.plan;
|
|
1316
|
+
if (plan.fanIn === "integration" && plan.trunk === defaultTrunk(cwd))
|
|
1317
|
+
return fanoutFail(
|
|
1318
|
+
"invalid_input",
|
|
1319
|
+
`fanIn "integration" needs an integration branch as the trunk, not ${plan.trunk}`,
|
|
1320
|
+
`workit git branch <integration-branch> --base ${plan.trunk}, then plan with --trunk <integration-branch>`,
|
|
1321
|
+
);
|
|
1322
|
+
const badBranches = plan.slices
|
|
1323
|
+
.filter((slice) => !gitRun(cwd, ["check-ref-format", "--branch", slice.branch]).ok)
|
|
1324
|
+
.map((slice) => `slice ${slice.id}: ${slice.branch} is not a valid branch name`);
|
|
1325
|
+
if (badBranches.length)
|
|
1326
|
+
return fanoutFail("invalid_input", badBranches[0], "rename the branch in the plan", {
|
|
1327
|
+
problems: badBranches,
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
const ref = trunkRef(cwd, plan.trunk);
|
|
1331
|
+
const tracked = ref
|
|
1332
|
+
? nulList(gitRun(cwd, ["ls-tree", "-r", "-z", "--name-only", ref]).stdout)
|
|
1333
|
+
: [];
|
|
1334
|
+
const samples = plan.slices.flatMap((slice) =>
|
|
1335
|
+
[...slice.scope, ...slice.owns].flatMap(scopeSamples),
|
|
1336
|
+
);
|
|
1337
|
+
const { overlaps, conflicts } = analyzeOverlap(
|
|
1338
|
+
plan,
|
|
1339
|
+
[...tracked, ...samples],
|
|
1340
|
+
regeneratedFiles(mainCheckout(cwd), tracked),
|
|
1341
|
+
);
|
|
1342
|
+
if (conflicts.length)
|
|
1343
|
+
return fanoutFail(
|
|
1344
|
+
"blocked",
|
|
1345
|
+
`${conflicts.length} file-scope overlap${conflicts.length === 1 ? "" : "s"} between slices: ${conflicts[0].slices.join(" and ")} on ${conflicts[0].paths[0]}${conflicts[0].paths.length > 1 ? ` (+${conflicts[0].paths.length - 1})` : ""}`,
|
|
1346
|
+
conflicts[0].suggestion.text,
|
|
1347
|
+
{ name: plan.name, conflicts, overlaps },
|
|
1348
|
+
);
|
|
1349
|
+
|
|
1350
|
+
const trunkCase = caseIndex(tracked);
|
|
1351
|
+
const clashes: CaseCollision[] = [];
|
|
1352
|
+
for (const slice of plan.slices) {
|
|
1353
|
+
const seen = new Set<string>();
|
|
1354
|
+
for (const sample of [...slice.scope, ...slice.owns].flatMap(scopeSamples)) {
|
|
1355
|
+
const clash = caseClash(trunkCase, sample);
|
|
1356
|
+
if (clash && !seen.has(clash.path)) {
|
|
1357
|
+
seen.add(clash.path);
|
|
1358
|
+
clashes.push({ slice: slice.id, ...clash });
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (clashes.length)
|
|
1363
|
+
return fanoutFail(
|
|
1364
|
+
"blocked",
|
|
1365
|
+
`slice ${clashes[0].slice} names ${clashes[0].path}, which differs only in case from ${plan.trunk}'s ${clashes[0].trunk} (one path on macOS and Windows)`,
|
|
1366
|
+
`use ${plan.trunk}'s spelling ${clashes[0].trunk} in slice ${clashes[0].slice}'s scope, then workit fanout plan again`,
|
|
1367
|
+
{ name: plan.name, caseCollisions: clashes, overlaps },
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
const existing = readFanout(cwd, plan.name);
|
|
1371
|
+
const previous = existing.ok ? existing.data : null;
|
|
1372
|
+
plan.createdAt = previous?.createdAt || now.toISOString();
|
|
1373
|
+
// A re-plan keeps the lead (and a plan from before leads stays without one)
|
|
1374
|
+
// unless this session takes it over.
|
|
1375
|
+
plan.leadSession = input.takeLead ? session : previous ? previous.leadSession : session;
|
|
1376
|
+
plan.formerLeads = [
|
|
1377
|
+
...new Set([
|
|
1378
|
+
...(previous?.formerLeads ?? []),
|
|
1379
|
+
...(previous?.leadSession && previous.leadSession !== plan.leadSession
|
|
1380
|
+
? [previous.leadSession]
|
|
1381
|
+
: []),
|
|
1382
|
+
]),
|
|
1383
|
+
].filter((former) => former !== plan.leadSession);
|
|
1384
|
+
plan.updatedAt = now.toISOString();
|
|
1385
|
+
// Per slice: the same definition continues its run; a changed one starts anew.
|
|
1386
|
+
for (const slice of plan.slices) {
|
|
1387
|
+
slice.hash = sliceHash(slice);
|
|
1388
|
+
const before = previous?.slices.find((old) => old.id === slice.id);
|
|
1389
|
+
slice.hashSince =
|
|
1390
|
+
before && before.hash === slice.hash ? before.hashSince || plan.updatedAt : plan.updatedAt;
|
|
1391
|
+
}
|
|
1392
|
+
const written = writeFanout(cwd, plan);
|
|
1393
|
+
if (!written.ok) return written;
|
|
1394
|
+
if (previous && plan.leadSession !== previous.leadSession)
|
|
1395
|
+
appendObserved(cwd, {
|
|
1396
|
+
type: "fanout.lead.changed",
|
|
1397
|
+
actor: input.actor,
|
|
1398
|
+
fanout: plan.name,
|
|
1399
|
+
from: previous.leadSession,
|
|
1400
|
+
to: plan.leadSession,
|
|
1401
|
+
});
|
|
1402
|
+
appendObserved(cwd, {
|
|
1403
|
+
type: "fanout.planned",
|
|
1404
|
+
actor: input.actor,
|
|
1405
|
+
fanout: plan.name,
|
|
1406
|
+
trunk: plan.trunk,
|
|
1407
|
+
fanIn: plan.fanIn,
|
|
1408
|
+
slices: plan.slices.length,
|
|
1409
|
+
ids: plan.slices.slice(0, 20).map((slice) => slice.id),
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
const grouped = waves(plan.slices);
|
|
1413
|
+
const notes: string[] = [];
|
|
1414
|
+
const warnings: string[] = [];
|
|
1415
|
+
if (!input.takeLead && previous?.leadSession && session && session !== previous.leadSession)
|
|
1416
|
+
notes.push(
|
|
1417
|
+
`the lead stays ${previous.leadSession}; this session is ${session}. Leading from this session now? workit fanout plan <plan.json> --take-lead`,
|
|
1418
|
+
);
|
|
1419
|
+
if (previous === null) {
|
|
1420
|
+
const ledger = readLedger(cwd);
|
|
1421
|
+
const inForce = ledger.ok ? activeStanding(ledger.value.rows, plan.name, planLeads(plan)) : [];
|
|
1422
|
+
if (inForce.length)
|
|
1423
|
+
warnings.push(
|
|
1424
|
+
`${inForce.length} standing order${inForce.length === 1 ? " is" : "s are"} already in force for fanout ${plan.name}, and every brief will carry ${inForce.length === 1 ? "it" : "them"}: ${inForce.map((row) => `"${String(row.what)}"`).join("; ")}. From an earlier run? workit ledger standing clear --fanout ${plan.name}`,
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
if (!ref)
|
|
1428
|
+
notes.push(
|
|
1429
|
+
`trunk ${plan.trunk} does not resolve here; only sample paths of each glob were compared`,
|
|
1430
|
+
);
|
|
1431
|
+
warnings.push(...leaseContention(plan.slices, grouped, leaseSettings(cwd), cwd));
|
|
1432
|
+
grouped.forEach((wave, index) => {
|
|
1433
|
+
if (wave.length > IN_FLIGHT_CAP)
|
|
1434
|
+
notes.push(
|
|
1435
|
+
`wave ${index + 1} has ${wave.length} slices; keep 4-${IN_FLIGHT_CAP} in flight and queue the rest`,
|
|
1436
|
+
);
|
|
1437
|
+
});
|
|
1438
|
+
for (const overlap of overlaps)
|
|
1439
|
+
if (overlap.writer)
|
|
1440
|
+
notes.push(
|
|
1441
|
+
`${overlap.path} is regenerated at fan-in: slices leave it uncommitted, and the lead runs \`${overlap.writer}\` after each rebase`,
|
|
1442
|
+
);
|
|
1443
|
+
for (const slice of plan.slices)
|
|
1444
|
+
if (slice.dependsOn.length > 1 && slice.base === plan.trunk)
|
|
1445
|
+
notes.push(
|
|
1446
|
+
`slice ${slice.id} depends on ${slice.dependsOn.join(", ")}: start it from ${plan.trunk} after they land`,
|
|
1447
|
+
);
|
|
1448
|
+
return {
|
|
1449
|
+
ok: true,
|
|
1450
|
+
data: {
|
|
1451
|
+
name: plan.name,
|
|
1452
|
+
trunk: plan.trunk,
|
|
1453
|
+
fanIn: plan.fanIn,
|
|
1454
|
+
file: written.data,
|
|
1455
|
+
created: previous === null,
|
|
1456
|
+
slices: plan.slices.map(({ id, branch, base, worktree, tier, dependsOn, scope, owns }) => ({
|
|
1457
|
+
id,
|
|
1458
|
+
branch,
|
|
1459
|
+
base,
|
|
1460
|
+
worktree,
|
|
1461
|
+
tier,
|
|
1462
|
+
dependsOn,
|
|
1463
|
+
scope,
|
|
1464
|
+
owns,
|
|
1465
|
+
})),
|
|
1466
|
+
overlaps,
|
|
1467
|
+
waves: grouped,
|
|
1468
|
+
landingOrder: landingOrder(plan.slices),
|
|
1469
|
+
notes,
|
|
1470
|
+
warnings,
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
}
|