@juzser/blacksmith 0.3.0 → 0.5.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/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readEnv } from './env.js';
|
|
4
5
|
// factory/orchestrator/src/paths.ts -> repo root is three levels up.
|
|
5
6
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
/**
|
|
@@ -11,6 +12,16 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
11
12
|
* assets ships in the tarball. `packaging.test.ts` holds that claim.
|
|
12
13
|
*/
|
|
13
14
|
export const REPO_ROOT = path.resolve(here, '..', '..', '..');
|
|
15
|
+
/**
|
|
16
|
+
* Is `dir` this factory's own checkout, resolved rather than compared raw so
|
|
17
|
+
* a trailing slash or a relative path names the same answer. `audit.ts`
|
|
18
|
+
* (`auditedProjectName`) and `projects.ts` (`unwatchedProjects`) both need to
|
|
19
|
+
* tell "this clone, filed under its own directory name" from "a project that
|
|
20
|
+
* is genuinely named that" and used to each spell the comparison themselves.
|
|
21
|
+
*/
|
|
22
|
+
export function isFactoryCheckout(dir) {
|
|
23
|
+
return path.resolve(dir) === path.resolve(REPO_ROOT);
|
|
24
|
+
}
|
|
14
25
|
/**
|
|
15
26
|
* Is this module running out of the blacksmith clone, or out of an installed
|
|
16
27
|
* copy of the package?
|
|
@@ -19,7 +30,7 @@ export const REPO_ROOT = path.resolve(here, '..', '..', '..');
|
|
|
19
30
|
* `package.json#files` could not ship it if it tried. A linked worktree spells
|
|
20
31
|
* it as a file rather than a directory, which `existsSync` reads either way.
|
|
21
32
|
*/
|
|
22
|
-
const IS_CLONE = existsSync(path.join(REPO_ROOT, '.git'));
|
|
33
|
+
export const IS_CLONE = existsSync(path.join(REPO_ROOT, '.git'));
|
|
23
34
|
/**
|
|
24
35
|
* Where the work root goes when the CLI is not standing in a clone.
|
|
25
36
|
*
|
|
@@ -36,9 +47,10 @@ const WORK_DIR_NAME = '.blacksmith';
|
|
|
36
47
|
*
|
|
37
48
|
* Pure, and exported for the test: the constants below call it once at module
|
|
38
49
|
* load, but the install case can only be stated by a test that passes its own
|
|
39
|
-
* roots in. `
|
|
40
|
-
* into itself, and an install writes beside
|
|
41
|
-
* `node_modules/@juzser/blacksmith`, which the
|
|
50
|
+
* roots in. `BS_HOME` (or its legacy `SMITH_HOME` fallback) wins outright;
|
|
51
|
+
* otherwise a clone keeps writing into itself, and an install writes beside
|
|
52
|
+
* the operator instead of into `node_modules/@juzser/blacksmith`, which the
|
|
53
|
+
* next `npm i` replaces wholesale.
|
|
42
54
|
*
|
|
43
55
|
* The layout *under* the root is identical in both cases on purpose. A path
|
|
44
56
|
* like `state/events/<session>.jsonl` is spelled in runbooks, in agent
|
|
@@ -46,9 +58,10 @@ const WORK_DIR_NAME = '.blacksmith';
|
|
|
46
58
|
* if only the anchor moves.
|
|
47
59
|
*/
|
|
48
60
|
export function resolveWorkRoot(repoRoot, cwd, env, isClone) {
|
|
49
|
-
const declared = env
|
|
50
|
-
// An empty `SMITH_HOME=` is how an unset variable gets spelled by
|
|
51
|
-
// a shell profile or a CI matrix; resolved literally it would
|
|
61
|
+
const declared = readEnv(env, 'BS_HOME')?.trim();
|
|
62
|
+
// An empty `BS_HOME=`/`SMITH_HOME=` is how an unset variable gets spelled by
|
|
63
|
+
// accident in a shell profile or a CI matrix; resolved literally it would
|
|
64
|
+
// mean cwd.
|
|
52
65
|
if (declared)
|
|
53
66
|
return path.resolve(cwd, declared);
|
|
54
67
|
return isClone ? repoRoot : path.join(cwd, WORK_DIR_NAME);
|
|
@@ -1,26 +1,22 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { unreadTaskBudgetFields } from './budgets.js';
|
|
4
5
|
import { claimCoversPath } from './claims.js';
|
|
6
|
+
import { EFFORT_TIERS, isEffortTier } from './effortTiers.js';
|
|
5
7
|
import { SmithError } from './errors.js';
|
|
6
8
|
import { topoSort } from './graph.js';
|
|
7
9
|
import { SPECS_ACTIVE_DIR } from './paths.js';
|
|
8
10
|
import { compileSchemas, validateRecord, } from './schemas.js';
|
|
11
|
+
import { epicOfTaskId } from './taskId.js';
|
|
9
12
|
import { loadTaxonomy, TaxonomyError, validateRequiredDimensions, } from './taxonomy.js';
|
|
10
13
|
export class PlanError extends SmithError {
|
|
11
14
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* would then have to import it to validate a plan and effort.ts already
|
|
18
|
-
* (transitively) imports plan.ts.
|
|
19
|
-
*/
|
|
20
|
-
export const EFFORT_TIERS = ['small', 'medium', 'huge'];
|
|
21
|
-
export function isEffortTier(value) {
|
|
22
|
-
return typeof value === 'string' && EFFORT_TIERS.includes(value);
|
|
23
|
-
}
|
|
15
|
+
// The closed list of effort tier names, re-exported beside the
|
|
16
|
+
// `PlanFile.effort` field it types. It lives in the leaf module
|
|
17
|
+
// effortTiers.ts because budgets.ts sizes its caps by it too, and plan.ts
|
|
18
|
+
// already imports budgets.ts.
|
|
19
|
+
export { EFFORT_TIERS, isEffortTier };
|
|
24
20
|
/**
|
|
25
21
|
* Pseudo task-id ref for a PLAN-level (not task-level) event: planQuorum.ts's
|
|
26
22
|
* runPlanQuorum() stamps it onto the dispatch_decision/judge-verdict/
|
|
@@ -103,6 +99,24 @@ function planFilePath(epicId, version, opts) {
|
|
|
103
99
|
const dir = opts.specsDir ?? SPECS_ACTIVE_DIR;
|
|
104
100
|
return path.join(dir, epicId, `plan-v${version}.json`);
|
|
105
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The specs dir implied by a plan file's own location on disk -- the inverse
|
|
104
|
+
* of `planFilePath`: `<specsDir>/<epicId>/plan-vN.json` names `<specsDir>` as
|
|
105
|
+
* its grandparent, when the plan sits in that shape at all. `null` when it
|
|
106
|
+
* does not (a flat fixture, or a directory that merely happens to share the
|
|
107
|
+
* epic's version number), because a plan filed anywhere else names no specs
|
|
108
|
+
* dir a caller should guess at.
|
|
109
|
+
*
|
|
110
|
+
* `cli.ts`'s `plan amend` handler is the only caller (#219): unlike every
|
|
111
|
+
* other reader of `--specs-dir`, amend WRITES the version it derives a path
|
|
112
|
+
* for, so falling back to `SPECS_ACTIVE_DIR` when the plan being amended
|
|
113
|
+
* already lives somewhere else plants the new version in the wrong tree
|
|
114
|
+
* entirely -- beside no other version of the plan it just amended.
|
|
115
|
+
*/
|
|
116
|
+
export function impliedSpecsDir(planPath, epicId) {
|
|
117
|
+
const dir = path.dirname(path.resolve(planPath));
|
|
118
|
+
return path.basename(dir) === epicId ? path.dirname(dir) : null;
|
|
119
|
+
}
|
|
106
120
|
const PLAN_FILE_PATTERN = /^plan-v(\d+)\.json$/;
|
|
107
121
|
/**
|
|
108
122
|
* The highest plan version written for an epic, or null when the epic has no
|
|
@@ -133,6 +147,122 @@ export function loadPlan(epicId, version, opts = {}) {
|
|
|
133
147
|
}
|
|
134
148
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
135
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* A task ref's project, read off its own epic's latest plan version -- the
|
|
152
|
+
* D-246 precedent (`db/projector.ts`'s `planProjectResolver`, for the board)
|
|
153
|
+
* restated here so `errorIssues.ts` can answer the same question without
|
|
154
|
+
* importing anything database-shaped (`test/cliBoot.test.ts` pins `cli.ts`'s
|
|
155
|
+
* built module graph off drizzle/sqlite entirely, and `cli.ts` statically
|
|
156
|
+
* imports the error-issue reporter).
|
|
157
|
+
*
|
|
158
|
+
* Never throws and never guesses: an id with no epic segment, an epic with
|
|
159
|
+
* no plan on disk, and an unreadable or unparseable plan file all answer
|
|
160
|
+
* `null` alike, on purpose -- "cannot tell" and "the plan has no project" are
|
|
161
|
+
* the same non-answer to a caller that must never turn either one into this
|
|
162
|
+
* factory's own name. Answers cache by epic id for the resolver's lifetime,
|
|
163
|
+
* since one `issues report` run reads the same epic's plan for every row
|
|
164
|
+
* that epic contributed.
|
|
165
|
+
*/
|
|
166
|
+
export function planProjectResolverForTaskRef(opts = {}) {
|
|
167
|
+
const cache = new Map();
|
|
168
|
+
return (taskRef) => {
|
|
169
|
+
const epicId = epicOfTaskId(taskRef);
|
|
170
|
+
if (epicId === null)
|
|
171
|
+
return null;
|
|
172
|
+
const cached = cache.get(epicId);
|
|
173
|
+
if (cached !== undefined)
|
|
174
|
+
return cached;
|
|
175
|
+
let project = null;
|
|
176
|
+
try {
|
|
177
|
+
const version = latestPlanVersion(epicId, opts);
|
|
178
|
+
if (version !== null)
|
|
179
|
+
project = loadPlan(epicId, version, opts).project ?? null;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Unreadable or unparseable plan file: no answer, not a crash.
|
|
183
|
+
}
|
|
184
|
+
cache.set(epicId, project);
|
|
185
|
+
return project;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Whether `opts.specsDir` names THIS checkout's own specs/active tree --
|
|
190
|
+
* absent (so `planFilePath`/`latestPlanVersion` fall back to
|
|
191
|
+
* `SPECS_ACTIVE_DIR` themselves) or an explicit path that resolves to the
|
|
192
|
+
* same directory. Only then is a plan found there, naming no project,
|
|
193
|
+
* evidence of "self": `--specs-dir` is also how a caller points this
|
|
194
|
+
* resolver at a DIFFERENT checkout's specs tree entirely -- `issueInputs`'s
|
|
195
|
+
* own doc comment names "the daemon's own event log and a target checkout's
|
|
196
|
+
* specs tree" as an intended use -- and a plan found THERE naming no project
|
|
197
|
+
* is that other project's own silence, not this factory's (S2-b).
|
|
198
|
+
*/
|
|
199
|
+
function isSelfSpecsDir(opts) {
|
|
200
|
+
return opts.specsDir === undefined || path.resolve(opts.specsDir) === SPECS_ACTIVE_DIR;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The self-fallback half of `planProjectResolverForTaskRefOrSelf`, on its
|
|
204
|
+
* own: a plan found on disk for the epic that simply declares no `project`
|
|
205
|
+
* field IS this factory's own project, by construction -- only this
|
|
206
|
+
* checkout's own epics have a plan under `specs/active` at all, so finding
|
|
207
|
+
* one there is itself the "self" signal a foreign epic's plan (which lives
|
|
208
|
+
* in the foreign project's own specs tree, never this one) can never
|
|
209
|
+
* produce. An epic with no plan on disk anywhere, or a bare ref with no
|
|
210
|
+
* epic segment, answers `null` -- "cannot tell whose project this is" --
|
|
211
|
+
* because plan.ts must not be the module that hardcodes this factory's
|
|
212
|
+
* name; the caller supplies it.
|
|
213
|
+
*
|
|
214
|
+
* That "only this checkout's own epics" premise holds only while `opts`
|
|
215
|
+
* actually points at this checkout's own specs/active tree
|
|
216
|
+
* (`isSelfSpecsDir`, S2-b): a caller that redirected `--specs-dir` at some
|
|
217
|
+
* OTHER checkout's specs tree gets no self-fallback at all, `null` only,
|
|
218
|
+
* because a plan found there naming no project is that other checkout's own
|
|
219
|
+
* silence, not this factory's.
|
|
220
|
+
*
|
|
221
|
+
* Exported as its own resolver, separate from `planProjectResolverForTaskRef`
|
|
222
|
+
* (S2-c): production callers (`cli.ts`'s `issueInputs`) hand the two to
|
|
223
|
+
* `withSessionFallback`/`foldErrorEvents` as DISTINCT tiers -- the strict
|
|
224
|
+
* resolver first, then a session's own unanimous stamp, and only then this
|
|
225
|
+
* guess -- so a session-mate's real stamp naming a different project
|
|
226
|
+
* outranks this weakest signal rather than losing to it, which is what
|
|
227
|
+
* happened when the two were folded into one resolver and tried together as
|
|
228
|
+
* a single first tier.
|
|
229
|
+
*/
|
|
230
|
+
export function planSelfFallbackForTaskRef(selfProject, opts = {}) {
|
|
231
|
+
const selfEligible = isSelfSpecsDir(opts);
|
|
232
|
+
const hasPlanCache = new Map();
|
|
233
|
+
return (taskRef) => {
|
|
234
|
+
if (!selfEligible)
|
|
235
|
+
return null;
|
|
236
|
+
const epicId = epicOfTaskId(taskRef);
|
|
237
|
+
if (epicId === null)
|
|
238
|
+
return null;
|
|
239
|
+
let hasPlan = hasPlanCache.get(epicId);
|
|
240
|
+
if (hasPlan === undefined) {
|
|
241
|
+
hasPlan = latestPlanVersion(epicId, opts) !== null;
|
|
242
|
+
hasPlanCache.set(epicId, hasPlan);
|
|
243
|
+
}
|
|
244
|
+
return hasPlan ? selfProject : null;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* `planProjectResolverForTaskRef` composed with `planSelfFallbackForTaskRef`
|
|
249
|
+
* as a single resolver, direct answer first and self-fallback second, for
|
|
250
|
+
* callers that want the OLD combined behavior (both tiers tried back to
|
|
251
|
+
* back, with nothing -- such as a session stamp -- able to sit between
|
|
252
|
+
* them). Kept for callers that do not need the S2-c tier split; `cli.ts`'s
|
|
253
|
+
* `issueInputs` no longer uses this, it hands the two halves to
|
|
254
|
+
* `withSessionFallback` separately instead.
|
|
255
|
+
*/
|
|
256
|
+
export function planProjectResolverForTaskRefOrSelf(selfProject, opts = {}) {
|
|
257
|
+
const resolve = planProjectResolverForTaskRef(opts);
|
|
258
|
+
const selfFallback = planSelfFallbackForTaskRef(selfProject, opts);
|
|
259
|
+
return (taskRef) => {
|
|
260
|
+
const direct = resolve(taskRef);
|
|
261
|
+
if (direct !== null)
|
|
262
|
+
return direct;
|
|
263
|
+
return selfFallback(taskRef);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
136
266
|
/**
|
|
137
267
|
* Reduce an `output_schema_ref` to the name the compiled schema set knows it
|
|
138
268
|
* by. `compileSchemas` keys on filename-minus-`.schema.json`, so a path, a
|
|
@@ -172,6 +302,105 @@ function describeAllowed(err) {
|
|
|
172
302
|
* of what an edge must carry, which could then drift from taxonomy.yml's.
|
|
173
303
|
*/
|
|
174
304
|
const GLOB_CHARS = /[*?[{]/;
|
|
305
|
+
/** Extensions that make a claimed path a UI-visible surface (U2 D3). */
|
|
306
|
+
const UI_EXTENSION_RE = /\.(tsx|jsx|vue|svelte|css|scss|sass|less|html)$/i;
|
|
307
|
+
/**
|
|
308
|
+
* Whether a task spec is flagged as changing what a user sees. An absent
|
|
309
|
+
* `ui_affecting` means false at the gate (U2 D3) — work already running must
|
|
310
|
+
* not be blocked by a flag it predates.
|
|
311
|
+
*/
|
|
312
|
+
export function isUiAffecting(spec) {
|
|
313
|
+
return spec.ui_affecting === true;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* `plan.ui-flag-missing`: a task that claims a UI-extension path must say,
|
|
317
|
+
* one way or the other, whether it is UI-affecting. An absent flag reads as
|
|
318
|
+
* false everywhere else in the factory (U2 D3), but silently reading false
|
|
319
|
+
* here would let a visibly UI-shaped task skip the uiux judge turn by
|
|
320
|
+
* omission rather than by a reviewed `ui_affecting: false`. Only the
|
|
321
|
+
* absence of the key is an error; an explicit `false` passes.
|
|
322
|
+
*/
|
|
323
|
+
function uiFlagMissing(t) {
|
|
324
|
+
if (t.ui_affecting !== undefined)
|
|
325
|
+
return [];
|
|
326
|
+
const claims = Array.isArray(t.claims)
|
|
327
|
+
? t.claims.filter((c) => typeof c === 'string')
|
|
328
|
+
: [];
|
|
329
|
+
const firstMatch = claims.find((c) => UI_EXTENSION_RE.test(c));
|
|
330
|
+
if (firstMatch === undefined)
|
|
331
|
+
return [];
|
|
332
|
+
return [
|
|
333
|
+
{
|
|
334
|
+
path: '/ui_affecting',
|
|
335
|
+
message: `Task "${t.task_id}" claims UI-extension path "${firstMatch}" but has no ui_affecting flag (plan.ui-flag-missing). Set ui_affecting: true or false explicitly.`,
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Order-insensitive structural equality for the plain-JSON-shaped values a
|
|
341
|
+
* task record is made of (strings, numbers, booleans, null, arrays, plain
|
|
342
|
+
* objects). Arrays compare positionally — `claims` order is meaningful — and
|
|
343
|
+
* objects compare by key set and value, regardless of key order, since a
|
|
344
|
+
* record rebuilt via `{ ...t, ... }` and one authored by hand may list the
|
|
345
|
+
* same fields in different orders without differing in content.
|
|
346
|
+
*/
|
|
347
|
+
function deepEqual(a, b) {
|
|
348
|
+
if (a === b)
|
|
349
|
+
return true;
|
|
350
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
351
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
352
|
+
return false;
|
|
353
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
354
|
+
}
|
|
355
|
+
if (isPlainObject(a) || isPlainObject(b)) {
|
|
356
|
+
if (!isPlainObject(a) || !isPlainObject(b))
|
|
357
|
+
return false;
|
|
358
|
+
const aKeys = Object.keys(a);
|
|
359
|
+
const bKeys = Object.keys(b);
|
|
360
|
+
if (aKeys.length !== bKeys.length)
|
|
361
|
+
return false;
|
|
362
|
+
return aKeys.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Whether `t` is exempt from `plan.ui-flag-missing` because the previous
|
|
368
|
+
* plan version already carried this exact task_id with no `ui_affecting` key,
|
|
369
|
+
* as a verbatim carry-forward. The operator's binding decision (S2 fix round
|
|
370
|
+
* 2): work already in flight must not be blocked by a flag that predates it,
|
|
371
|
+
* so a task `draftNextVersion` carries forward unchanged is grandfathered
|
|
372
|
+
* rather than stamped `ui_affecting: false` — that would claim a decision
|
|
373
|
+
* nobody made.
|
|
374
|
+
*
|
|
375
|
+
* "Unchanged" means the whole record, not just `claims`: `draftNextVersion`
|
|
376
|
+
* rewrites only `plan_version` (every carried task) and `task_status` (the
|
|
377
|
+
* dead `superseded` record a supersede leaves behind) — so a record that
|
|
378
|
+
* matches the previous one on every other field is something the plan editor
|
|
379
|
+
* itself carried forward, never content a human or a supersede replacement
|
|
380
|
+
* wrote. A supersede replacement that keeps the old task_id (D-121) writes
|
|
381
|
+
* new content under that id — a changed description or deps included, even
|
|
382
|
+
* when the claims happen to match (S1 fix round 3) — so it fails this
|
|
383
|
+
* comparison and still needs the flag.
|
|
384
|
+
*
|
|
385
|
+
* A task_id can hold several rows (dead `superseded` records plus the live
|
|
386
|
+
* one), so a row is exempt when ANY previous row of that id matches it. A
|
|
387
|
+
* supersede replacement that restores the exact content of an existing row
|
|
388
|
+
* is therefore exempt too: that content already stood unflagged.
|
|
389
|
+
*
|
|
390
|
+
* `added` tasks and v1 tasks have no previous record to match, so they are
|
|
391
|
+
* never exempt.
|
|
392
|
+
*/
|
|
393
|
+
function isGrandfatheredUiFlag(t, previous) {
|
|
394
|
+
if (previous === undefined)
|
|
395
|
+
return false;
|
|
396
|
+
const { plan_version: _pv2, task_status: _ts2, ...rest } = t;
|
|
397
|
+
return previous.tasks.some((prevTask) => {
|
|
398
|
+
if (prevTask.task_id !== t.task_id || prevTask.ui_affecting !== undefined)
|
|
399
|
+
return false;
|
|
400
|
+
const { plan_version: _pv1, task_status: _ts1, ...prevRest } = prevTask;
|
|
401
|
+
return deepEqual(prevRest, rest);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
175
404
|
/**
|
|
176
405
|
* A `keeps_exports` promise is a file the task swears to keep the exports of,
|
|
177
406
|
* and the post-run verifier reads it back against the task's diff. Three
|
|
@@ -256,6 +485,11 @@ export function validatePlan(plan, opts = {}) {
|
|
|
256
485
|
for (const issue of unkeptPromises(t, result.valid ? [] : result.errors)) {
|
|
257
486
|
errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
|
|
258
487
|
}
|
|
488
|
+
if (!isGrandfatheredUiFlag(t, opts.previous)) {
|
|
489
|
+
for (const issue of uiFlagMissing(t)) {
|
|
490
|
+
errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
259
493
|
if (t.plan_version !== plan.version) {
|
|
260
494
|
errors.push({
|
|
261
495
|
path: `/tasks/${t.task_id}/plan_version`,
|
|
@@ -268,7 +502,7 @@ export function validatePlan(plan, opts = {}) {
|
|
|
268
502
|
for (const field of unreadTaskBudgetFields(budget)) {
|
|
269
503
|
errors.push({
|
|
270
504
|
path: `/tasks/${t.task_id}/budget/${field}`,
|
|
271
|
-
message: `Budget field "${field}" has no mechanical reader: nothing in the factory can enforce it per task, so declaring it here states a limit the task will never be held to. The turn ceiling is the role template's maxTurns (.claude/agents
|
|
505
|
+
message: `Budget field "${field}" has no mechanical reader: nothing in the factory can enforce it per task, so declaring it here states a limit the task will never be held to. The turn ceiling is the role template's maxTurns (.claude/agents/bs-<role>.md), enforced by the harness per role; the dispatch prompt restates that number (agent-interviews.md M-4, corrected 2026-09-11).`,
|
|
272
506
|
});
|
|
273
507
|
}
|
|
274
508
|
// D-7: the ref is what result.schema.json defers structured_output's shape
|
|
@@ -408,7 +642,72 @@ function readAddedList(epicId, version, added) {
|
|
|
408
642
|
});
|
|
409
643
|
return added;
|
|
410
644
|
}
|
|
411
|
-
|
|
645
|
+
/**
|
|
646
|
+
* The replacement and added records a draft lands, at most one live record
|
|
647
|
+
* under any id. `liveSpec` reads the last live record under an id, so a second
|
|
648
|
+
* one fails nothing: it quietly wins, and ingest sees one task where the
|
|
649
|
+
* author wrote two.
|
|
650
|
+
*
|
|
651
|
+
* Several supersede keys folding into one task is the legitimate way to name
|
|
652
|
+
* one id twice. Their replacements are identical, so one copy is carried.
|
|
653
|
+
* Anything else that lands a second live record is refused: two keys with
|
|
654
|
+
* different records under one id, or a replacement or added id that a carried
|
|
655
|
+
* task still holds live, that a replacement already declares, or that an
|
|
656
|
+
* earlier added entry declares. Dead records may share an id freely; a task
|
|
657
|
+
* superseded under its own id (D-121) already leaves one of each.
|
|
658
|
+
*/
|
|
659
|
+
function oneLiveRecordPerId(prev, version, carried, supersede, addedList) {
|
|
660
|
+
const refuse = (taskId, why, details) => new PlanError('plan.duplicate-live-task', `Refusing to draft plan "${prev.epic_id}" v${version}: ${why}`, { epicId: prev.epic_id, version, taskId, ...details });
|
|
661
|
+
const ownId = (taskId) => `Give the new task an id of its own, or supersede "${taskId}" in the same amendment.`;
|
|
662
|
+
const liveCarried = new Set(carried.filter((t) => t.task_status !== 'superseded').map((t) => t.task_id));
|
|
663
|
+
const replacements = [];
|
|
664
|
+
const keyOf = new Map();
|
|
665
|
+
for (const [key, t] of Object.entries(supersede)) {
|
|
666
|
+
const record = { ...t, plan_version: version };
|
|
667
|
+
const firstKey = keyOf.get(record.task_id);
|
|
668
|
+
if (firstKey === undefined) {
|
|
669
|
+
if (liveCarried.has(record.task_id)) {
|
|
670
|
+
throw refuse(record.task_id, `changes.supersede["${key}"] replaces into "${record.task_id}", which v${prev.version} still holds live and this amendment does not supersede. ${ownId(record.task_id)}`, { key });
|
|
671
|
+
}
|
|
672
|
+
keyOf.set(record.task_id, key);
|
|
673
|
+
replacements.push(record);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const first = replacements.find((r) => r.task_id === record.task_id);
|
|
677
|
+
if (!isDeepStrictEqual(first, record)) {
|
|
678
|
+
throw refuse(record.task_id, `changes.supersede["${firstKey}"] and changes.supersede["${key}"] both replace into "${record.task_id}" with different records. Keys fold into one task only when they carry the same replacement.`, { keys: [firstKey, key] });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const added = [];
|
|
682
|
+
const addedAt = new Map();
|
|
683
|
+
addedList.forEach((t, index) => {
|
|
684
|
+
const where = liveCarried.has(t.task_id)
|
|
685
|
+
? `v${prev.version} still holds it live. ${ownId(t.task_id)}`
|
|
686
|
+
: keyOf.has(t.task_id)
|
|
687
|
+
? `changes.supersede["${keyOf.get(t.task_id)}"] already replaces into it. Give the new task an id of its own.`
|
|
688
|
+
: addedAt.has(t.task_id)
|
|
689
|
+
? `changes.added[${addedAt.get(t.task_id)}] already declares it. Give the new task an id of its own.`
|
|
690
|
+
: undefined;
|
|
691
|
+
if (where !== undefined) {
|
|
692
|
+
throw refuse(t.task_id, `changes.added[${index}] declares "${t.task_id}", and ${where}`, {
|
|
693
|
+
index,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
addedAt.set(t.task_id, index);
|
|
697
|
+
added.push({ ...t, plan_version: version });
|
|
698
|
+
});
|
|
699
|
+
return { replacements, added };
|
|
700
|
+
}
|
|
701
|
+
export function draftNextVersion(prev, changes,
|
|
702
|
+
/**
|
|
703
|
+
* Dead records for tasks the plan never listed — ones only the event log
|
|
704
|
+
* added — that `changes.supersede` retires. Built by the caller from the
|
|
705
|
+
* log (`supersededStubFromLog`), because this function does no I/O. Each
|
|
706
|
+
* must already be `superseded` at the new version; they are carried beside
|
|
707
|
+
* the plan's own records so ingest has something to emit
|
|
708
|
+
* `task-superseded` from.
|
|
709
|
+
*/
|
|
710
|
+
loggedStubs = []) {
|
|
412
711
|
const newVersion = prev.version + 1;
|
|
413
712
|
const supersede = readSupersedeMap(prev.epic_id, newVersion, changes.supersede);
|
|
414
713
|
const carried = [];
|
|
@@ -422,11 +721,8 @@ export function draftNextVersion(prev, changes) {
|
|
|
422
721
|
}
|
|
423
722
|
carried.push({ ...t, plan_version: newVersion });
|
|
424
723
|
}
|
|
425
|
-
|
|
426
|
-
const added = readAddedList(prev.epic_id, newVersion, changes.added)
|
|
427
|
-
...t,
|
|
428
|
-
plan_version: newVersion,
|
|
429
|
-
}));
|
|
724
|
+
carried.push(...loggedStubs);
|
|
725
|
+
const { replacements, added } = oneLiveRecordPerId(prev, newVersion, carried, supersede, readAddedList(prev.epic_id, newVersion, changes.added));
|
|
430
726
|
const tasks = [...carried, ...replacements, ...added];
|
|
431
727
|
// An edge may only name a task this version declares -- `validatePlan`'s own
|
|
432
728
|
// rule. Dropping a completed task from the live backlog is what breaks it:
|
|
@@ -435,21 +731,69 @@ export function draftNextVersion(prev, changes) {
|
|
|
435
731
|
// no longer mentions. Plans are immutable and nothing deletes one, so that
|
|
436
732
|
// left no way out.
|
|
437
733
|
//
|
|
438
|
-
// Dropping the edge loses nothing: the only ids that leave are
|
|
439
|
-
// not superseded, so the ordering constraint is already
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
734
|
+
// Dropping the edge loses nothing: the only ids that leave outright are
|
|
735
|
+
// completed and not superseded, so the ordering constraint is already
|
|
736
|
+
// discharged. Applied to the CARRIED edges only -- an edge this amendment
|
|
737
|
+
// adds is something the author wrote, and deleting it silently would answer
|
|
738
|
+
// a typo with a plan that no longer says what they asked for. `validatePlan`
|
|
739
|
+
// names that one instead.
|
|
740
|
+
//
|
|
741
|
+
// A superseded task is carried forward too (as `superseded`), which used to
|
|
742
|
+
// read as "so it keeps its edges" -- true only when the replacement keeps
|
|
743
|
+
// the old task_id (D-121's usual case; the id maps to itself below). A
|
|
744
|
+
// supersede that changes the id moves the *work* to the new id, but the old
|
|
745
|
+
// one stays in `tasks` only as a dead record: `livePlanTasks` (and so
|
|
746
|
+
// `computeNextWave`'s done set, waveNext.ts) drops it, so it can never
|
|
747
|
+
// become terminal. An edge still naming it would defer its dependent
|
|
748
|
+
// forever -- `dependency-pending` on an id nothing will ever finish. Moving
|
|
749
|
+
// both ends of a carried edge through the old-id -> replacement-id map
|
|
750
|
+
// before the declared-check is what keeps the dependency resolvable under
|
|
751
|
+
// its new name instead of quietly wedging it (#234).
|
|
445
752
|
const declared = new Set(tasks.map((t) => t.task_id));
|
|
446
|
-
const
|
|
753
|
+
const rewireTo = new Map();
|
|
754
|
+
for (const [oldId, replacement] of Object.entries(supersede)) {
|
|
755
|
+
if (replacement.task_id !== oldId)
|
|
756
|
+
rewireTo.set(oldId, replacement.task_id);
|
|
757
|
+
}
|
|
758
|
+
const rewire = (id) => rewireTo.get(id) ?? id;
|
|
759
|
+
// taskEvents.ts's `edgeKey` (see its doc comment at edgesAlreadyRecorded)
|
|
760
|
+
// treats task+dependsOn+edge_type as the triple that identifies an edge: an
|
|
761
|
+
// `artifact` handoff and a `claim-order` handoff between the same two tasks
|
|
762
|
+
// are two different claims and both belong in the log as two events. Not
|
|
763
|
+
// reused directly -- taskEvents.ts imports plan.ts, so importing back would
|
|
764
|
+
// make a cycle -- but the dedup below has to match its shape or it silently
|
|
765
|
+
// merges two edges that the rest of the system has always kept distinct.
|
|
766
|
+
const edgeKey = (task, dependsOn, edgeType) => `${task}\u0000${dependsOn}\u0000${edgeType}`;
|
|
767
|
+
const newEdges = changes.newEdges ?? [];
|
|
768
|
+
const newEdgeKeys = new Set(newEdges.map((e) => edgeKey(e.task, e.dependsOn, e.edge_type)));
|
|
769
|
+
const seenCarried = new Set();
|
|
770
|
+
const carriedEdges = [];
|
|
771
|
+
for (const e of prev.edges) {
|
|
772
|
+
const rewiredTask = rewire(e.task);
|
|
773
|
+
const rewiredDependsOn = rewire(e.dependsOn);
|
|
774
|
+
if (!declared.has(rewiredTask) || !declared.has(rewiredDependsOn))
|
|
775
|
+
continue;
|
|
776
|
+
if (rewiredTask === rewiredDependsOn)
|
|
777
|
+
continue; // rewiring both ends onto the same replacement is not a dependency
|
|
778
|
+
const key = edgeKey(rewiredTask, rewiredDependsOn, e.edge_type);
|
|
779
|
+
// An edge this amendment adds wins over a carried edge rewired onto the
|
|
780
|
+
// same (task, dependsOn, edge_type) triple, and a rewire that collapses
|
|
781
|
+
// two carried edges onto the same triple keeps only the first -- either
|
|
782
|
+
// way, no duplicate. Two edges on the same pair with different edge_type
|
|
783
|
+
// are not a duplicate at all (edgeKey's triple, not just the pair).
|
|
784
|
+
if (newEdgeKeys.has(key) || seenCarried.has(key))
|
|
785
|
+
continue;
|
|
786
|
+
seenCarried.add(key);
|
|
787
|
+
carriedEdges.push(rewiredTask === e.task && rewiredDependsOn === e.dependsOn
|
|
788
|
+
? e
|
|
789
|
+
: { ...e, task: rewiredTask, dependsOn: rewiredDependsOn });
|
|
790
|
+
}
|
|
447
791
|
return {
|
|
448
792
|
epic_id: prev.epic_id,
|
|
449
793
|
version: newVersion,
|
|
450
794
|
status: 'active',
|
|
451
795
|
tasks,
|
|
452
|
-
edges: [...carriedEdges, ...
|
|
796
|
+
edges: [...carriedEdges, ...newEdges],
|
|
453
797
|
...(prev.project ? { project: prev.project } : {}),
|
|
454
798
|
...(prev.effort ? { effort: prev.effort } : {}),
|
|
455
799
|
};
|
|
@@ -464,8 +808,8 @@ export function draftNextVersion(prev, changes) {
|
|
|
464
808
|
* is not: a caller that could write half of a version could write a version
|
|
465
809
|
* no event explains.
|
|
466
810
|
*/
|
|
467
|
-
export function nextVersion(prev, changes, opts = {}) {
|
|
468
|
-
const newPlan = draftNextVersion(prev, changes);
|
|
811
|
+
export function nextVersion(prev, changes, opts = {}, loggedStubs = []) {
|
|
812
|
+
const newPlan = draftNextVersion(prev, changes, loggedStubs);
|
|
469
813
|
writePlanFile(newPlan, opts);
|
|
470
814
|
return newPlan;
|
|
471
815
|
}
|
|
@@ -479,7 +823,7 @@ export function nextVersion(prev, changes, opts = {}) {
|
|
|
479
823
|
* dead copies are set aside. Indexing the whole task list into a Map by id
|
|
480
824
|
* silently answers with whichever record happened to be written last.
|
|
481
825
|
*/
|
|
482
|
-
function liveSpec(plan, id) {
|
|
826
|
+
export function liveSpec(plan, id) {
|
|
483
827
|
let found;
|
|
484
828
|
for (const t of plan.tasks) {
|
|
485
829
|
if (t.task_id === id && t.task_status !== 'superseded')
|
|
@@ -509,6 +853,19 @@ export function livePlanTasks(plan) {
|
|
|
509
853
|
}
|
|
510
854
|
return live;
|
|
511
855
|
}
|
|
856
|
+
/**
|
|
857
|
+
* The plan's tasks in the shape `resolveFindingOwner` reads: only the live
|
|
858
|
+
* (D-126) ones, and only the `claims` field ownership resolution needs. This
|
|
859
|
+
* is `livePlanTasks` restated as claims rather than full specs, so a caller
|
|
860
|
+
* asking "does some task in this plan still claim that file?" (`audit
|
|
861
|
+
* resolve`) gets the same live/superseded rule `epic verdict` does, not the
|
|
862
|
+
* raw `plan.tasks` array that also holds every amendment left behind.
|
|
863
|
+
*/
|
|
864
|
+
export function planClaimedTasks(plan) {
|
|
865
|
+
return livePlanTasks(plan)
|
|
866
|
+
.filter((task) => Array.isArray(task.claims))
|
|
867
|
+
.map((task) => ({ task_id: task.task_id, claims: task.claims }));
|
|
868
|
+
}
|
|
512
869
|
/**
|
|
513
870
|
* What makes two records the same *spec*: everything except the version stamp
|
|
514
871
|
* and the workflow status. `nextVersion` rewrites `plan_version` on every
|
|
@@ -536,6 +893,12 @@ function specSignature(t) {
|
|
|
536
893
|
* keyed by task_id and a replacement that keeps its id — the shape every real
|
|
537
894
|
* amendment has used — leaves the plan holding a dead record and a live one
|
|
538
895
|
* under the same key.
|
|
896
|
+
*
|
|
897
|
+
* An id new to B is `added` only if B holds it live. One that arrives already
|
|
898
|
+
* dead is a task B retired without A ever recording it — a task only the event
|
|
899
|
+
* log added, which `draftNextVersion` carries as a superseded stub — so it is
|
|
900
|
+
* `superseded`, after the ids A knew. Reading it as added would make a dead
|
|
901
|
+
* record look like new work to land.
|
|
539
902
|
*/
|
|
540
903
|
export function diffPlans(vA, vB) {
|
|
541
904
|
const aIds = new Set(vA.tasks.map((t) => t.task_id));
|
|
@@ -544,9 +907,11 @@ export function diffPlans(vA, vB) {
|
|
|
544
907
|
const superseded = [];
|
|
545
908
|
const carried = [];
|
|
546
909
|
const removed = [];
|
|
910
|
+
const deadOnArrival = [];
|
|
547
911
|
for (const id of bIds) {
|
|
548
|
-
if (
|
|
549
|
-
|
|
912
|
+
if (aIds.has(id))
|
|
913
|
+
continue;
|
|
914
|
+
(liveSpec(vB, id) === undefined ? deadOnArrival : added).push(id);
|
|
550
915
|
}
|
|
551
916
|
for (const id of aIds) {
|
|
552
917
|
if (!bIds.has(id)) {
|
|
@@ -562,5 +927,6 @@ export function diffPlans(vA, vB) {
|
|
|
562
927
|
: liveB !== undefined && specSignature(liveA) === specSignature(liveB);
|
|
563
928
|
(same ? carried : superseded).push(id);
|
|
564
929
|
}
|
|
930
|
+
superseded.push(...deadOnArrival);
|
|
565
931
|
return { added, removed, superseded, carried };
|
|
566
932
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { loadBudgetPolicy } from './budgets.js';
|
|
2
1
|
import { loadCrosscheckPolicy, } from './crosscheck.js';
|
|
2
|
+
import { budgetPolicyForPlan } from './epicBudget.js';
|
|
3
3
|
import { appendEvent } from './events.js';
|
|
4
4
|
import { livePlanTasks, loadPlan, PlanError, planRefTaskId, } from './plan.js';
|
|
5
5
|
import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
|
|
@@ -329,7 +329,10 @@ export async function runPlanQuorum(input, ctx, opts = {}) {
|
|
|
329
329
|
});
|
|
330
330
|
}
|
|
331
331
|
const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
|
|
332
|
-
|
|
332
|
+
// The cap for the tier this plan runs at: its own `effort` after the
|
|
333
|
+
// security floor (default_tier when it names none).
|
|
334
|
+
const epicCapTokens = input.epicCapTokens ??
|
|
335
|
+
budgetPolicyForPlan(plan, { securityPolicy: policy.planQuorum }).epic.capTokens;
|
|
333
336
|
const triggers = evaluatePlanQuorumTriggers(plan, policy.planQuorum, epicCapTokens, {
|
|
334
337
|
plannerConfidence: input.plannerConfidence,
|
|
335
338
|
});
|