shapeup-sdlc 1.6.3 → 3.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 +5 -5
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +54 -107
- package/README.md +165 -151
- package/SECURITY.md +49 -27
- package/bin/init.mjs +93 -108
- package/bin/lib/grant.mjs +145 -0
- package/commands/build.md +20 -0
- package/commands/eval.md +5 -4
- package/commands/scopes.md +5 -4
- package/commands/shape.md +1 -1
- package/commands/ship.md +53 -7
- package/commands/wire.md +1 -1
- package/hooks/dispatch-receipt.mjs +195 -0
- package/hooks/gate-intake.mjs +16 -17
- package/hooks/gate-zerowork.mjs +107 -25
- package/hooks/hooks.json +9 -48
- package/hooks/lib/decision.mjs +38 -19
- package/hooks/safety-spine.mjs +4 -4
- package/hooks/sandbox-guard.mjs +130 -50
- package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +344 -55
- package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +69 -26
- package/kernel/harness.mjs +134 -0
- package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +25 -22
- package/kernel/init/run.mjs +489 -0
- package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
- package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +261 -17
- package/kernel/lib/paths.mjs +491 -0
- package/kernel/probe/concurrency.mjs +510 -0
- package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +10 -11
- package/kernel/probe/eval.mjs +77 -0
- package/kernel/probe/leg.mjs +125 -0
- package/kernel/probe/resume.mjs +528 -0
- package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +102 -21
- package/kernel/probe/t0.mjs +66 -0
- package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +104 -28
- package/kernel/reduce/graph.mjs +437 -0
- package/kernel/reduce/hill.mjs +152 -0
- package/kernel/reduce/ingest.mjs +633 -0
- package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
- package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +93 -14
- package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +22 -21
- package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +14 -8
- package/kernel/report/export.mjs +325 -0
- package/kernel/report/facts.mjs +347 -0
- package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +22 -25
- package/kernel/verify/dispatch.mjs +114 -0
- package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
- package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
- package/kernel/verify/skills.mjs +125 -0
- package/kernel/verify/spec.mjs +559 -0
- package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +138 -28
- package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +28 -13
- package/oracles/_shared.mjs +1 -1
- package/oracles/http-oracle.mjs +2 -2
- package/oracles/index.mjs +1 -1
- package/oracles/process-oracle.mjs +2 -2
- package/oracles/snapshot-oracle.mjs +2 -2
- package/oracles/test-oracle.mjs +2 -2
- package/package.json +11 -13
- package/skills/ba-pitch-analyzer/SKILL.md +24 -19
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +3 -1
- package/skills/ba-pitch-analyzer/references/task-generation.md +8 -8
- package/skills/ba-pitch-analyzer/references/test-surface.md +1 -1
- package/skills/coach/SKILL.md +3 -3
- package/skills/orient/SKILL.md +2 -1
- package/skills/qa-edge-hunter/SKILL.md +15 -41
- package/skills/scope-architect/SKILL.md +57 -18
- package/skills/scope-hammer/SKILL.md +5 -5
- package/skills/shapeup/SKILL.md +3 -3
- package/skills/shapeup/resources/context-compaction.md +4 -3
- package/skills/solution-architect/SKILL.md +37 -15
- package/skills/spec-evaluator/SKILL.md +24 -7
- package/skills/spec-evaluator/references/dimensions/_registry.md +2 -2
- package/skills/spec-evaluator/references/dimensions/completeness.md +1 -1
- package/skills/spec-evaluator/references/dimensions/integration.md +98 -67
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +4 -4
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +1 -1
- package/skills/spec-evaluator/references/probing.md +42 -23
- package/skills/spec-evaluator/references/report-schema.md +2 -2
- package/skills/task-executor/SKILL.md +32 -18
- package/skills/tech-lead/SKILL.md +127 -438
- package/skills/tech-lead/references/gates.md +140 -49
- package/skills/tech-lead/references/protocol.md +832 -0
- package/skills/tech-lead/schemas/domain.schema.json +645 -198
- package/skills/tech-lead/schemas/gate-answers.schema.json +2 -2
- package/skills/tech-lead/schemas/work-order.schema.json +11 -2
- package/skills/tech-lead/schemas/work-result.schema.json +56 -18
- package/skills/tech-lead/workflows/shapeup-run.js +1474 -0
- package/skills/translator/SKILL.md +2 -2
- package/hooks/anti-rationalization.mjs +0 -244
- package/hooks/compact-snapshot.mjs +0 -47
- package/hooks/gate-deadline.mjs +0 -151
- package/hooks/gate-l2.mjs +0 -161
- package/hooks/session-rehydrate.mjs +0 -109
- package/skills/advisor-protocol/SKILL.md +0 -171
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
- package/skills/spec-evaluator/README.md +0 -93
- package/skills/tech-lead/README.md +0 -71
- package/skills/tech-lead/references/delegation.md +0 -254
- package/skills/tech-lead/references/invocation.md +0 -45
- package/skills/tech-lead/references/ledger-schema.md +0 -214
- package/skills/tech-lead/references/round-protocol.md +0 -184
- package/skills/tech-lead/references/state-model.md +0 -66
- package/skills/tech-lead/scripts/ingest-result.mjs +0 -270
- package/skills/tech-lead/scripts/init-run.mjs +0 -326
- package/skills/tech-lead/scripts/lib/is-main.mjs +0 -82
- package/skills/tech-lead/scripts/lib/paths.mjs +0 -278
- package/skills/translator/README.md +0 -66
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GATE L0.1 — START THE RUN. The orchestrator's first tool call, before any prose.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS (reproduced, not theorized).
|
|
5
|
+
//
|
|
6
|
+
// Dispatched with a valid spec, the orchestrator did this:
|
|
7
|
+
//
|
|
8
|
+
// TOOL Skill(tech-lead, "--unattended --rounds 3\n\n# the requirement…")
|
|
9
|
+
// TEXT "The tech-lead skill is orchestrating the full Shape Up harness. It will: 1. …"
|
|
10
|
+
// FINAL (same text — session ends)
|
|
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.
|
|
40
|
+
//
|
|
41
|
+
// USAGE
|
|
42
|
+
// node `harness init run` --slug <slug> --intake-file <path> [options] <- prefer this
|
|
43
|
+
// node `harness init run` --slug <slug> --intake-text "<requirement>" [options]
|
|
44
|
+
// cat spec.md | node `harness init run` --slug <slug> --intake-stdin [options]
|
|
45
|
+
//
|
|
46
|
+
// PREFER --intake-file. A multi-line requirement inlined into a shell argument is where this step
|
|
47
|
+
// goes wrong: quoting breaks, a `#` after a newline trips path validation, and the run spends six
|
|
48
|
+
// turns fighting its own command line instead of starting. Observed, repeatedly.
|
|
49
|
+
//
|
|
50
|
+
// --auto-level interactive | auto | unattended (default: interactive)
|
|
51
|
+
// --lens lite | standard | cross-context (default: standard)
|
|
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: spec-conformance)
|
|
56
|
+
// --gate-answers path | preset name (see `harness gate`; recorded, not read)
|
|
57
|
+
// --wall-clock-budget N deadline breaker, seconds (off by default; see `harness verify budget`)
|
|
58
|
+
// --cwd project root (default: process.cwd())
|
|
59
|
+
// --force re-init over an existing run receipt
|
|
60
|
+
//
|
|
61
|
+
// Prints a JSON receipt on stdout. Exit 0 on success, 2 on a usage error, 3 when a live run
|
|
62
|
+
// already exists and --force was not given.
|
|
63
|
+
//
|
|
64
|
+
// EXIT 3 IS THE RESUME PATH, not a dead end. It prints the file-derived RunSnapshot for the run that
|
|
65
|
+
// is already open — slug, status, round, attempt, board counts, pending orders — so the orchestrator
|
|
66
|
+
// continues from the phase the files report instead of re-opening the run or restarting the pipeline
|
|
67
|
+
// from phase 1. It previously said "Resume it (`--from <slug>`)", and `--from` is a /tech-lead flag
|
|
68
|
+
// that takes a phase, not an init-run flag that takes a slug: the one instruction available at the
|
|
69
|
+
// one moment it mattered named a mechanism that does not parse.
|
|
70
|
+
|
|
71
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from "node:fs";
|
|
72
|
+
import { join, dirname, resolve } from "node:path";
|
|
73
|
+
import { createHash } from "node:crypto";
|
|
74
|
+
import { decideLane, treeSize } from "./fit.mjs";
|
|
75
|
+
import { runArgs } from "../lib/argv.mjs";
|
|
76
|
+
import { uncoerce } from "../lib/contract.mjs";
|
|
77
|
+
import { deriveSnapshot } from "../reduce/snapshot.mjs";
|
|
78
|
+
import { mintRunId } from "../lib/paths.mjs";
|
|
79
|
+
import { localRoot, activeScope, globLocal, globShared, ordersDir, resultsDir } from "../lib/paths.mjs";
|
|
80
|
+
import { resolveWorkers } from "../verify/skills.mjs";
|
|
81
|
+
|
|
82
|
+
export const RECEIPT_VERSION = 1;
|
|
83
|
+
|
|
84
|
+
const AUTO_LEVELS = new Set(["interactive", "auto", "unattended"]);
|
|
85
|
+
const LENSES = new Set(["lite", "standard", "cross-context"]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The eval dimension set when the caller names none. Kept to the base correctness dimension so an
|
|
89
|
+
* unconfigured run behaves exactly as it did before this flag existed.
|
|
90
|
+
*/
|
|
91
|
+
export const DEFAULT_DIMENSIONS = ["spec-conformance"];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse `--dimensions` into the set written to the ledger. Shape-validated only, NOT checked against
|
|
95
|
+
* the dimensions that ship: adding `references/dimensions/<id>.md` and naming it here is the
|
|
96
|
+
* documented injection path, so a closed list here would make the evaluator's own extension point
|
|
97
|
+
* unreachable. An id with no file behind it is skipped-with-a-warning at dimension resolution, which
|
|
98
|
+
* is where that check belongs and where it can actually see the files.
|
|
99
|
+
*
|
|
100
|
+
* @param {(string|null|undefined)} raw - The comma-separated flag value; absent → the default set.
|
|
101
|
+
* @returns {string[]} Trimmed, de-duplicated ids in the caller's order.
|
|
102
|
+
* @throws {Error} If the list is empty or an entry is not a kebab-case id.
|
|
103
|
+
*/
|
|
104
|
+
export function parseDimensions(raw) {
|
|
105
|
+
if (raw === null || raw === undefined) return [...DEFAULT_DIMENSIONS];
|
|
106
|
+
const ids = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
107
|
+
if (!ids.length) throw new Error("--dimensions: empty list — omit the flag to use the default [spec-conformance]");
|
|
108
|
+
for (const id of ids) {
|
|
109
|
+
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(id)) {
|
|
110
|
+
throw new Error(`--dimensions: "${id}" is not a dimension id (kebab-case, e.g. spec-conformance, tdd-surface, integration)`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...new Set(ids)];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Slugify a free-text feature name into a filesystem-safe run id. */
|
|
117
|
+
export function slugify(text) {
|
|
118
|
+
return String(text || "")
|
|
119
|
+
.toLowerCase()
|
|
120
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
121
|
+
.replace(/^-+|-+$/g, "")
|
|
122
|
+
.slice(0, 48) || "run";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function digest(text) {
|
|
126
|
+
return createHash("sha256").update(String(text ?? ""), "utf8").digest("hex");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the receipt record. Pure — takes resolved inputs, returns the object that gets written.
|
|
131
|
+
* Kept separate from I/O so the structural tests can assert its shape without a filesystem.
|
|
132
|
+
*/
|
|
133
|
+
export function buildReceipt({ slug, intake, config, startedAt, plugin = null }) {
|
|
134
|
+
const intakeText = String(intake ?? "");
|
|
135
|
+
const intakeSha256 = digest(intakeText);
|
|
136
|
+
return {
|
|
137
|
+
receipt_version: RECEIPT_VERSION,
|
|
138
|
+
type: "harness-run-receipt",
|
|
139
|
+
slug,
|
|
140
|
+
// THE JOIN KEY, minted here because this is where a run acquires an identity at all.
|
|
141
|
+
//
|
|
142
|
+
// `order_id` was the nearest thing the harness had, and it is `<slug>/r<N>-a<M>`: unique
|
|
143
|
+
// WITHIN a run and identical across every run of the same slug. So every record the pipeline
|
|
144
|
+
// writes — orders, results, journal rows, trial rows, hook receipts — could be grouped by
|
|
145
|
+
// feature and never by RUN, which makes "compare this run against the last one" and "what did
|
|
146
|
+
// this run cost" both unanswerable from data that was otherwise all present.
|
|
147
|
+
//
|
|
148
|
+
// Derived, not drawn (see `mintRunId` in lib/paths.mjs): a pure function of the three fields below
|
|
149
|
+
// it, so any writer holding this receipt recomputes the same id without being handed it, and a
|
|
150
|
+
// receipt written before this field existed still yields the id it would have been given.
|
|
151
|
+
run_id: mintRunId({ slug, startedAt, intakeSha256 }),
|
|
152
|
+
started_at: startedAt,
|
|
153
|
+
intake_sha256: intakeSha256,
|
|
154
|
+
intake_chars: intakeText.length,
|
|
155
|
+
intake_lines: intakeText ? intakeText.split("\n").length : 0,
|
|
156
|
+
// The single fact that separates "the harness ran" from "the harness described itself".
|
|
157
|
+
// Written before any gate, so its ABSENCE at Stop is unambiguous.
|
|
158
|
+
started: true,
|
|
159
|
+
// WHICH COPY OF THE PLUGIN PRODUCED THIS RUN. Recorded while it is in hand, because afterwards
|
|
160
|
+
// it is unrecoverable: every artifact a run leaves looks identical whether it came from this
|
|
161
|
+
// version, a stale marketplace install, or a sub-agent improvising past a failed dispatch. A
|
|
162
|
+
// run whose trace cannot name its own plugin cannot be compared with another run, and cannot be
|
|
163
|
+
// cleared of the wrong-version failure the roster check exists to catch.
|
|
164
|
+
plugin: plugin ? { name: plugin.name, version: plugin.version, root: plugin.root } : null,
|
|
165
|
+
config,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The `harness-run.md` frontmatter block, per references/protocol.md Part 4 — State. */
|
|
170
|
+
export function runFrontmatter({ slug, config, startedAt }) {
|
|
171
|
+
return [
|
|
172
|
+
"---",
|
|
173
|
+
"type: harness-run",
|
|
174
|
+
`feature: ${slug}`,
|
|
175
|
+
`spec_folder: ${config.spec_folder}`,
|
|
176
|
+
`lens: ${config.lens}`,
|
|
177
|
+
// The ledger is the ONE place the dimension set lives: resume-state reads it back off this line
|
|
178
|
+
// and the workflow hands it to every evaluate order. Written through the same list dialect the
|
|
179
|
+
// parser reads (contract-md), never hand-joined.
|
|
180
|
+
`eval_dimensions: ${uncoerce(config.eval_dimensions ?? DEFAULT_DIMENSIONS)}`,
|
|
181
|
+
`max_rounds: ${config.max_rounds}`,
|
|
182
|
+
`attempt_budget: ${config.attempt_budget}`,
|
|
183
|
+
`wall_clock_budget_s: ${config.wall_clock_budget_s ?? "~"}`,
|
|
184
|
+
`auto_level: ${config.auto_level}`,
|
|
185
|
+
`gate_answers: ${config.gate_answers ?? "~"}`,
|
|
186
|
+
`lane: ${config.fit?.lane ?? "full"}${config.fit?.overridden_from ? ` (overridden from ${config.fit.overridden_from})` : ""}`,
|
|
187
|
+
"status: orienting",
|
|
188
|
+
"final_verdict: ~",
|
|
189
|
+
"rounds_used: 0",
|
|
190
|
+
"discovered_rounds: 0",
|
|
191
|
+
"deploy: ~",
|
|
192
|
+
`started_at: ${startedAt}`,
|
|
193
|
+
"closed_at: ~",
|
|
194
|
+
"---",
|
|
195
|
+
"",
|
|
196
|
+
`# Harness run — ${slug}`,
|
|
197
|
+
"",
|
|
198
|
+
"Opened by ``harness init run`` (GATE L0.1). The tech lead is the sole writer from here on.",
|
|
199
|
+
"",
|
|
200
|
+
"## Rounds",
|
|
201
|
+
"",
|
|
202
|
+
"| Phase | Round | Result | Duration | Notes |",
|
|
203
|
+
"|-------|-------|--------|----------|-------|",
|
|
204
|
+
"| Init | — | run opened | — | intake recorded, receipt written |",
|
|
205
|
+
"",
|
|
206
|
+
"## Decisions log",
|
|
207
|
+
"",
|
|
208
|
+
"| Gate | Decision | Source | Note |",
|
|
209
|
+
"|------|----------|--------|------|",
|
|
210
|
+
"",
|
|
211
|
+
].join("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Unwedge `--force`: resolve every order left LIVE by a run nobody is continuing.
|
|
216
|
+
*
|
|
217
|
+
* `hooks/sandbox-guard.mjs`'s `liveOrders()` treats any file under `orders/` with no SAME-NAMED
|
|
218
|
+
* file under `results/` as live, and constrains every later Edit/Write to what some live order's
|
|
219
|
+
* substrate permits. `--force` used to `mkdirSync(..., {recursive:true})` over the same directories
|
|
220
|
+
* and stop — a no-op on dirs that already exist and already hold the stale order, so the wedge
|
|
221
|
+
* survived it exactly as before. This is the real unwedge path: for every order abandoned by the run
|
|
222
|
+
* being forced over, write a same-named record under `results/` so `liveOrders()` no longer counts
|
|
223
|
+
* it, without deleting the order file itself (`orders/` is this codebase's own audit trail of what
|
|
224
|
+
* was dispatched, not a rolling buffer — losing the file loses the record that a dispatch happened).
|
|
225
|
+
*
|
|
226
|
+
* Not a real WorkResult: `work-result.schema.json`'s `status` enum (done/partial/escalated/failed)
|
|
227
|
+
* has no member that honestly means "no worker ever answered" — every one of those values would
|
|
228
|
+
* misrepresent an abandoned dispatch as an attempt that actually ran. So this writes a plainly
|
|
229
|
+
* self-labelled, intentionally non-conforming marker instead of forcing a lie into a schema-valid
|
|
230
|
+
* shape. `liveOrders()` only checks filename presence under `results/`, never content, so this is
|
|
231
|
+
* sufficient to unwedge on its own.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} cwd - Project root.
|
|
234
|
+
* @param {string} slug - The run being forced over.
|
|
235
|
+
* @returns {string[]} Order-id suffixes (filenames minus `.json`) resolved as abandoned.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveAbandonedOrders(cwd, slug) {
|
|
238
|
+
const oDir = ordersDir(cwd, slug);
|
|
239
|
+
const rDir = resultsDir(cwd, slug);
|
|
240
|
+
if (!existsSync(oDir)) return [];
|
|
241
|
+
const done = new Set(existsSync(rDir) ? readdirSync(rDir) : []);
|
|
242
|
+
const resolvedAt = new Date().toISOString();
|
|
243
|
+
const resolved = [];
|
|
244
|
+
for (const f of readdirSync(oDir)) {
|
|
245
|
+
if (!f.endsWith(".json") || done.has(f)) continue;
|
|
246
|
+
mkdirSync(rDir, { recursive: true });
|
|
247
|
+
const marker = {
|
|
248
|
+
synthetic: true,
|
|
249
|
+
status: "abandoned", // not in work-result.schema.json's enum — deliberately: see banner above
|
|
250
|
+
order_id: f.slice(0, -".json".length),
|
|
251
|
+
reason: "dispatched, never answered — resolved by `harness init run --force`",
|
|
252
|
+
resolved_at: resolvedAt,
|
|
253
|
+
};
|
|
254
|
+
writeFileSync(join(rDir, f), JSON.stringify(marker, null, 2) + "\n", "utf8");
|
|
255
|
+
resolved.push(marker.order_id);
|
|
256
|
+
}
|
|
257
|
+
return resolved;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- CLI -------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/** The typed argv contract (see `./lib/argv.mjs`). */
|
|
263
|
+
export const ARGV_SPEC = {
|
|
264
|
+
usage: 'harness.mjs init run (--intake-file <path> | --intake-text "<req>" | --intake-stdin) ' +
|
|
265
|
+
"[--slug <slug>] [--auto-level interactive|auto|unattended] [--lens <lens>] " +
|
|
266
|
+
"[--max-rounds N] [--attempts N] [--spec-folder <dir>] [--dimensions <a,b>] " +
|
|
267
|
+
"[--gate-answers <preset|path>] " +
|
|
268
|
+
"[--lane full|tiny] [--tiny] [--wall-clock-budget <seconds>] [--cwd <dir>] " +
|
|
269
|
+
"[--plugin-root <dir>] [--force]",
|
|
270
|
+
_: { arity: 0, max: 0, name: "(no positional operands)" },
|
|
271
|
+
cwd: { type: "path" },
|
|
272
|
+
"intake-text": { type: "str" },
|
|
273
|
+
"intake-file": { type: "str" }, // "-" is a legitimate value here (stdin), so not type "path"
|
|
274
|
+
"intake-stdin": { type: "flag" },
|
|
275
|
+
slug: { type: "str" },
|
|
276
|
+
"auto-level": { type: "str" },
|
|
277
|
+
lens: { type: "str" },
|
|
278
|
+
"max-rounds": { type: "int", min: 1 },
|
|
279
|
+
attempts: { type: "int", min: 1 },
|
|
280
|
+
"spec-folder": { type: "path" },
|
|
281
|
+
dimensions: { type: "str" },
|
|
282
|
+
"gate-answers": { type: "str" },
|
|
283
|
+
lane: { type: "str" },
|
|
284
|
+
tiny: { type: "flag" },
|
|
285
|
+
"wall-clock-budget": { type: "int", min: 1 },
|
|
286
|
+
// Which copy of the plugin this run is opened against. Defaults to the one this kernel is part
|
|
287
|
+
// of, which is right for every ordinary invocation; it is nameable because a machine can carry
|
|
288
|
+
// several installs, and because the roster refusal below is otherwise unreachable for a test —
|
|
289
|
+
// a check whose failure path cannot be exercised is a check nobody has seen work.
|
|
290
|
+
"plugin-root": { type: "path" },
|
|
291
|
+
force: { type: "flag" },
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
function fail(code, msg) {
|
|
295
|
+
console.error(msg);
|
|
296
|
+
process.exit(code);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Open a run: mint the receipt, or refuse (exit 3) when one is already live.
|
|
301
|
+
*
|
|
302
|
+
* @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
|
|
303
|
+
* @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
|
|
304
|
+
* call `process.exit()` with the subcommand's documented code rather than returning.
|
|
305
|
+
*/
|
|
306
|
+
export function cli(rawArgv) {
|
|
307
|
+
const args = runArgs(ARGV_SPEC, rawArgv);
|
|
308
|
+
const cwd = args.cwd || process.cwd();
|
|
309
|
+
|
|
310
|
+
// GATE L0 — the worker roster, before any spend.
|
|
311
|
+
//
|
|
312
|
+
// HERE AND NOT IN THE WORKFLOW SCRIPT, for two reasons. This is what GATE L0 actually executes,
|
|
313
|
+
// and it covers both lanes rather than only the orchestrated one — the prose/`--tiny` lane opens
|
|
314
|
+
// its run through exactly this call. And it is genuinely before any spend: the workflow script's
|
|
315
|
+
// own helpers reach the kernel by spawning a sub-agent first, so a check placed there has already
|
|
316
|
+
// paid for a model call before it can refuse.
|
|
317
|
+
//
|
|
318
|
+
// What it can and cannot prove is stated in verify/skills.mjs and is worth repeating where it is
|
|
319
|
+
// enforced: this says the files exist at this root at this version. It does not say the SESSION
|
|
320
|
+
// will resolve that copy. The orchestrator's canary dispatch answers that, and its receipt is the
|
|
321
|
+
// evidence; this refusal is the cheap half that costs nothing to run on every single run.
|
|
322
|
+
const plugin = resolveWorkers(args.pluginRoot ? resolve(args.pluginRoot) : undefined);
|
|
323
|
+
if (plugin.missing.length) {
|
|
324
|
+
fail(3, [
|
|
325
|
+
`✋ init-run: refusing to open a run — ${plugin.missing.length} of ${plugin.workers.length} worker skills are missing.`,
|
|
326
|
+
"",
|
|
327
|
+
` plugin: ${plugin.name ?? "unknown"} ${plugin.version ?? "unknown version"}`,
|
|
328
|
+
` root: ${plugin.root}`,
|
|
329
|
+
` missing: ${plugin.missing.join(", ")}`,
|
|
330
|
+
"",
|
|
331
|
+
"A run against this copy would dispatch workers that cannot resolve, and a failed dispatch is",
|
|
332
|
+
"answered by the sub-agent improvising the craft itself — phases reporting complete with none",
|
|
333
|
+
"of the shipped craft applied. Load the working copy (`claude --plugin-dir <repo>`) or install",
|
|
334
|
+
"and enable the plugin, then retry. `harness verify skills` reports the same thing on demand.",
|
|
335
|
+
].join("\n"));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let intake = args.intakeText ?? null;
|
|
339
|
+
const intakeFile = args.intakeFile ?? null;
|
|
340
|
+
// `--intake-file -` is the shape everyone reaches for; accept it rather than erroring on a
|
|
341
|
+
// file literally named "-". (Measured: an agent tried exactly this on its second attempt.)
|
|
342
|
+
if (args.intakeStdin || intakeFile === "-") {
|
|
343
|
+
try { intake = readFileSync(0, "utf8"); } catch { fail(2, "--intake-stdin: nothing on stdin"); }
|
|
344
|
+
} else if (intakeFile) {
|
|
345
|
+
const p = intakeFile.startsWith("/") ? intakeFile : join(cwd, intakeFile);
|
|
346
|
+
if (!existsSync(p)) fail(2, `--intake-file not found: ${p}`);
|
|
347
|
+
intake = readFileSync(p, "utf8");
|
|
348
|
+
}
|
|
349
|
+
if (!intake || !intake.trim()) {
|
|
350
|
+
fail(2, [
|
|
351
|
+
"✋ init-run: no intake. Pass --intake-text \"<the requirement>\" or --intake-file <path>.",
|
|
352
|
+
"",
|
|
353
|
+
"An orchestrator with no spec has nothing to orchestrate — this is the same precondition",
|
|
354
|
+
"GATE L0.0 (hooks/gate-intake.mjs) enforces at dispatch, re-checked here where the run is",
|
|
355
|
+
"actually opened.",
|
|
356
|
+
].join("\n"));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const slug = (args.slug ?? null) || slugify(intake.split("\n").find((l) => l.trim()) || "run");
|
|
360
|
+
|
|
361
|
+
const auto_level = args.autoLevel ?? "interactive";
|
|
362
|
+
if (!AUTO_LEVELS.has(auto_level)) fail(2, `--auto-level must be one of: ${[...AUTO_LEVELS].join(", ")}`);
|
|
363
|
+
const lens = args.lens ?? "standard";
|
|
364
|
+
if (!LENSES.has(lens)) fail(2, `--lens must be one of: ${[...LENSES].join(", ")}`);
|
|
365
|
+
// GATE L0.5 — the eval dimension set, resolved ONCE here and carried by the ledger. Before this
|
|
366
|
+
// flag the line was a constant, so a dimension the PO asked for at L0.5 had nowhere to be
|
|
367
|
+
// recorded and the run graded spec-conformance whatever the answer had been.
|
|
368
|
+
let eval_dimensions;
|
|
369
|
+
try { eval_dimensions = parseDimensions(args.dimensions ?? null); }
|
|
370
|
+
catch (e) { fail(2, e.message); }
|
|
371
|
+
|
|
372
|
+
const config = {
|
|
373
|
+
auto_level,
|
|
374
|
+
lens,
|
|
375
|
+
eval_dimensions,
|
|
376
|
+
max_rounds: args.maxRounds ?? 3,
|
|
377
|
+
attempt_budget: args.attempts ?? 5,
|
|
378
|
+
spec_folder: args.specFolder ?? `${globShared(slug, "spec")}/`,
|
|
379
|
+
gate_answers: args.gateAnswers ?? null,
|
|
380
|
+
tiny_lane: !!args.tiny,
|
|
381
|
+
// GATE L0.3 — the lane, computed rather than judged (see `harness init fit`). Recorded with its
|
|
382
|
+
// evidence so a heavy lane on a small change is visible instead of accidental. An explicit
|
|
383
|
+
// --lane or --tiny is honoured and marked as an override, because a measured recommendation
|
|
384
|
+
// fitted on three features must not outrank a human who knows the codebase.
|
|
385
|
+
fit: (() => {
|
|
386
|
+
const auto = decideLane({ intake, files: treeSize(cwd) });
|
|
387
|
+
const forced = (args.lane ?? null) || (args.tiny ? "tiny" : null);
|
|
388
|
+
return forced && forced !== auto.lane
|
|
389
|
+
? { ...auto, lane: forced, overridden_from: auto.lane, override_source: args.tiny ? "--tiny" : "--lane" }
|
|
390
|
+
: auto;
|
|
391
|
+
})(),
|
|
392
|
+
// The third breaker (see `harness verify budget`). Null = off, which is the default and
|
|
393
|
+
// keeps every existing run behaving exactly as before. Set it in any lane with a hard clock
|
|
394
|
+
// — CI, an overnight run — so the harness trips its own breaker and ships what
|
|
395
|
+
// is green, instead of being killed from outside and shipping nothing.
|
|
396
|
+
wall_clock_budget_s: args.wallClockBudget ?? null,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const runRoot = localRoot(cwd, slug);
|
|
400
|
+
const receiptPath = join(runRoot, "receipt.json");
|
|
401
|
+
// A RUN IS ALREADY OPEN. This is the resume path, and it used to be a dead end.
|
|
402
|
+
//
|
|
403
|
+
// The refusal is right: silently re-initialising would discard the round history the circuit
|
|
404
|
+
// breaker counts against. What was wrong was the instruction it gave — "Resume it (`--from
|
|
405
|
+
// <slug>`)". `--from` is not an init-run flag at all; it is a `/tech-lead` flag, and it takes a
|
|
406
|
+
// PHASE (`--from build`), not a slug. So at the one moment the orchestrator most needs a next
|
|
407
|
+
// step, the runtime named a mechanism that does not exist, on a script whose failure mode was
|
|
408
|
+
// already invisible — under a symlinked install an entry-point guard once skipped this whole body.
|
|
409
|
+
//
|
|
410
|
+
// Observed consequence on a handoff: a fresh session in a workspace with an open run burns most
|
|
411
|
+
// of its budget before its first write, largely on forensics against this step, and closes none
|
|
412
|
+
// of the gap.
|
|
413
|
+
//
|
|
414
|
+
// So the refusal now DOES the resume work instead of describing it. It emits the derived snapshot
|
|
415
|
+
// — the same file-only derivation `harness reduce graph --subgraph run` injects — so the orchestrator gets
|
|
416
|
+
// slug, status, round, attempt, board counts and pending orders in THIS tool call rather than
|
|
417
|
+
// needing to discover that it needs another one. Exit 3 still means "do not proceed as if you
|
|
418
|
+
// opened a run"; it now also means "here is the run you are actually in".
|
|
419
|
+
if (existsSync(receiptPath) && !args.force) {
|
|
420
|
+
let resume = null;
|
|
421
|
+
try { resume = deriveSnapshot(cwd); } catch { /* a broken run must still produce the refusal */ }
|
|
422
|
+
fail(3, [
|
|
423
|
+
`✋ init-run: a run is ALREADY OPEN — receipt exists at ${receiptPath}.`,
|
|
424
|
+
"",
|
|
425
|
+
"Do NOT re-initialise and do NOT restart the pipeline from phase 1. Re-opening would discard",
|
|
426
|
+
"the round history the circuit breaker counts against, and the board, ledger and receipt below",
|
|
427
|
+
"already hold the run's real state. RESUME from the phase these files report.",
|
|
428
|
+
"",
|
|
429
|
+
resume
|
|
430
|
+
? `RESUME STATE (derived from files, never from memory):\n${JSON.stringify(resume, null, 2)}`
|
|
431
|
+
: [
|
|
432
|
+
"The receipt exists but no run state could be derived, which means the run root is",
|
|
433
|
+
"incomplete. Inspect it before deciding:",
|
|
434
|
+
` ls -R ${runRoot}`,
|
|
435
|
+
].join("\n"),
|
|
436
|
+
"",
|
|
437
|
+
"To re-derive this at any time:",
|
|
438
|
+
" node <plugin>/kernel/harness.mjs reduce snapshot --cwd <dir>",
|
|
439
|
+
"To abandon the open run and start over, deliberately: --force",
|
|
440
|
+
].join("\n"));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// THE REAL UNWEDGE PATH. `--force` used to `mkdirSync(..., {recursive:true})` over orders/,
|
|
444
|
+
// results/ and discovery/ and stop — a no-op on directories that already exist and already hold a
|
|
445
|
+
// dispatched-but-unanswered order, so `sandbox-guard.mjs`'s `liveOrders()` kept constraining every
|
|
446
|
+
// later write to that stale order's substrate regardless of `--force`. Resolve every such order
|
|
447
|
+
// BEFORE the fresh run starts writing (see `resolveAbandonedOrders()` above for why this writes a
|
|
448
|
+
// marker under `results/` rather than deleting the order file).
|
|
449
|
+
if (args.force) {
|
|
450
|
+
const abandoned = resolveAbandonedOrders(cwd, slug);
|
|
451
|
+
if (abandoned.length) {
|
|
452
|
+
console.error(
|
|
453
|
+
`⚠ init-run --force: resolved ${abandoned.length} dispatched-but-unanswered order(s) as abandoned — ${abandoned.join(", ")}`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const startedAt = new Date().toISOString();
|
|
459
|
+
const receipt = buildReceipt({ slug, intake, config, startedAt, plugin });
|
|
460
|
+
|
|
461
|
+
mkdirSync(runRoot, { recursive: true });
|
|
462
|
+
mkdirSync(join(runRoot, "orders"), { recursive: true });
|
|
463
|
+
mkdirSync(join(runRoot, "results"), { recursive: true });
|
|
464
|
+
mkdirSync(join(runRoot, "discovery"), { recursive: true });
|
|
465
|
+
|
|
466
|
+
// The intake, verbatim. So "the spec was dropped on the hand-off" is a checkable claim.
|
|
467
|
+
writeFileSync(join(runRoot, "intake.md"), intake.endsWith("\n") ? intake : intake + "\n", "utf8");
|
|
468
|
+
writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n", "utf8");
|
|
469
|
+
writeFileSync(join(runRoot, "harness-run.md"), runFrontmatter({ slug, config, startedAt }), "utf8");
|
|
470
|
+
|
|
471
|
+
// The pointer every downstream guard reads to answer "is a run active?".
|
|
472
|
+
const pointer = activeScope(cwd);
|
|
473
|
+
mkdirSync(dirname(pointer), { recursive: true });
|
|
474
|
+
writeFileSync(pointer, JSON.stringify({ slug, started_at: startedAt }, null, 2) + "\n", "utf8");
|
|
475
|
+
|
|
476
|
+
console.log(JSON.stringify({
|
|
477
|
+
ok: true,
|
|
478
|
+
slug,
|
|
479
|
+
// Echoed so the orchestrator can put it in RunArgs without re-reading the receipt.
|
|
480
|
+
run_id: receipt.run_id,
|
|
481
|
+
run_root: globLocal(slug),
|
|
482
|
+
receipt: globLocal(slug, "receipt.json"),
|
|
483
|
+
intake_sha256: receipt.intake_sha256,
|
|
484
|
+
intake_chars: receipt.intake_chars,
|
|
485
|
+
config,
|
|
486
|
+
next: "GATE L0 — pin the run config, emit the gate block, then ORIENT.",
|
|
487
|
+
}, null, 2));
|
|
488
|
+
}
|
|
489
|
+
|
|
@@ -1,44 +1,27 @@
|
|
|
1
|
-
//
|
|
1
|
+
// argv — the typed CLI boundary: who is running, and with what.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// This project's envelope boundary is rigorously typed: 38 `$defs` in `domain.schema.json`,
|
|
6
|
-
// both directions validated, and a `PreToolUse` hook that DENIES a malformed WorkOrder before a
|
|
7
|
-
// worker ever sees it. That discipline stopped dead at `process.argv` — which is where the
|
|
8
|
-
// pipeline actually executes.
|
|
9
|
-
//
|
|
10
|
-
// The reproduced defect. `t0-verify.mjs` parsed its own flags with
|
|
11
|
-
//
|
|
12
|
-
// if (a === "--round") out.round = Number(argv[++i]); // then: args.round ?? 1
|
|
13
|
-
//
|
|
14
|
-
// `Number(undefined)` is `NaN`, and `??` does not catch `NaN`. So passing a flag WITHOUT a value
|
|
15
|
-
// wrote a real verdict artifact to `t0/verdicts/rNaN-a1.json` and **exited 0**:
|
|
16
|
-
//
|
|
17
|
-
// $ t0-verify.mjs contract.json --round --attempt 1
|
|
18
|
-
// { "path": "t0/verdicts/rNaN-a1.json", "overall": "green", ... } exit=0
|
|
19
|
-
//
|
|
20
|
-
// The orchestrator then looks for `r1-a1.json`, finds nothing, and the evaluator's MANDATORY T0
|
|
21
|
-
// citation cannot resolve — on the single artifact the judge is structurally required to cite.
|
|
22
|
-
// A green verdict at an address nobody will look up is the same failure class as a silent no-op:
|
|
23
|
-
// indistinguishable from working.
|
|
24
|
-
//
|
|
25
|
-
// THE CONTRACT, mirroring `validate-envelope`'s: reject BEFORE anything runs, exit 2, and put a
|
|
3
|
+
// CONTRACT, mirroring the envelope's: reject BEFORE anything runs, exit 2, and put a
|
|
26
4
|
// machine-readable reason on stderr. Nothing here touches the filesystem, so a rejected parse
|
|
27
|
-
// cannot leave a half-written artifact behind.
|
|
5
|
+
// cannot leave a half-written artifact behind. Exit 2 is this plugin's "the input was malformed,
|
|
6
|
+
// nothing ran" code — deliberately NOT 1, which every oracle uses for "ran, and the answer is no".
|
|
28
7
|
//
|
|
29
8
|
// const SPEC = {
|
|
30
9
|
// _: { arity: 1, name: "scope-contract.json" },
|
|
31
10
|
// round: { type: "int", min: 1, required: true },
|
|
32
11
|
// "no-seesaw": { type: "flag" },
|
|
33
12
|
// };
|
|
34
|
-
// const args = runArgs(SPEC,
|
|
13
|
+
// const args = runArgs(SPEC, argv); // args.round, args.noSeesaw, args._
|
|
35
14
|
//
|
|
36
|
-
// Flag names reach the caller camelCased (`--no-seesaw` → `noSeesaw`)
|
|
37
|
-
//
|
|
38
|
-
// a
|
|
15
|
+
// Flag names reach the caller camelCased (`--no-seesaw` → `noSeesaw`). Unknown flags are rejected
|
|
16
|
+
// rather than swallowed as positionals: a typo'd `--rounds 2` landing in `_` is the same defect
|
|
17
|
+
// wearing a different hat. Untyped coercion is the failure this guards — `Number(undefined)` is
|
|
18
|
+
// `NaN`, `??` does not catch `NaN`, and a verdict written to `r NaN-a1.json` with exit 0 is
|
|
19
|
+
// indistinguishable from working.
|
|
39
20
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
21
|
+
// {@link isMain} answers the other half of the boundary question: was this module executed, or
|
|
22
|
+
// imported? Comparing `import.meta.url` to a raw `file://${process.argv[1]}` is false under a
|
|
23
|
+
// symlinked install path (macOS `/var`, nvm, pnpm, Homebrew) and under any path containing a space,
|
|
24
|
+
// which silently turns an entry point into a no-op that still exits 0.
|
|
42
25
|
|
|
43
26
|
/** Thrown by {@link parseArgs} when argv does not satisfy the spec. `detail` is the wire shape. */
|
|
44
27
|
export class ArgvError extends Error {
|
|
@@ -222,3 +205,41 @@ export function runArgs(spec, argv = process.argv.slice(2)) {
|
|
|
222
205
|
process.exit(2);
|
|
223
206
|
}
|
|
224
207
|
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// isMain — "was this module executed directly, or imported?"
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
import { realpathSync } from "node:fs";
|
|
214
|
+
import { pathToFileURL } from "node:url";
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* True when `moduleUrl` belongs to the module Node was asked to execute.
|
|
218
|
+
*
|
|
219
|
+
* Resolves both sides through `pathToFileURL` (so percent-encoding matches) and `realpathSync` (so
|
|
220
|
+
* symlinks match), which is what makes the guard hold where the naive string compare does not.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} moduleUrl - The caller's `import.meta.url`.
|
|
223
|
+
* @returns {boolean} true if executed directly, false if imported (or if there is no entry point,
|
|
224
|
+
* e.g. `node --eval`, where nothing should auto-run).
|
|
225
|
+
*/
|
|
226
|
+
export function isMain(moduleUrl) {
|
|
227
|
+
const entry = process.argv[1];
|
|
228
|
+
if (!entry || !moduleUrl) return false;
|
|
229
|
+
|
|
230
|
+
// Cheap path first: correct encoding, no filesystem access. Handles spaces and unicode.
|
|
231
|
+
let entryUrl;
|
|
232
|
+
try { entryUrl = pathToFileURL(entry).href; } catch { return false; }
|
|
233
|
+
if (entryUrl === moduleUrl) return true;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const realEntry = pathToFileURL(realpathSync(entry)).href;
|
|
237
|
+
if (realEntry === moduleUrl) return true;
|
|
238
|
+
return realEntry === pathToFileURL(realpathSync(new URL(moduleUrl))).href;
|
|
239
|
+
} catch {
|
|
240
|
+
// An unreadable or deleted entry point is not this function's problem to report. Returning
|
|
241
|
+
// false means "do not auto-run", which is the safe direction for an imported module and is
|
|
242
|
+
// never the direction that silently skips a gate.
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|