@juzser/blacksmith 0.4.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} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- 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} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -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} +4 -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 +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- 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 +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- 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 +109 -53
- 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 +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- 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 +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- 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 +13 -37
- 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 +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -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/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 +4 -4
- 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/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- 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 +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- 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 +7 -7
- 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,7 +1,9 @@
|
|
|
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';
|
|
@@ -10,18 +12,11 @@ import { epicOfTaskId } from './taskId.js';
|
|
|
10
12
|
import { loadTaxonomy, TaxonomyError, validateRequiredDimensions, } from './taxonomy.js';
|
|
11
13
|
export class PlanError extends SmithError {
|
|
12
14
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* would then have to import it to validate a plan and effort.ts already
|
|
19
|
-
* (transitively) imports plan.ts.
|
|
20
|
-
*/
|
|
21
|
-
export const EFFORT_TIERS = ['small', 'medium', 'huge'];
|
|
22
|
-
export function isEffortTier(value) {
|
|
23
|
-
return typeof value === 'string' && EFFORT_TIERS.includes(value);
|
|
24
|
-
}
|
|
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 };
|
|
25
20
|
/**
|
|
26
21
|
* Pseudo task-id ref for a PLAN-level (not task-level) event: planQuorum.ts's
|
|
27
22
|
* runPlanQuorum() stamps it onto the dispatch_decision/judge-verdict/
|
|
@@ -104,6 +99,24 @@ function planFilePath(epicId, version, opts) {
|
|
|
104
99
|
const dir = opts.specsDir ?? SPECS_ACTIVE_DIR;
|
|
105
100
|
return path.join(dir, epicId, `plan-v${version}.json`);
|
|
106
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
|
+
}
|
|
107
120
|
const PLAN_FILE_PATTERN = /^plan-v(\d+)\.json$/;
|
|
108
121
|
/**
|
|
109
122
|
* The highest plan version written for an epic, or null when the epic has no
|
|
@@ -289,6 +302,105 @@ function describeAllowed(err) {
|
|
|
289
302
|
* of what an edge must carry, which could then drift from taxonomy.yml's.
|
|
290
303
|
*/
|
|
291
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
|
+
}
|
|
292
404
|
/**
|
|
293
405
|
* A `keeps_exports` promise is a file the task swears to keep the exports of,
|
|
294
406
|
* and the post-run verifier reads it back against the task's diff. Three
|
|
@@ -373,6 +485,11 @@ export function validatePlan(plan, opts = {}) {
|
|
|
373
485
|
for (const issue of unkeptPromises(t, result.valid ? [] : result.errors)) {
|
|
374
486
|
errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
|
|
375
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
|
+
}
|
|
376
493
|
if (t.plan_version !== plan.version) {
|
|
377
494
|
errors.push({
|
|
378
495
|
path: `/tasks/${t.task_id}/plan_version`,
|
|
@@ -385,7 +502,7 @@ export function validatePlan(plan, opts = {}) {
|
|
|
385
502
|
for (const field of unreadTaskBudgetFields(budget)) {
|
|
386
503
|
errors.push({
|
|
387
504
|
path: `/tasks/${t.task_id}/budget/${field}`,
|
|
388
|
-
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).`,
|
|
389
506
|
});
|
|
390
507
|
}
|
|
391
508
|
// D-7: the ref is what result.schema.json defers structured_output's shape
|
|
@@ -525,7 +642,72 @@ function readAddedList(epicId, version, added) {
|
|
|
525
642
|
});
|
|
526
643
|
return added;
|
|
527
644
|
}
|
|
528
|
-
|
|
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 = []) {
|
|
529
711
|
const newVersion = prev.version + 1;
|
|
530
712
|
const supersede = readSupersedeMap(prev.epic_id, newVersion, changes.supersede);
|
|
531
713
|
const carried = [];
|
|
@@ -539,11 +721,8 @@ export function draftNextVersion(prev, changes) {
|
|
|
539
721
|
}
|
|
540
722
|
carried.push({ ...t, plan_version: newVersion });
|
|
541
723
|
}
|
|
542
|
-
|
|
543
|
-
const added = readAddedList(prev.epic_id, newVersion, changes.added)
|
|
544
|
-
...t,
|
|
545
|
-
plan_version: newVersion,
|
|
546
|
-
}));
|
|
724
|
+
carried.push(...loggedStubs);
|
|
725
|
+
const { replacements, added } = oneLiveRecordPerId(prev, newVersion, carried, supersede, readAddedList(prev.epic_id, newVersion, changes.added));
|
|
547
726
|
const tasks = [...carried, ...replacements, ...added];
|
|
548
727
|
// An edge may only name a task this version declares -- `validatePlan`'s own
|
|
549
728
|
// rule. Dropping a completed task from the live backlog is what breaks it:
|
|
@@ -552,21 +731,69 @@ export function draftNextVersion(prev, changes) {
|
|
|
552
731
|
// no longer mentions. Plans are immutable and nothing deletes one, so that
|
|
553
732
|
// left no way out.
|
|
554
733
|
//
|
|
555
|
-
// Dropping the edge loses nothing: the only ids that leave are
|
|
556
|
-
// not superseded, so the ordering constraint is already
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
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).
|
|
562
752
|
const declared = new Set(tasks.map((t) => t.task_id));
|
|
563
|
-
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
|
+
}
|
|
564
791
|
return {
|
|
565
792
|
epic_id: prev.epic_id,
|
|
566
793
|
version: newVersion,
|
|
567
794
|
status: 'active',
|
|
568
795
|
tasks,
|
|
569
|
-
edges: [...carriedEdges, ...
|
|
796
|
+
edges: [...carriedEdges, ...newEdges],
|
|
570
797
|
...(prev.project ? { project: prev.project } : {}),
|
|
571
798
|
...(prev.effort ? { effort: prev.effort } : {}),
|
|
572
799
|
};
|
|
@@ -581,8 +808,8 @@ export function draftNextVersion(prev, changes) {
|
|
|
581
808
|
* is not: a caller that could write half of a version could write a version
|
|
582
809
|
* no event explains.
|
|
583
810
|
*/
|
|
584
|
-
export function nextVersion(prev, changes, opts = {}) {
|
|
585
|
-
const newPlan = draftNextVersion(prev, changes);
|
|
811
|
+
export function nextVersion(prev, changes, opts = {}, loggedStubs = []) {
|
|
812
|
+
const newPlan = draftNextVersion(prev, changes, loggedStubs);
|
|
586
813
|
writePlanFile(newPlan, opts);
|
|
587
814
|
return newPlan;
|
|
588
815
|
}
|
|
@@ -596,7 +823,7 @@ export function nextVersion(prev, changes, opts = {}) {
|
|
|
596
823
|
* dead copies are set aside. Indexing the whole task list into a Map by id
|
|
597
824
|
* silently answers with whichever record happened to be written last.
|
|
598
825
|
*/
|
|
599
|
-
function liveSpec(plan, id) {
|
|
826
|
+
export function liveSpec(plan, id) {
|
|
600
827
|
let found;
|
|
601
828
|
for (const t of plan.tasks) {
|
|
602
829
|
if (t.task_id === id && t.task_status !== 'superseded')
|
|
@@ -666,6 +893,12 @@ function specSignature(t) {
|
|
|
666
893
|
* keyed by task_id and a replacement that keeps its id — the shape every real
|
|
667
894
|
* amendment has used — leaves the plan holding a dead record and a live one
|
|
668
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.
|
|
669
902
|
*/
|
|
670
903
|
export function diffPlans(vA, vB) {
|
|
671
904
|
const aIds = new Set(vA.tasks.map((t) => t.task_id));
|
|
@@ -674,9 +907,11 @@ export function diffPlans(vA, vB) {
|
|
|
674
907
|
const superseded = [];
|
|
675
908
|
const carried = [];
|
|
676
909
|
const removed = [];
|
|
910
|
+
const deadOnArrival = [];
|
|
677
911
|
for (const id of bIds) {
|
|
678
|
-
if (
|
|
679
|
-
|
|
912
|
+
if (aIds.has(id))
|
|
913
|
+
continue;
|
|
914
|
+
(liveSpec(vB, id) === undefined ? deadOnArrival : added).push(id);
|
|
680
915
|
}
|
|
681
916
|
for (const id of aIds) {
|
|
682
917
|
if (!bIds.has(id)) {
|
|
@@ -692,5 +927,6 @@ export function diffPlans(vA, vB) {
|
|
|
692
927
|
: liveB !== undefined && specSignature(liveA) === specSignature(liveB);
|
|
693
928
|
(same ? carried : superseded).push(id);
|
|
694
929
|
}
|
|
930
|
+
superseded.push(...deadOnArrival);
|
|
695
931
|
return { added, removed, superseded, carried };
|
|
696
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
|
});
|