shapeup-sdlc 3.18.2 → 4.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/.claude/settings.local.example.json +2 -3
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +115 -56
- package/README.md +101 -143
- package/SECURITY.md +16 -25
- package/agents/orient.md +14 -0
- package/agents/qa-edge-hunter.md +13 -0
- package/agents/spec-evaluator.md +14 -0
- package/agents/task-executor.md +14 -0
- package/bin/init.mjs +13 -16
- package/bin/lib/grant.mjs +8 -19
- package/commands/build.md +9 -14
- package/commands/eval.md +3 -3
- package/commands/hammer.md +9 -5
- package/commands/retro.md +12 -11
- package/commands/scopes.md +1 -12
- package/commands/ship.md +45 -88
- package/commands/wire.md +1 -8
- package/hooks/dispatch-receipt.mjs +94 -8
- package/hooks/hooks.json +0 -41
- package/hooks/lib/decision.mjs +6 -9
- package/hooks/lib/tier.mjs +50 -0
- package/hooks/sandbox-guard.mjs +140 -64
- package/kernel/gate.mjs +203 -22
- package/kernel/harness.mjs +20 -67
- package/kernel/init/run.mjs +175 -523
- package/kernel/lib/contract.mjs +9 -93
- package/kernel/lib/floor.mjs +64 -0
- package/kernel/lib/paths.mjs +39 -112
- package/kernel/order.mjs +157 -0
- package/kernel/probe/digest.mjs +2 -38
- package/kernel/schemas/domain.schema.json +146 -1125
- package/kernel/schemas/work-order.schema.json +8 -4
- package/kernel/schemas/work-result.schema.json +1 -1
- package/kernel/verify/envelope.mjs +2 -2
- package/kernel/verify/skills.mjs +27 -8
- package/kernel/verify/t0.mjs +2 -2
- package/package.json +4 -2
- package/skills/building/SKILL.md +144 -0
- package/skills/building/resources/gates.md +76 -0
- package/skills/building/resources/guidance.md +23 -0
- package/skills/{translator/references/preservation-rules.md → building/resources/intake-preservation.md} +1 -1
- package/skills/building/resources/intake.md +157 -0
- package/skills/building/resources/orders.md +69 -0
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/doc-schemas.md +1 -1
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/task-generation.md +13 -13
- package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/test-surface.md +6 -7
- package/skills/building/resources/plan-scopes.md +95 -0
- package/skills/building/resources/plan-spec.md +102 -0
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/_index.tmpl.md +1 -1
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/assess-report.tmpl.md +10 -11
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/run-state.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/scope-summary.tmpl.md +2 -2
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/synthesis.tmpl.md +4 -4
- package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/usecase.tmpl.md +2 -2
- package/skills/building/resources/plan-wire.md +82 -0
- package/skills/building/resources/plan.md +98 -0
- package/skills/building/resources/retro-research.md +60 -0
- package/skills/building/resources/retro-scan.md +49 -0
- package/skills/building/resources/retro.md +225 -0
- package/skills/building/resources/rounds.md +78 -0
- package/skills/building/resources/stop.md +127 -0
- package/skills/orient/SKILL.md +37 -37
- package/skills/qa-edge-hunter/SKILL.md +29 -14
- package/skills/shapeup/SKILL.md +5 -5
- package/skills/shapeup/resources/breadboarding.md +1 -1
- package/skills/shapeup/resources/context-compaction.md +5 -5
- package/skills/shapeup/resources/shaping.md +1 -1
- package/skills/spec-evaluator/SKILL.md +17 -21
- package/skills/spec-evaluator/references/dimension-contract.md +1 -1
- package/skills/spec-evaluator/references/dimensions/completeness.md +3 -3
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +1 -1
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +5 -5
- package/skills/task-executor/SKILL.md +11 -15
- package/commands/hill.md +0 -10
- package/hooks/gate-intake.mjs +0 -109
- package/hooks/gate-zerowork.mjs +0 -349
- package/hooks/tier-guard.mjs +0 -162
- package/kernel/compile.mjs +0 -1419
- package/kernel/init/fit.mjs +0 -199
- package/kernel/init/run-args.mjs +0 -206
- package/kernel/lib/breadboard.mjs +0 -165
- package/kernel/probe/attempts.mjs +0 -161
- package/kernel/probe/concurrency.mjs +0 -535
- package/kernel/probe/eval.mjs +0 -405
- package/kernel/probe/hunt.mjs +0 -104
- package/kernel/probe/leg.mjs +0 -229
- package/kernel/probe/owner.mjs +0 -142
- package/kernel/probe/requirements.mjs +0 -328
- package/kernel/probe/resume.mjs +0 -1110
- package/kernel/probe/rounds.mjs +0 -115
- package/kernel/probe/staged.mjs +0 -83
- package/kernel/probe/stats.mjs +0 -477
- package/kernel/probe/t0.mjs +0 -115
- package/kernel/reduce/board.mjs +0 -350
- package/kernel/reduce/graph.mjs +0 -482
- package/kernel/reduce/hill.mjs +0 -322
- package/kernel/reduce/ingest.mjs +0 -767
- package/kernel/reduce/leftovers.mjs +0 -159
- package/kernel/reduce/ship.mjs +0 -529
- package/kernel/reduce/snapshot.mjs +0 -291
- package/kernel/reduce/verdict.mjs +0 -172
- package/kernel/report/export.mjs +0 -384
- package/kernel/report/facts.mjs +0 -234
- package/kernel/verify/budget.mjs +0 -153
- package/kernel/verify/build.mjs +0 -428
- package/kernel/verify/dispatch.mjs +0 -114
- package/kernel/verify/spec.mjs +0 -975
- package/kernel/verify/trace.mjs +0 -604
- package/skills/ba-pitch-analyzer/SKILL.md +0 -204
- package/skills/coach/SKILL.md +0 -397
- package/skills/hill-chart/SKILL.md +0 -154
- package/skills/hill-chart/assets/dashboard.template.html +0 -1432
- package/skills/scope-architect/SKILL.md +0 -174
- package/skills/scope-hammer/SKILL.md +0 -231
- package/skills/solution-architect/SKILL.md +0 -158
- package/skills/tech-lead/SKILL.md +0 -151
- package/skills/tech-lead/references/gates.md +0 -605
- package/skills/tech-lead/references/protocol.md +0 -889
- package/skills/tech-lead/references/tiny-lane.md +0 -53
- package/skills/tech-lead/workflows/shapeup-run.js +0 -2187
- package/skills/translator/SKILL.md +0 -258
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/contract-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ddd-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/integration-analysis.md +0 -0
- /package/skills/{ba-pitch-analyzer/references → building/resources/plan-references}/ux-behavior-patterns.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/api-feasibility.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/be-service.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/offline-storage.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/contracts/third-party-api.contract.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/context-map.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/event-choreography.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/migration-plan.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/cross-context/team-handoff.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/domain-model.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/feedback.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/integration.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-board.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task-spike.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/task.tmpl.md +0 -0
- /package/skills/{ba-pitch-analyzer/assets/templates → building/resources/plan-templates}/ux-behavior.tmpl.md +0 -0
package/kernel/init/run.mjs
CHANGED
|
@@ -1,126 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// harness init run — open a run, or refuse.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// WHAT IT IS FOR. Whether a run has started has to be a fact on disk, written at t=0, because the
|
|
5
|
+
// other evidence (artifacts that appear later) cannot tell a run that never started from one that
|
|
6
|
+
// described itself and stopped. Opening a run writes four things and stages nothing in the project:
|
|
5
7
|
//
|
|
6
|
-
//
|
|
8
|
+
// receipt.json this run started, with THIS intake, at THIS time, under THIS plugin copy; carries
|
|
9
|
+
// the run id every later record (orders, trials, hook decisions) is keyed by
|
|
10
|
+
// run-args.json the launch record: the models, budgets and answer set the run was opened with
|
|
11
|
+
// intake.md the intake verbatim, so "the spec was dropped on the hand-off" is checkable
|
|
12
|
+
// active-scope the pointer every hook reads to answer "is a run in flight?"
|
|
7
13
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// It loaded an instruction file describing eleven gates and returned a description of eleven
|
|
13
|
-
// gates. No code, no board, no gate artifacts — and prose that reads exactly like a successful
|
|
14
|
-
// run, with every defect still in the deliverable.
|
|
15
|
-
//
|
|
16
|
-
// Two guards existed and neither could see it:
|
|
17
|
-
// • `gate-intake.mjs` (L0.0) fires on an EMPTY intake. Intake was valid here. Correct no-op.
|
|
18
|
-
// • the ship report's census fires when a completion claim contradicts run facts. It is
|
|
19
|
-
// scoped to an ACTIVE run — and a run that never started produces none of the files it
|
|
20
|
-
// reads — and its claim detector matches past-tense completion ("done", "shipped"), while
|
|
21
|
-
// narration is future-tense ("it will"). Two independent misses on the same transcript.
|
|
22
|
-
//
|
|
23
|
-
// The root cause of BOTH misses is the same: **whether a run had started was not a fact on
|
|
24
|
-
// disk.** It was an inference from artifacts that only appear later. So this script exists to
|
|
25
|
-
// make starting a run a mechanical event with a receipt, at t=0:
|
|
26
|
-
//
|
|
27
|
-
// • It is the orchestrator's FIRST action, stated in the first screen of SKILL.md. Everything
|
|
28
|
-
// emitted before a tool call is narration surface; this shrinks that surface to zero.
|
|
29
|
-
// • It writes `receipt.json` — the fact "this run started, with THIS intake, at THIS time".
|
|
30
|
-
// `gate-zerowork.mjs` (Stop) blocks a session that invoked tech-lead and produced no
|
|
31
|
-
// receipt. Narration now has a detector that does not depend on what the narration says.
|
|
32
|
-
// • It writes `active-scope`, which is the precondition every downstream guard already
|
|
33
|
-
// assumed someone had established. Previously that someone was the model, deciding to.
|
|
34
|
-
// An invariant that depends on the model choosing to establish it is a prompt, not a gate.
|
|
35
|
-
//
|
|
36
|
-
// It also HASHES the intake into the receipt. The first (wrong) diagnosis of the failure above was
|
|
37
|
-
// that requirement text had been dropped on the hand-off. It had not — but nothing on disk could
|
|
38
|
-
// settle that either way. Now it can: the intake that reached the orchestrator is recorded
|
|
39
|
-
// verbatim next to its digest, so "the spec was dropped" is checkable, not arguable.
|
|
14
|
+
// It also refuses, before anything is written: a model below the floor for the roles that write code
|
|
15
|
+
// and grade it, and a plugin copy whose worker roster does not resolve. And it records GATE L0 — the
|
|
16
|
+
// intake conversation is that decision, and opening the run is the act that ledgers it.
|
|
40
17
|
//
|
|
41
18
|
// USAGE
|
|
42
|
-
//
|
|
43
|
-
// node `harness init run` --slug <slug> --intake-text "<requirement>" [options]
|
|
44
|
-
// cat spec.md | node `harness init run` --slug <slug> --intake-stdin [options]
|
|
19
|
+
// harness.mjs init run --slug <slug> --intake-file <path> [options]
|
|
45
20
|
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
21
|
+
// --auto-level interactive|auto|unattended (default interactive)
|
|
22
|
+
// --max-rounds N outer breaker, rounds of build + evaluate (default 3)
|
|
23
|
+
// --attempts N inner breaker, T0 attempts per scope (default 5)
|
|
24
|
+
// --spec-folder <dir> where the committed spec lives (default shapeup/<slug>/spec/)
|
|
25
|
+
// --gate-answers <preset|path> the answer set the gates resolve from
|
|
26
|
+
// --exec-model/--eval-model/--qa-model <m> role -> model (flag, then SHAPEUP_<ROLE>_MODEL, then sonnet)
|
|
27
|
+
// --no-eval --no-qa --parallel-scopes N
|
|
28
|
+
// --force re-open over a run that is already open
|
|
49
29
|
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// --max-rounds N outer circuit breaker (default: 3)
|
|
53
|
-
// --attempts N inner per-scope T0 budget (default: 5)
|
|
54
|
-
// --spec-folder SHARED spec deliverable path (default: shapeup/<slug>/spec/)
|
|
55
|
-
// --dimensions comma-separated eval dimensions (default: auto — resolved from the spec per EVAL order)
|
|
56
|
-
// --gate-answers path | preset name (see `harness gate`; recorded, not read)
|
|
57
|
-
// --breadboard path to the pitch's breadboard (default: found — see resolveBreadboard())
|
|
58
|
-
// --wall-clock-budget N deadline breaker, seconds (off by default; see `harness verify budget`)
|
|
59
|
-
// --cwd project root (default: process.cwd())
|
|
60
|
-
// --force re-init over an existing run receipt
|
|
61
|
-
//
|
|
62
|
-
// Prints a JSON receipt on stdout. Exit 0 on success, 2 on a usage error, 3 when a live run
|
|
63
|
-
// already exists and --force was not given.
|
|
64
|
-
//
|
|
65
|
-
// EXIT 3 IS THE RESUME PATH, not a dead end. It prints the file-derived RunSnapshot for the run that
|
|
66
|
-
// is already open — slug, status, round, attempt, board counts, pending orders — so the orchestrator
|
|
67
|
-
// continues from the phase the files report instead of re-opening the run or restarting the pipeline
|
|
68
|
-
// from phase 1. It previously said "Resume it (`--from <slug>`)", and `--from` is a /tech-lead flag
|
|
69
|
-
// that takes a phase, not an init-run flag that takes a slug: the one instruction available at the
|
|
70
|
-
// one moment it mattered named a mechanism that does not parse.
|
|
30
|
+
// Exit 0 opened · 2 usage error · 3 refused (a run is already open, or the roster is missing).
|
|
31
|
+
// A run already open keeps its record: the refusal prints what is on disk and does not overwrite it.
|
|
71
32
|
|
|
72
|
-
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync,
|
|
73
|
-
import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "../gate.mjs";
|
|
33
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from "node:fs";
|
|
74
34
|
import { join, dirname, resolve, relative, sep } from "node:path";
|
|
75
35
|
import { createHash } from "node:crypto";
|
|
76
|
-
import {
|
|
36
|
+
import { discover, resolve as resolveGate, appendGateLedger, PRESETS } from "../gate.mjs";
|
|
77
37
|
import { runArgs } from "../lib/argv.mjs";
|
|
78
|
-
import {
|
|
79
|
-
import {
|
|
80
|
-
import { mintRunId } from "../lib/paths.mjs";
|
|
38
|
+
import { mintRunId, insideRunTrace } from "../lib/paths.mjs";
|
|
39
|
+
import { launchProblems, floorProblems, AUTO_LEVELS } from "../lib/floor.mjs";
|
|
81
40
|
import {
|
|
82
|
-
localRoot, activeScope, activeOrder, globLocal, globShared, ordersDir, resultsDir,
|
|
83
|
-
workflowsStage, globWorkflowsStage, sharedRoot, shapingDir, breadboard as stagedBreadboard, harnessRun,
|
|
41
|
+
localRoot, activeScope, activeOrder, globLocal, globShared, ordersDir, resultsDir, runArgsPath,
|
|
84
42
|
} from "../lib/paths.mjs";
|
|
85
|
-
import { parseBreadboard, hasBreadboardTables, idCounts } from "../lib/breadboard.mjs";
|
|
86
43
|
import { resolveWorkers } from "../verify/skills.mjs";
|
|
87
44
|
|
|
88
45
|
export const RECEIPT_VERSION = 1;
|
|
89
46
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The eval dimension set when the caller names none: `auto`. The spec does not exist yet when a run
|
|
95
|
-
* opens, so the set cannot be resolved here; each evaluate order resolves it from the spec on disk
|
|
96
|
-
* (`harness compile`), with the judge's own auto-enable rules. A fixed default in this place made
|
|
97
|
-
* every orchestrated run grade `spec-conformance` alone, because an explicit list switches the
|
|
98
|
-
* judge's auto-enable off.
|
|
99
|
-
*/
|
|
100
|
-
export const DEFAULT_DIMENSIONS = "auto";
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Parse `--dimensions` into the set written to the ledger. Shape-validated only, NOT checked against
|
|
104
|
-
* the dimensions that ship: adding `references/dimensions/<id>.md` and naming it here is the
|
|
105
|
-
* documented injection path, so a closed list here would make the evaluator's own extension point
|
|
106
|
-
* unreachable. An id with no file behind it is skipped-with-a-warning at dimension resolution, which
|
|
107
|
-
* is where that check belongs and where it can actually see the files.
|
|
108
|
-
*
|
|
109
|
-
* @param {(string|null|undefined)} raw - The comma-separated flag value; absent → `auto`.
|
|
110
|
-
* @returns {(string[]|string)} Trimmed, de-duplicated ids in the caller's order, or `auto`.
|
|
111
|
-
* @throws {Error} If the list is empty or an entry is not a kebab-case id.
|
|
112
|
-
*/
|
|
113
|
-
export function parseDimensions(raw) {
|
|
114
|
-
if (raw === null || raw === undefined) return DEFAULT_DIMENSIONS;
|
|
115
|
-
const ids = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
116
|
-
if (!ids.length) throw new Error("--dimensions: empty list — omit the flag to resolve the set from the spec");
|
|
117
|
-
for (const id of ids) {
|
|
118
|
-
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(id)) {
|
|
119
|
-
throw new Error(`--dimensions: "${id}" is not a dimension id (kebab-case, e.g. spec-conformance, tdd-surface, integration)`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return [...new Set(ids)];
|
|
123
|
-
}
|
|
47
|
+
/** The model a role runs on when neither a flag nor its environment knob names one. */
|
|
48
|
+
export const DEFAULT_MODEL = "sonnet";
|
|
124
49
|
|
|
125
50
|
/** Slugify a free-text feature name into a filesystem-safe run id. */
|
|
126
51
|
export function slugify(text) {
|
|
@@ -136,150 +61,97 @@ export function digest(text) {
|
|
|
136
61
|
}
|
|
137
62
|
|
|
138
63
|
/**
|
|
139
|
-
* Build the receipt
|
|
140
|
-
* Kept separate from I/O so the structural tests can assert its shape without a filesystem.
|
|
64
|
+
* Build the receipt. Pure: resolved inputs in, the object that gets written out.
|
|
141
65
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* @param {object} o
|
|
146
|
-
* @param {string} o.startedAt - ISO start time.
|
|
147
|
-
* @param {(object|null)} [o.plugin] - The plugin copy that answered.
|
|
148
|
-
* @param {(string|null)} [o.intakeSource] - Repo-relative `--intake-file` path, or "text" / "stdin".
|
|
149
|
-
* @param {({source: string, path: (string|null), text: string, translated?: boolean}|null)} [o.breadboard] -
|
|
150
|
-
* The resolved breadboard, `path` repo-relative (null when embedded in the intake), or null.
|
|
66
|
+
* The run id is derived, not drawn (`mintRunId`): a pure function of slug, start time and intake
|
|
67
|
+
* digest, so any writer holding the receipt recomputes the same id without being handed it.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} o - Resolved inputs.
|
|
151
70
|
* @returns {object} The receipt.
|
|
152
71
|
*/
|
|
153
|
-
export function buildReceipt({ slug, intake, config, startedAt, plugin = null, intakeSource = null
|
|
72
|
+
export function buildReceipt({ slug, intake, config, startedAt, plugin = null, intakeSource = null }) {
|
|
154
73
|
const intakeText = String(intake ?? "");
|
|
155
74
|
const intakeSha256 = digest(intakeText);
|
|
156
75
|
return {
|
|
157
76
|
receipt_version: RECEIPT_VERSION,
|
|
158
77
|
type: "harness-run-receipt",
|
|
159
78
|
slug,
|
|
160
|
-
// THE JOIN KEY, minted here because this is where a run acquires an identity at all.
|
|
161
|
-
//
|
|
162
|
-
// `order_id` was the nearest thing the harness had, and it is `<slug>/r<N>-a<M>`: unique
|
|
163
|
-
// WITHIN a run and identical across every run of the same slug. So every record the pipeline
|
|
164
|
-
// writes — orders, results, journal rows, trial rows, hook receipts — could be grouped by
|
|
165
|
-
// feature and never by RUN, which makes "compare this run against the last one" and "what did
|
|
166
|
-
// this run cost" both unanswerable from data that was otherwise all present.
|
|
167
|
-
//
|
|
168
|
-
// Derived, not drawn (see `mintRunId` in lib/paths.mjs): a pure function of the three fields below
|
|
169
|
-
// it, so any writer holding this receipt recomputes the same id without being handed it, and a
|
|
170
|
-
// receipt written before this field existed still yields the id it would have been given.
|
|
171
79
|
run_id: mintRunId({ slug, startedAt, intakeSha256 }),
|
|
172
80
|
started_at: startedAt,
|
|
173
81
|
intake_sha256: intakeSha256,
|
|
174
82
|
intake_chars: intakeText.length,
|
|
175
83
|
intake_lines: intakeText ? intakeText.split("\n").length : 0,
|
|
176
|
-
// WHERE THE INTAKE CAME FROM. The copy above is all the run reads, so without its origin nothing
|
|
177
|
-
// on disk says which file the pitch was — or that it had a second half beside it.
|
|
178
84
|
intake_source: intakeSource,
|
|
179
|
-
// THE PITCH'S OTHER HALF. A `/shapeup` pitch is shaping.md + breadboard.md, and a Place that only
|
|
180
|
-
// the breadboard names reaches no planning worker unless the run carries it. Recorded here, at
|
|
181
|
-
// t=0, rather than left to a worker to note: the kernel knows whether a breadboard exists before
|
|
182
|
-
// anything is dispatched, and a worker's prose rule for exactly this case did not hold.
|
|
183
|
-
breadboard: breadboard
|
|
184
|
-
? {
|
|
185
|
-
source: breadboard.source,
|
|
186
|
-
path: breadboard.path ?? null,
|
|
187
|
-
sha256: digest(breadboard.text),
|
|
188
|
-
chars: String(breadboard.text ?? "").length,
|
|
189
|
-
ids: idCounts(parseBreadboard(breadboard.text)),
|
|
190
|
-
...(typeof breadboard.translated === "boolean" ? { translated: breadboard.translated } : {}),
|
|
191
|
-
}
|
|
192
|
-
: null,
|
|
193
|
-
// The single fact that separates "the harness ran" from "the harness described itself".
|
|
194
|
-
// Written before any gate, so its ABSENCE at Stop is unambiguous.
|
|
195
85
|
started: true,
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// version, a stale marketplace install, or a sub-agent improvising past a failed dispatch. A
|
|
199
|
-
// run whose trace cannot name its own plugin cannot be compared with another run, and cannot be
|
|
200
|
-
// cleared of the wrong-version failure the roster check exists to catch.
|
|
86
|
+
// Which copy of the plugin produced this run. A run whose trace cannot name its own plugin cannot
|
|
87
|
+
// be cleared of the wrong-version failure the roster check exists to catch.
|
|
201
88
|
plugin: plugin ? { name: plugin.name, version: plugin.version, root: plugin.root } : null,
|
|
202
89
|
config,
|
|
203
90
|
};
|
|
204
91
|
}
|
|
205
92
|
|
|
206
|
-
/**
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
`attempt_budget: ${config.attempt_budget}`,
|
|
220
|
-
`wall_clock_budget_s: ${config.wall_clock_budget_s ?? "~"}`,
|
|
221
|
-
`auto_level: ${config.auto_level}`,
|
|
222
|
-
`gate_answers: ${config.gate_answers ?? "~"}`,
|
|
223
|
-
`lane: ${config.fit?.lane ?? "full"}${config.fit?.overridden_from ? ` (overridden from ${config.fit.overridden_from})` : ""}`,
|
|
224
|
-
"status: orienting",
|
|
225
|
-
"final_verdict: ~",
|
|
226
|
-
"rounds_used: 0",
|
|
227
|
-
"discovered_rounds: 0",
|
|
228
|
-
"deploy: ~",
|
|
229
|
-
`started_at: ${startedAt}`,
|
|
230
|
-
"closed_at: ~",
|
|
231
|
-
// The cause a terminal status ended on, written alongside `closed_at` by
|
|
232
|
-
// `probe resume --close` (kernel/probe/resume.mjs's closeRun) — the two land in one write, so a
|
|
233
|
-
// closed run's ledger never carries a timestamp with no reason beside it.
|
|
234
|
-
"close_cause: ~",
|
|
235
|
-
// The once-only guard's OWN record of what closed this run — deliberately separate from
|
|
236
|
-
// `status:` above, which every phase rewrites (`setRunStatus`) for the life of the run,
|
|
237
|
-
// including the product's own ship path immediately before `probe resume --close` runs. Only
|
|
238
|
-
// `closeRun` ever writes this line, so it is the one field an intervening `status:` rewrite
|
|
239
|
-
// cannot move (kernel/probe/resume.mjs's closeRun docblock has the measured scenario).
|
|
240
|
-
"closed_status: ~",
|
|
241
|
-
"---",
|
|
242
|
-
"",
|
|
243
|
-
`# Harness run — ${slug}`,
|
|
244
|
-
"",
|
|
245
|
-
"Opened by ``harness init run`` (GATE L0.1). The tech lead is the sole writer from here on.",
|
|
246
|
-
"",
|
|
247
|
-
"## Rounds",
|
|
248
|
-
"",
|
|
249
|
-
"| Phase | Round | Result | Duration | Notes |",
|
|
250
|
-
"|-------|-------|--------|----------|-------|",
|
|
251
|
-
"| Init | — | run opened | — | intake recorded, receipt written |",
|
|
252
|
-
"",
|
|
253
|
-
"## Decisions log",
|
|
254
|
-
"",
|
|
255
|
-
"| Gate | Decision | Source | Note |",
|
|
256
|
-
"|------|----------|--------|------|",
|
|
257
|
-
"",
|
|
258
|
-
].join("\n");
|
|
93
|
+
/** Drop `undefined` leaves, one level deep, so the record never carries a literal `undefined`. */
|
|
94
|
+
function pruned(o) {
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const [k, v] of Object.entries(o)) {
|
|
97
|
+
if (v === undefined) continue;
|
|
98
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
99
|
+
const inner = pruned(v);
|
|
100
|
+
if (Object.keys(inner).length) out[k] = inner;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
out[k] = v;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
259
106
|
}
|
|
260
107
|
|
|
261
108
|
/**
|
|
262
|
-
*
|
|
109
|
+
* Resolve the role -> model matrix: the flag, else the role's environment knob, else `sonnet`; `qa`
|
|
110
|
+
* follows `exec` when nothing names it. `eval` is left unset under `--no-eval`.
|
|
263
111
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
112
|
+
* @param {object} a - Parsed argv.
|
|
113
|
+
* @param {object} [env=process.env] - Environment, injectable for a test.
|
|
114
|
+
* @returns {{exec: string, eval?: string, qa: string}} The matrix.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveModels(a, env = process.env) {
|
|
117
|
+
const pick = (flag, knob) => (flag ?? env[knob] ?? "").trim() || undefined;
|
|
118
|
+
const exec = pick(a.execModel, "SHAPEUP_EXEC_MODEL") ?? DEFAULT_MODEL;
|
|
119
|
+
const evalModel = a.noEval ? pick(a.evalModel, "SHAPEUP_EVAL_MODEL") : (pick(a.evalModel, "SHAPEUP_EVAL_MODEL") ?? DEFAULT_MODEL);
|
|
120
|
+
const qa = pick(a.qaModel, "SHAPEUP_QA_MODEL") ?? exec;
|
|
121
|
+
return pruned({ exec, eval: evalModel, qa });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the launch record. Pure, so a test asserts its shape without a filesystem. The receipt's
|
|
126
|
+
* config and the record come from the same parsed argv, so the two cannot disagree about the dials.
|
|
272
127
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
128
|
+
* @param {object} o - Resolved inputs.
|
|
129
|
+
* @returns {object} The record, pruned of unset optionals.
|
|
130
|
+
*/
|
|
131
|
+
export function buildLaunchRecord({
|
|
132
|
+
slug, runId, autoLevel, answers, models, maxRounds, attemptBudget, pluginRoot, startedAt,
|
|
133
|
+
noEval, noQa, maxParallelScopes,
|
|
134
|
+
}) {
|
|
135
|
+
return pruned({
|
|
136
|
+
slug, runId, autoLevel, answers,
|
|
137
|
+
models, budgets: { maxRounds, attemptBudget },
|
|
138
|
+
pluginRoot, startedAt,
|
|
139
|
+
noEval: noEval || undefined, noQa: noQa || undefined,
|
|
140
|
+
maxParallelScopes,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Unwedge `--force`: resolve every order left LIVE by a run nobody is continuing.
|
|
146
|
+
*
|
|
147
|
+
* The substrate fence treats any order with no same-named result as live and constrains every later
|
|
148
|
+
* write to what it permits, so re-opening over a dispatch nobody answered would keep the stale fence.
|
|
149
|
+
* For each such order this writes a plainly self-labelled marker under `results/` (never a lie in a
|
|
150
|
+
* schema-valid shape), and keeps the order file: `orders/` is the audit trail of what was dispatched.
|
|
279
151
|
*
|
|
280
152
|
* @param {string} cwd - Project root.
|
|
281
153
|
* @param {string} slug - The run being forced over.
|
|
282
|
-
* @returns {string[]} Order
|
|
154
|
+
* @returns {string[]} Order ids resolved as abandoned.
|
|
283
155
|
*/
|
|
284
156
|
export function resolveAbandonedOrders(cwd, slug) {
|
|
285
157
|
const oDir = ordersDir(cwd, slug);
|
|
@@ -293,7 +165,7 @@ export function resolveAbandonedOrders(cwd, slug) {
|
|
|
293
165
|
mkdirSync(rDir, { recursive: true });
|
|
294
166
|
const marker = {
|
|
295
167
|
synthetic: true,
|
|
296
|
-
status: "abandoned", //
|
|
168
|
+
status: "abandoned", // deliberately outside the WorkResult status enum: nobody answered
|
|
297
169
|
order_id: f.slice(0, -".json".length),
|
|
298
170
|
reason: "dispatched, never answered — resolved by `harness init run --force`",
|
|
299
171
|
resolved_at: resolvedAt,
|
|
@@ -304,132 +176,16 @@ export function resolveAbandonedOrders(cwd, slug) {
|
|
|
304
176
|
return resolved;
|
|
305
177
|
}
|
|
306
178
|
|
|
307
|
-
/**
|
|
308
|
-
* Copy the plugin's run scripts into the project so the launch can actually read them.
|
|
309
|
-
*
|
|
310
|
-
* WHY A COPY, AND WHY HERE. `Workflow({scriptPath})` loads a script only from somewhere the session
|
|
311
|
-
* is already allowed to read — the working directory, or a directory the operator added. The plugin
|
|
312
|
-
* is installed OUTSIDE the project (a version-stamped cache directory on a marketplace install), so
|
|
313
|
-
* naming the shipped path fails the launch outright, and no permission rule fixes it: the grant that
|
|
314
|
-
* authorises the Workflow tool says nothing about where it may read from. The failure is invisible
|
|
315
|
-
* in development, where the plugin root and the working directory are the same tree, and total for
|
|
316
|
-
* everybody else. A project-local copy is inside the working directory by construction, so it loads
|
|
317
|
-
* in every permission mode without widening what the session may read.
|
|
318
|
-
*
|
|
319
|
-
* Opening a run is the right moment: it is the one step every lane passes through before a launch,
|
|
320
|
-
* and it already knows which copy of the plugin answered.
|
|
321
|
-
*
|
|
322
|
-
* `refresh` is what keeps an upgrade from arriving mid-round. Opening a run (or forcing over one)
|
|
323
|
-
* overwrites the staged copies, so a plugin upgrade reaches the NEXT run. A call that finds a run
|
|
324
|
-
* already open only fills in what is missing: that run should finish on the orchestrator it started
|
|
325
|
-
* with, and swapping the script under a resumed round is a different build than the one the gates
|
|
326
|
-
* were answered for — but a relaunch with no script at all is a dead end, so a copy that is gone
|
|
327
|
-
* (a cleaned run workspace, a run opened by a version that never staged) is written back.
|
|
328
|
-
*
|
|
329
|
-
* Best-effort by design: a project that cannot be written to still opens its run and can still be
|
|
330
|
-
* launched from the install path by an operator who adds that directory. Reporting beats refusing.
|
|
331
|
-
*
|
|
332
|
-
* @param {string} cwd - Project root.
|
|
333
|
-
* @param {string} pluginRoot - The plugin copy that answered this call.
|
|
334
|
-
* @param {object} [opts] - Options.
|
|
335
|
-
* @param {boolean} [opts.refresh] - Overwrite an existing copy (true) or only fill gaps (false).
|
|
336
|
-
* @returns {{ok: boolean, dir: string, staged: string[], reason?: string}} Outcome, for the caller to report.
|
|
337
|
-
*/
|
|
338
|
-
export function stageWorkflows(cwd, pluginRoot, { refresh = true } = {}) {
|
|
339
|
-
const src = join(pluginRoot, "skills", "tech-lead", "workflows");
|
|
340
|
-
const dir = workflowsStage(cwd);
|
|
341
|
-
let names;
|
|
342
|
-
try {
|
|
343
|
-
names = readdirSync(src).filter((f) => f.endsWith(".js")).sort();
|
|
344
|
-
} catch (e) {
|
|
345
|
-
return { ok: false, dir, staged: [], reason: `no workflow scripts at ${src}: ${e.message}` };
|
|
346
|
-
}
|
|
347
|
-
try {
|
|
348
|
-
mkdirSync(dir, { recursive: true });
|
|
349
|
-
for (const f of names) {
|
|
350
|
-
const dst = join(dir, f);
|
|
351
|
-
if (refresh || !existsSync(dst)) copyFileSync(join(src, f), dst);
|
|
352
|
-
}
|
|
353
|
-
} catch (e) {
|
|
354
|
-
return { ok: false, dir, staged: [], reason: `could not stage into ${dir}: ${e.message}` };
|
|
355
|
-
}
|
|
356
|
-
return { ok: true, dir, staged: names };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Find the breadboard that belongs to this run's pitch. First hit wins:
|
|
361
|
-
*
|
|
362
|
-
* 1. `flag` — `--breadboard <path>`, resolved against cwd; a missing file is an error.
|
|
363
|
-
* 2. `sibling` — `breadboard.md` in the folder of `--intake-file` (not for text or stdin).
|
|
364
|
-
* 3. `shaping-dir` — `shapeup/<slug>/shaping/breadboard.md`, the documented location.
|
|
365
|
-
* 4. `shared-root` — `shapeup/<slug>/breadboard.md`, the flat layout real projects use.
|
|
366
|
-
* 5. `embedded` — the intake itself, when it carries Places and UI tables; nothing is staged.
|
|
367
|
-
*
|
|
368
|
-
* THE INTAKE'S OWN FOLDER COMES FIRST, not the documented location, because the only consumer that
|
|
369
|
-
* hit this keeps its pitch flat in `shapeup/<slug>/`: a lookup that reads only the shaping folder
|
|
370
|
-
* passes every fixture built to the documented layout and misses the real one.
|
|
371
|
-
*
|
|
372
|
-
* A discovered candidate that is the intake file itself, or the run's own staged copy, is skipped:
|
|
373
|
-
* the staged copy is this function's OUTPUT, and re-opening a run from its staged intake must not
|
|
374
|
-
* inherit the breadboard of the run it replaces.
|
|
375
|
-
*
|
|
376
|
-
* A TRANSLATED PITCH GETS ITS TRANSLATED BREADBOARD. When the intake is a translator output
|
|
377
|
-
* (`<name>.en.md`), each folder is tried for `breadboard.en.md` before `breadboard.md`, and the
|
|
378
|
-
* result says whether it is translated — an English spec planned from a source-language breadboard
|
|
379
|
-
* is a mismatch the caller reports, not one it may silently stage.
|
|
380
|
-
*
|
|
381
|
-
* @param {object} o - Inputs (destructured).
|
|
382
|
-
* @param {string} o.cwd - Project root.
|
|
383
|
-
* @param {string} o.slug - Feature slug.
|
|
384
|
-
* @param {(string|null)} [o.intakeFile] - The `--intake-file` value as given ("-" = stdin).
|
|
385
|
-
* @param {(string|null)} [o.flag] - The `--breadboard` value as given.
|
|
386
|
-
* @param {string} [o.intake] - The intake text, for the embedded case.
|
|
387
|
-
* @returns {({source: string, path: (string|null), text: string, translated?: boolean}|null)} The
|
|
388
|
-
* breadboard, its absolute path (null when embedded), and its text — plus `translated` when the
|
|
389
|
-
* intake is a `.en.md` translation; or null when the pitch has none.
|
|
390
|
-
* @throws {Error} If `--breadboard` names a file that does not exist.
|
|
391
|
-
*/
|
|
392
|
-
export function resolveBreadboard({ cwd, slug, intakeFile = null, flag = null, intake = "" }) {
|
|
393
|
-
const self = intakeFile && intakeFile !== "-" ? resolve(cwd, intakeFile) : null;
|
|
394
|
-
const english = Boolean(self && self.endsWith(".en.md"));
|
|
395
|
-
/** Tag a hit with whether it is translated — only meaningful when the intake is. */
|
|
396
|
-
const hit = (source, p, text) => ({ source, path: p, text, ...(english ? { translated: p === null || p.endsWith(".en.md") } : {}) });
|
|
397
|
-
if (flag) {
|
|
398
|
-
const p = resolve(cwd, flag);
|
|
399
|
-
if (!existsSync(p) || !statSync(p).isFile()) throw new Error(`--breadboard not found: ${p}`);
|
|
400
|
-
return hit("flag", p, readFileSync(p, "utf8"));
|
|
401
|
-
}
|
|
402
|
-
const staged = stagedBreadboard(cwd, slug);
|
|
403
|
-
const names = english ? ["breadboard.en.md", "breadboard.md"] : ["breadboard.md"];
|
|
404
|
-
const folders = [
|
|
405
|
-
...(self ? [["sibling", dirname(self)]] : []),
|
|
406
|
-
["shaping-dir", shapingDir(cwd, slug)],
|
|
407
|
-
["shared-root", sharedRoot(cwd, slug)],
|
|
408
|
-
];
|
|
409
|
-
for (const [source, dir] of folders) {
|
|
410
|
-
for (const n of names) {
|
|
411
|
-
const p = join(dir, n);
|
|
412
|
-
if (p === self || p === staged) continue;
|
|
413
|
-
if (existsSync(p) && statSync(p).isFile()) return hit(source, p, readFileSync(p, "utf8"));
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
if (hasBreadboardTables(intake)) return hit("embedded", null, String(intake));
|
|
417
|
-
return null;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** A path relative to the project root, `/`-joined on every platform — the form a receipt records. */
|
|
179
|
+
/** A path relative to the project root, `/`-joined on every platform. */
|
|
421
180
|
const repoRel = (cwd, p) => relative(cwd, p).split(sep).join("/");
|
|
422
181
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
182
|
+
/** The typed argv contract (see `../lib/argv.mjs`). */
|
|
426
183
|
export const ARGV_SPEC = {
|
|
427
184
|
usage: 'harness.mjs init run (--intake-file <path> | --intake-text "<req>" | --intake-stdin) ' +
|
|
428
|
-
"[--slug <slug>] [--auto-level interactive|auto|unattended]
|
|
429
|
-
"[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--
|
|
430
|
-
"[--
|
|
431
|
-
"[--
|
|
432
|
-
"[--plugin-root <dir>] [--force]",
|
|
185
|
+
"[--slug <slug>] [--auto-level interactive|auto|unattended] " +
|
|
186
|
+
"[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--gate-answers <preset|path>] " +
|
|
187
|
+
"[--exec-model <m>] [--eval-model <m>] [--qa-model <m>] [--no-eval] [--no-qa] " +
|
|
188
|
+
"[--parallel-scopes N] [--cwd <dir>] [--plugin-root <dir>] [--force]",
|
|
433
189
|
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
434
190
|
cwd: { type: "path" },
|
|
435
191
|
"intake-text": { type: "str" },
|
|
@@ -437,20 +193,18 @@ export const ARGV_SPEC = {
|
|
|
437
193
|
"intake-stdin": { type: "flag" },
|
|
438
194
|
slug: { type: "str" },
|
|
439
195
|
"auto-level": { type: "str" },
|
|
440
|
-
lens: { type: "str" },
|
|
441
196
|
"max-rounds": { type: "int", min: 1 },
|
|
442
197
|
attempts: { type: "int", min: 1 },
|
|
443
198
|
"spec-folder": { type: "path" },
|
|
444
|
-
dimensions: { type: "str" },
|
|
445
199
|
"gate-answers": { type: "str" },
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
"
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
//
|
|
453
|
-
//
|
|
200
|
+
"exec-model": { type: "str" },
|
|
201
|
+
"eval-model": { type: "str" },
|
|
202
|
+
"qa-model": { type: "str" },
|
|
203
|
+
"no-eval": { type: "flag" },
|
|
204
|
+
"no-qa": { type: "flag" },
|
|
205
|
+
"parallel-scopes": { type: "int", min: 1 },
|
|
206
|
+
// Which copy of the plugin this run is opened against. Nameable because a machine can carry several
|
|
207
|
+
// installs, and because the roster refusal is otherwise unreachable for a test.
|
|
454
208
|
"plugin-root": { type: "path" },
|
|
455
209
|
force: { type: "flag" },
|
|
456
210
|
};
|
|
@@ -461,48 +215,57 @@ function fail(code, msg) {
|
|
|
461
215
|
}
|
|
462
216
|
|
|
463
217
|
/**
|
|
464
|
-
* Open a run: mint the receipt, or refuse
|
|
218
|
+
* Open a run: mint the receipt, or refuse when one is already live.
|
|
465
219
|
*
|
|
466
|
-
* @param {string[]} rawArgv - The subcommand's own arguments
|
|
467
|
-
* @returns {
|
|
468
|
-
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
220
|
+
* @param {string[]} rawArgv - The subcommand's own arguments.
|
|
221
|
+
* @returns {void} Calls `process.exit()` with the documented code on every refusal.
|
|
469
222
|
*/
|
|
470
223
|
export function cli(rawArgv) {
|
|
471
224
|
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
472
225
|
const cwd = args.cwd || process.cwd();
|
|
226
|
+
if (insideRunTrace(cwd)) {
|
|
227
|
+
fail(2, `✋ init-run: ${resolve(cwd)} is inside a run trace (.shapeup/). Run it from the project root: from here it would open a second run nested in the first.`);
|
|
228
|
+
}
|
|
473
229
|
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
230
|
+
// THE MODEL FLOOR, before anything is written and before the already-open refusal.
|
|
231
|
+
const models = resolveModels(args);
|
|
232
|
+
const belowFloor = floorProblems(models, { noEval: Boolean(args.noEval) });
|
|
233
|
+
if (belowFloor.length) {
|
|
234
|
+
fail(2, [
|
|
235
|
+
`✋ init-run: ${belowFloor.join("; ")}.`,
|
|
236
|
+
"",
|
|
237
|
+
"The launch record's models for the roles that write code (exec) and grade it (eval) must be sonnet or",
|
|
238
|
+
"above; a cheaper tier is refused here, before a run is opened. (A worker agent inherits the session's",
|
|
239
|
+
"model, so the record states intent and the dispatch receipts name the model that ran.) Name a model with --exec-model / --eval-model, or set",
|
|
240
|
+
"SHAPEUP_EXEC_MODEL / SHAPEUP_EVAL_MODEL. `qa` is not checked, and `eval` is not under --no-eval.",
|
|
241
|
+
].join("\n"));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// GATE L0 — the worker roster, before any spend. It says the files exist at this root at this
|
|
245
|
+
// version; whether the SESSION resolves that copy is answered by the first dispatch's receipt.
|
|
246
|
+
let plugin;
|
|
247
|
+
try { plugin = resolveWorkers(args.pluginRoot ? resolve(args.pluginRoot) : undefined); }
|
|
248
|
+
catch (e) {
|
|
249
|
+
// A root with no domain registry cannot name its own roster: a broken installation, refused the
|
|
250
|
+
// same way as a missing worker rather than thrown as a stack trace.
|
|
251
|
+
fail(3, `✋ init-run: refusing to open a run — the plugin copy cannot name its worker roster (${e.message}).`);
|
|
252
|
+
}
|
|
487
253
|
if (plugin.missing.length) {
|
|
488
254
|
fail(3, [
|
|
489
|
-
`✋ init-run: refusing to open a run — ${plugin.missing.length} of ${plugin.workers.length}
|
|
255
|
+
`✋ init-run: refusing to open a run — ${plugin.missing.length} of ${plugin.workers.length} workers are missing.`,
|
|
490
256
|
"",
|
|
491
257
|
` plugin: ${plugin.name ?? "unknown"} ${plugin.version ?? "unknown version"}`,
|
|
492
258
|
` root: ${plugin.root}`,
|
|
493
259
|
` missing: ${plugin.missing.join(", ")}`,
|
|
494
260
|
"",
|
|
495
261
|
"A run against this copy would dispatch workers that cannot resolve, and a failed dispatch is",
|
|
496
|
-
"answered by the sub-agent improvising the craft itself
|
|
497
|
-
"
|
|
498
|
-
"and enable the plugin, then retry. `harness verify skills` reports the same thing on demand.",
|
|
262
|
+
"answered by the sub-agent improvising the craft itself. Load the working copy",
|
|
263
|
+
"(`claude --plugin-dir <repo>`) or install and enable the plugin, then retry.",
|
|
499
264
|
].join("\n"));
|
|
500
265
|
}
|
|
501
266
|
|
|
502
267
|
let intake = args.intakeText ?? null;
|
|
503
268
|
const intakeFile = args.intakeFile ?? null;
|
|
504
|
-
// `--intake-file -` is the shape everyone reaches for; accept it rather than erroring on a
|
|
505
|
-
// file literally named "-". (Measured: an agent tried exactly this on its second attempt.)
|
|
506
269
|
if (args.intakeStdin || intakeFile === "-") {
|
|
507
270
|
try { intake = readFileSync(0, "utf8"); } catch { fail(2, "--intake-stdin: nothing on stdin"); }
|
|
508
271
|
} else if (intakeFile) {
|
|
@@ -512,196 +275,91 @@ export function cli(rawArgv) {
|
|
|
512
275
|
}
|
|
513
276
|
if (!intake || !intake.trim()) {
|
|
514
277
|
fail(2, [
|
|
515
|
-
"✋ init-run: no intake. Pass --intake-
|
|
278
|
+
"✋ init-run: no intake. Pass --intake-file <path> or --intake-text \"<the requirement>\".",
|
|
516
279
|
"",
|
|
517
|
-
"
|
|
518
|
-
"GATE L0.0 (hooks/gate-intake.mjs) enforces at dispatch, re-checked here where the run is",
|
|
519
|
-
"actually opened.",
|
|
280
|
+
"A run opened over no requirement would plan nothing and read like a success.",
|
|
520
281
|
].join("\n"));
|
|
521
282
|
}
|
|
522
283
|
|
|
523
284
|
const slug = (args.slug ?? null) || slugify(intake.split("\n").find((l) => l.trim()) || "run");
|
|
524
|
-
|
|
525
285
|
const auto_level = args.autoLevel ?? "interactive";
|
|
526
|
-
if (!AUTO_LEVELS.
|
|
527
|
-
const lens = args.lens ?? "standard";
|
|
528
|
-
if (!LENSES.has(lens)) fail(2, `--lens must be one of: ${[...LENSES].join(", ")}`);
|
|
529
|
-
// GATE L0.5 — the eval dimension set, resolved ONCE here and carried by the ledger. Before this
|
|
530
|
-
// flag the line was a constant, so a dimension the PO asked for at L0.5 had nowhere to be
|
|
531
|
-
// recorded and the run graded spec-conformance whatever the answer had been.
|
|
532
|
-
let eval_dimensions;
|
|
533
|
-
try { eval_dimensions = parseDimensions(args.dimensions ?? null); }
|
|
534
|
-
catch (e) { fail(2, e.message); }
|
|
535
|
-
// The pitch's second half, resolved before anything is written so a bad `--breadboard` is a usage
|
|
536
|
-
// error that ran nothing. Read-only here; staged below, once the run is actually opened.
|
|
537
|
-
let bb = null;
|
|
538
|
-
try { bb = resolveBreadboard({ cwd, slug, intakeFile, flag: args.breadboard ?? null, intake }); }
|
|
539
|
-
catch (e) { fail(2, e.message); }
|
|
540
|
-
if (bb?.translated === false) {
|
|
541
|
-
console.error(`⚠ init-run: the intake is a translation but its breadboard is not — staging ${repoRel(cwd, bb.path)} as found.`);
|
|
542
|
-
console.error(" Translate the breadboard too (translator), and name the .en.md with --breadboard, or the spec is planned from two languages.");
|
|
543
|
-
}
|
|
286
|
+
if (!AUTO_LEVELS.includes(auto_level)) fail(2, `--auto-level must be one of: ${AUTO_LEVELS.join(", ")}`);
|
|
544
287
|
const intakeSource = args.intakeStdin || intakeFile === "-" ? "stdin" : intakeFile ? repoRel(cwd, resolve(cwd, intakeFile)) : "text";
|
|
545
288
|
|
|
546
289
|
const config = {
|
|
547
290
|
auto_level,
|
|
548
|
-
lens,
|
|
549
|
-
eval_dimensions,
|
|
550
291
|
max_rounds: args.maxRounds ?? 3,
|
|
551
292
|
attempt_budget: args.attempts ?? 5,
|
|
552
293
|
spec_folder: args.specFolder ?? `${globShared(slug, "spec")}/`,
|
|
553
294
|
gate_answers: args.gateAnswers ?? null,
|
|
554
|
-
tiny_lane: !!args.tiny,
|
|
555
|
-
// GATE L0.3 — the lane, computed rather than judged (see `harness init fit`). Recorded with its
|
|
556
|
-
// evidence so a heavy lane on a small change is visible instead of accidental. An explicit
|
|
557
|
-
// --lane or --tiny is honoured and marked as an override, because a measured recommendation
|
|
558
|
-
// fitted on three features must not outrank a human who knows the codebase.
|
|
559
|
-
fit: (() => {
|
|
560
|
-
const auto = decideLane({ intake, files: treeSize(cwd) });
|
|
561
|
-
const forced = (args.lane ?? null) || (args.tiny ? "tiny" : null);
|
|
562
|
-
return forced && forced !== auto.lane
|
|
563
|
-
? { ...auto, lane: forced, overridden_from: auto.lane, override_source: args.tiny ? "--tiny" : "--lane" }
|
|
564
|
-
: auto;
|
|
565
|
-
})(),
|
|
566
|
-
// The third breaker (see `harness verify budget`). Null = off, which is the default and
|
|
567
|
-
// keeps every existing run behaving exactly as before. Set it in any lane with a hard clock
|
|
568
|
-
// — CI, an overnight run — so the harness trips its own breaker and ships what
|
|
569
|
-
// is green, instead of being killed from outside and shipping nothing.
|
|
570
|
-
wall_clock_budget_s: args.wallClockBudget ?? null,
|
|
571
295
|
};
|
|
572
296
|
|
|
573
297
|
const runRoot = localRoot(cwd, slug);
|
|
574
298
|
const receiptPath = join(runRoot, "receipt.json");
|
|
575
299
|
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
// it does NOT get is a swapped orchestrator — see stageWorkflows() for why `refresh` is false
|
|
579
|
-
// exactly when a run is already open and is not being forced over.
|
|
580
|
-
// `Boolean(...)`, not the bare flag: an absent `--force` is `undefined`, and `false || undefined`
|
|
581
|
-
// is `undefined`, which a destructured default reads as "not passed" and turns back into `true`.
|
|
582
|
-
const staged = stageWorkflows(cwd, plugin.root, { refresh: !existsSync(receiptPath) || Boolean(args.force) });
|
|
583
|
-
if (!staged.ok) {
|
|
584
|
-
console.error(`⚠ init-run: could not stage the run scripts — ${staged.reason}`);
|
|
585
|
-
console.error(` Launch from the install path instead, and add ${plugin.root} to the session's`);
|
|
586
|
-
console.error(" readable directories (/add-dir) if the Workflow tool refuses to load it.");
|
|
587
|
-
}
|
|
588
|
-
// A RUN IS ALREADY OPEN. This is the resume path, and it used to be a dead end.
|
|
589
|
-
//
|
|
590
|
-
// The refusal is right: silently re-initialising would discard the round history the circuit
|
|
591
|
-
// breaker counts against. What was wrong was the instruction it gave — "Resume it (`--from
|
|
592
|
-
// <slug>`)". `--from` is not an init-run flag at all; it is a `/tech-lead` flag, and it takes a
|
|
593
|
-
// PHASE (`--from build`), not a slug. So at the one moment the orchestrator most needs a next
|
|
594
|
-
// step, the runtime named a mechanism that does not exist, on a script whose failure mode was
|
|
595
|
-
// already invisible — under a symlinked install an entry-point guard once skipped this whole body.
|
|
596
|
-
//
|
|
597
|
-
// Observed consequence on a handoff: a fresh session in a workspace with an open run burns most
|
|
598
|
-
// of its budget before its first write, largely on forensics against this step, and closes none
|
|
599
|
-
// of the gap.
|
|
600
|
-
//
|
|
601
|
-
// So the refusal now DOES the resume work instead of describing it. It emits the derived snapshot
|
|
602
|
-
// — the same file-only derivation `harness reduce graph --subgraph run` injects — so the orchestrator gets
|
|
603
|
-
// slug, status, round, attempt, board counts and pending orders in THIS tool call rather than
|
|
604
|
-
// needing to discover that it needs another one. Exit 3 still means "do not proceed as if you
|
|
605
|
-
// opened a run"; it now also means "here is the run you are actually in".
|
|
300
|
+
// A RUN IS ALREADY OPEN: the refusal does the resume work, printing what is on disk. Re-opening
|
|
301
|
+
// would discard the round history the circuit breaker counts against.
|
|
606
302
|
if (existsSync(receiptPath) && !args.force) {
|
|
607
|
-
let
|
|
608
|
-
try {
|
|
609
|
-
// A receipt says a run EXISTS, not that it is open: a terminal close leaves the receipt in place.
|
|
610
|
-
// Calling a closed run "open" hid the one fact that changes what resuming it means.
|
|
303
|
+
let kept = null;
|
|
304
|
+
try { kept = JSON.parse(readFileSync(runArgsPath(cwd, slug), "utf8")); } catch { /* no record on disk */ }
|
|
611
305
|
let closed = null;
|
|
612
|
-
try {
|
|
613
|
-
const fm = splitFrontmatter(readFileSync(harnessRun(cwd, slug), "utf8")).meta || {};
|
|
614
|
-
if (fm.closed_status && fm.closed_status !== "~") closed = { status: fm.closed_status, at: fm.closed_at, cause: fm.close_cause };
|
|
615
|
-
} catch { /* no ledger — the receipt alone decides, as before */ }
|
|
306
|
+
try { closed = JSON.parse(readFileSync(join(runRoot, "close.json"), "utf8")); } catch { /* not closed */ }
|
|
616
307
|
fail(3, [
|
|
617
308
|
closed
|
|
618
|
-
? `✋ init-run: this run is CLOSED as "${closed.status}" at ${closed.at ?? "?"} — receipt exists at ${receiptPath}.`
|
|
309
|
+
? `✋ init-run: this run is CLOSED as "${closed.status}" at ${closed.at ?? "?"} (${closed.cause ?? "no cause recorded"}) — receipt exists at ${receiptPath}.`
|
|
619
310
|
: `✋ init-run: a run is ALREADY OPEN — receipt exists at ${receiptPath}.`,
|
|
620
|
-
...(closed ? [
|
|
621
|
-
` Its close: ${closed.cause && closed.cause !== "~" ? closed.cause : "no cause recorded"}`,
|
|
622
|
-
" Resuming it REOPENS it: the first phase that runs moves the ledger off the closed state, keeps",
|
|
623
|
-
" this close under prior_closes, and the run's next terminal close is recorded as its own.",
|
|
624
|
-
] : []),
|
|
625
311
|
"",
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
312
|
+
closed
|
|
313
|
+
? "Reopening a closed run is the PO's decision: --force answers every order nobody answered and writes a fresh record."
|
|
314
|
+
: "Do NOT re-initialise and do NOT restart from phase 1. The orders, results, gate ledger and board",
|
|
315
|
+
closed ? "" : "under the run root already hold the run's real state; resume from the phase they report.",
|
|
629
316
|
"",
|
|
630
|
-
|
|
631
|
-
? `
|
|
632
|
-
:
|
|
633
|
-
"The receipt exists but no run state could be derived, which means the run root is",
|
|
634
|
-
"incomplete. Inspect it before deciding:",
|
|
635
|
-
` ls -R ${runRoot}`,
|
|
636
|
-
].join("\n"),
|
|
317
|
+
kept
|
|
318
|
+
? `LAUNCH RECORD (kept — the switches typed to this call were NOT applied; --force re-opens with new ones):\n${JSON.stringify(kept, null, 2)}`
|
|
319
|
+
: "No launch record is on disk for this run — re-open it with --force to write one.",
|
|
637
320
|
"",
|
|
638
|
-
"To re-derive this at any time:",
|
|
639
|
-
" node <plugin>/kernel/harness.mjs reduce snapshot --cwd <dir>",
|
|
640
321
|
"To abandon the open run and start over, deliberately: --force",
|
|
641
|
-
"",
|
|
642
|
-
staged.ok
|
|
643
|
-
? `Relaunch the same run with: Workflow({scriptPath: "${globWorkflowsStage("shapeup-run.js")}", args: <the same RunArgs>})`
|
|
644
|
-
: "The run scripts are NOT staged in this project — see the warning above before relaunching.",
|
|
645
322
|
].join("\n"));
|
|
646
323
|
}
|
|
647
324
|
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
// dispatched-but-unanswered order, so `sandbox-guard.mjs`'s `liveOrders()` kept constraining every
|
|
651
|
-
// later write to that stale order's substrate regardless of `--force`. Resolve every such order
|
|
652
|
-
// BEFORE the fresh run starts writing (see `resolveAbandonedOrders()` above for why this writes a
|
|
653
|
-
// marker under `results/` rather than deleting the order file).
|
|
325
|
+
// The real unwedge path: resolve every dispatched-but-unanswered order BEFORE the fresh run writes,
|
|
326
|
+
// and retire the pointer the abandoned run left, so the fence stops constraining writes to it.
|
|
654
327
|
if (args.force) {
|
|
655
328
|
const abandoned = resolveAbandonedOrders(cwd, slug);
|
|
656
329
|
if (abandoned.length) {
|
|
657
|
-
console.error(
|
|
658
|
-
`⚠ init-run --force: resolved ${abandoned.length} dispatched-but-unanswered order(s) as abandoned — ${abandoned.join(", ")}`,
|
|
659
|
-
);
|
|
330
|
+
console.error(`⚠ init-run --force: resolved ${abandoned.length} dispatched-but-unanswered order(s) as abandoned — ${abandoned.join(", ")}`);
|
|
660
331
|
}
|
|
661
|
-
// And the pointer the abandoned run left behind. It names a run that is being forced over, so
|
|
662
|
-
// the next compile republishes it within the second; retiring it here means a `--force` that
|
|
663
|
-
// resolves nothing still leaves no stale claim about which run is open.
|
|
664
332
|
rmSync(activeOrder(cwd), { force: true });
|
|
665
333
|
}
|
|
666
334
|
|
|
667
335
|
const startedAt = new Date().toISOString();
|
|
668
|
-
const receipt = buildReceipt({
|
|
669
|
-
|
|
670
|
-
|
|
336
|
+
const receipt = buildReceipt({ slug, intake, config, startedAt, plugin, intakeSource });
|
|
337
|
+
const launch = buildLaunchRecord({
|
|
338
|
+
slug, runId: receipt.run_id, autoLevel: auto_level, answers: config.gate_answers ?? undefined, models,
|
|
339
|
+
maxRounds: config.max_rounds, attemptBudget: config.attempt_budget,
|
|
340
|
+
pluginRoot: plugin.root, startedAt,
|
|
341
|
+
noEval: args.noEval, noQa: args.noQa, maxParallelScopes: args.parallelScopes ?? undefined,
|
|
671
342
|
});
|
|
343
|
+
const unlaunchable = launchProblems(launch);
|
|
344
|
+
if (unlaunchable.length) fail(2, `✋ init-run: the launch record is not launchable — ${unlaunchable.join("; ")}`);
|
|
672
345
|
|
|
673
|
-
mkdirSync(runRoot, { recursive: true });
|
|
674
|
-
mkdirSync(join(runRoot, "orders"), { recursive: true });
|
|
675
|
-
mkdirSync(join(runRoot, "results"), { recursive: true });
|
|
676
|
-
mkdirSync(join(runRoot, "discovery"), { recursive: true });
|
|
677
|
-
|
|
678
|
-
// The intake, verbatim. So "the spec was dropped on the hand-off" is a checkable claim.
|
|
346
|
+
for (const d of ["", "orders", "results", "discovery"]) mkdirSync(join(runRoot, d), { recursive: true });
|
|
679
347
|
writeFileSync(join(runRoot, "intake.md"), intake.endsWith("\n") ? intake : intake + "\n", "utf8");
|
|
680
|
-
// The breadboard, verbatim — byte for byte, so the receipt's digest is the file's. A copy left by a
|
|
681
|
-
// run this one replaces goes first: a re-open without a breadboard must not inherit the last one.
|
|
682
|
-
rmSync(stagedBreadboard(cwd, slug), { force: true });
|
|
683
|
-
if (bb?.path) writeFileSync(stagedBreadboard(cwd, slug), bb.text, "utf8");
|
|
684
348
|
writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n", "utf8");
|
|
685
|
-
|
|
349
|
+
// The launch record, in the same act as the receipt and from the same parsed argv.
|
|
350
|
+
writeFileSync(runArgsPath(cwd, slug), JSON.stringify(launch, null, 2) + "\n", "utf8");
|
|
686
351
|
|
|
687
|
-
// The pointer every downstream guard reads to answer "is a run active?".
|
|
688
352
|
const pointer = activeScope(cwd);
|
|
689
353
|
mkdirSync(dirname(pointer), { recursive: true });
|
|
690
354
|
writeFileSync(pointer, JSON.stringify({ slug, started_at: startedAt }, null, 2) + "\n", "utf8");
|
|
691
355
|
|
|
692
|
-
// GATE L0
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
// the answer set the run was configured with (a preset name or a file), else the interactive
|
|
696
|
-
// defaults. Best-effort: a row that cannot be written must not fail the opening.
|
|
356
|
+
// GATE L0 has a deterministic call site: the run's opening. The intake conversation is the L0
|
|
357
|
+
// decision; opening the run records it, with the answer set the run was configured with. Best
|
|
358
|
+
// effort: a row that cannot be written must not fail the opening.
|
|
697
359
|
try {
|
|
698
360
|
const ga = config.gate_answers ?? null;
|
|
699
361
|
const presetName = ga && PRESETS[ga] ? ga : null;
|
|
700
362
|
const found = discover({ cwd, slug, preset: presetName, file: ga && !presetName ? ga : null });
|
|
701
|
-
// A preset or file answers L0 for the run; a run with neither — the interactive lane, where the
|
|
702
|
-
// tech lead held the intake conversation before opening it — has that conversation as its L0
|
|
703
|
-
// decision, and the opening records it as such. An answer set that says `ask` is recorded as
|
|
704
|
-
// `ask`: the row states what the set said, and the intake note says what happened.
|
|
705
363
|
const r = found.error
|
|
706
364
|
? { status: "ok", decision: "proceed", source: "intake (no answer set on disk)", note: "the intake conversation is the L0 decision" }
|
|
707
365
|
: resolveGate(found.set, "L0", found.source);
|
|
@@ -715,20 +373,14 @@ export function cli(rawArgv) {
|
|
|
715
373
|
console.log(JSON.stringify({
|
|
716
374
|
ok: true,
|
|
717
375
|
slug,
|
|
718
|
-
// Echoed so the orchestrator can put it in RunArgs without re-reading the receipt.
|
|
719
376
|
run_id: receipt.run_id,
|
|
720
377
|
run_root: globLocal(slug),
|
|
721
378
|
receipt: globLocal(slug, "receipt.json"),
|
|
722
379
|
intake_sha256: receipt.intake_sha256,
|
|
723
380
|
intake_chars: receipt.intake_chars,
|
|
724
|
-
// The staged breadboard the planning dispatches are handed, or null when the pitch has none
|
|
725
|
-
// separate (`breadboard_source` then says "embedded" or null).
|
|
726
|
-
breadboard: bb?.path ? globLocal(slug, "breadboard.md") : null,
|
|
727
|
-
breadboard_source: bb?.source ?? null,
|
|
728
381
|
config,
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
next: "GATE L0 —
|
|
382
|
+
launch_record: globLocal(slug, "run-args.json"),
|
|
383
|
+
launch,
|
|
384
|
+
next: "GATE L0 — confirm the run config with the PO, then orient.",
|
|
732
385
|
}, null, 2));
|
|
733
386
|
}
|
|
734
|
-
|