@agentsfleet/orly 0.10.14 → 0.12.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/.githooks/pre-push +2 -1
- package/AGENTS.md +21 -31
- package/README.md +94 -28
- package/SOUL.md +13 -23
- package/audits/agents-md.md +67 -57
- package/audits/deinit-pairs.sh +13 -18
- package/audits/design-tokens.sh +9 -23
- package/audits/doc-read.sh +10 -33
- package/audits/doc-reads.ts +41 -0
- package/audits/error-codes.sh +18 -25
- package/audits/logging-rust.awk +145 -0
- package/audits/logging.sh +10 -166
- package/audits/msid-ui.sh +45 -18
- package/audits/rule-ledger-lib.sh +9 -6
- package/audits/rust-error.sh +20 -15
- package/audits/scope.sh +88 -0
- package/audits/spec-template.sh +2 -2
- package/audits/spec-template.ts +1 -1
- package/audits/sql-mod.sh +11 -14
- package/audits/ufs-strings.awk +164 -0
- package/audits/ufs.sh +10 -188
- package/core/operating-model.md +8 -8
- package/dispatch/edit_rules.md +8 -6
- package/dispatch/lib.sh +41 -26
- package/dispatch/lifecycle.md +12 -5
- package/dispatch/name_architecture.md +8 -3
- package/dispatch/verify.md +1 -1
- package/dispatch/write_any.md +10 -3
- package/dispatch/write_changelog.md +7 -4
- package/dispatch/write_go.md +1 -1
- package/dispatch/write_http.md +5 -7
- package/dispatch/write_python.md +1 -1
- package/dispatch/write_rust.md +17 -1
- package/dispatch/write_shell.md +1 -1
- package/dispatch/write_spec.md +3 -1
- package/dispatch/write_sql.md +28 -6
- package/dispatch/write_ts_adhere_bun.md +11 -0
- package/dispatch/write_zig.md +53 -21
- package/docs/CHANGELOG.md +10 -0
- package/docs/CHANGELOG_VOICE.md +11 -2
- package/docs/EXECUTE_DOC_READS.md +10 -10
- package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
- package/docs/JUDGMENTS.md +156 -0
- package/docs/LIFECYCLE_PATTERNS.md +26 -16
- package/docs/LOGGING_STANDARD.md +39 -19
- package/docs/ORLY_ARCHITECTURE.md +77 -6
- package/docs/RELEASE_TEMPLATE.md +21 -5
- package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
- package/docs/RULE_ENFORCEMENT.md +6 -6
- package/docs/SCHEMA_CONVENTIONS.md +24 -3
- package/docs/TEMPLATE.md +4 -4
- package/docs/VERIFY_TIERS.md +2 -2
- package/docs/greptile-learnings/RULES.md +81 -26
- package/llms.txt +46 -34
- package/package.json +6 -1
- package/packs/language/go/rules.md +1 -1
- package/packs/language/python/rules.md +1 -1
- package/packs/language/rust/rules.md +17 -1
- package/packs/language/shell/rules.md +1 -1
- package/registry.json +22 -7
- package/schemas/profile.schema.json +48 -5
- package/skills/orly-babysit-prs/SKILL.md +87 -34
- package/skills/orly-spec-new/SKILL.md +19 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -4
- package/skills/orly-write-unit-test/SKILL.md +10 -1
- package/src/cli.test.ts +2 -2
- package/src/cli.ts +19 -75
- package/src/cli_help.ts +69 -0
- package/src/command_limits.ts +20 -0
- package/src/command_process.test.ts +109 -0
- package/src/command_process.ts +88 -0
- package/src/command_runner.test.ts +88 -0
- package/src/command_runner.ts +33 -0
- package/src/comprehension_cache.test.ts +123 -0
- package/src/config.test.ts +5 -4
- package/src/config.ts +26 -164
- package/src/config_discovery.ts +159 -0
- package/src/config_validation.test.ts +56 -0
- package/src/config_validation.ts +25 -0
- package/src/criteria.test.ts +1 -1
- package/src/criteria.ts +10 -26
- package/src/criteria_spec.ts +12 -20
- package/src/criteria_support.ts +2 -19
- package/src/doc_reads.test.ts +42 -0
- package/src/doc_rules.ts +2 -2
- package/src/evaluation_resources.test.ts +64 -0
- package/src/execution/config.ts +22 -0
- package/src/execution/plan.test.ts +65 -0
- package/src/execution/plan.ts +51 -0
- package/src/gates.ts +9 -6
- package/src/gates_spec.test.ts +52 -6
- package/src/gates_test_support.ts +9 -7
- package/src/install.test.ts +38 -222
- package/src/install.ts +117 -308
- package/src/install_loaders.test.ts +203 -0
- package/src/install_nested.test.ts +2 -2
- package/src/install_packs.test.ts +66 -6
- package/src/installation/hooks.ts +63 -0
- package/src/installation/lock.test.ts +97 -0
- package/src/installation/lock.ts +56 -0
- package/src/installation/migration.test.ts +212 -0
- package/src/installation/migration.ts +65 -0
- package/src/installation/plan.ts +96 -0
- package/src/installation/transaction.ts +126 -0
- package/src/installation_preservation.test.ts +48 -0
- package/src/judgments/advice.test.ts +38 -0
- package/src/judgments/advice.ts +32 -0
- package/src/judgments/command.integration.test.ts +47 -0
- package/src/judgments/command.ts +90 -0
- package/src/judgments/constants.ts +55 -0
- package/src/judgments/evidence.test.ts +48 -0
- package/src/judgments/evidence.ts +34 -0
- package/src/judgments/files.test.ts +62 -0
- package/src/judgments/files.ts +73 -0
- package/src/judgments/questions.test.ts +19 -0
- package/src/judgments/questions.ts +85 -0
- package/src/judgments/replay.test.ts +43 -0
- package/src/judgments/replay.ts +66 -0
- package/src/judgments/runner.integration.test.ts +165 -0
- package/src/judgments/runner.ts +97 -0
- package/src/judgments/scan.test.ts +19 -0
- package/src/judgments/scan.ts +40 -0
- package/src/judgments/syntax.test.ts +115 -0
- package/src/judgments/syntax.ts +71 -0
- package/src/judgments/syntax_worker.ts +70 -0
- package/src/judgments/test_support.ts +60 -0
- package/src/judgments/transport.integration.test.ts +63 -0
- package/src/judgments/transport.ts +35 -0
- package/src/judgments/types.test.ts +43 -0
- package/src/judgments/types.ts +66 -0
- package/src/judgments/wire.test.ts +48 -0
- package/src/judgments/wire.ts +40 -0
- package/src/loaders.test.ts +13 -16
- package/src/loaders.ts +28 -10
- package/src/model.test.ts +20 -3
- package/src/model.ts +7 -18
- package/src/pack_hygiene.test.ts +42 -1
- package/src/pack_hygiene.ts +18 -8
- package/src/references.ts +15 -7
- package/src/release_inventory.test.ts +80 -0
- package/src/render.test.ts +1 -1
- package/src/render.ts +3 -8
- package/src/required_scanner.test.ts +36 -0
- package/src/review_poll.test.ts +158 -0
- package/src/scanner_scope.test.ts +202 -0
- package/src/setup.test.ts +9 -6
- package/src/spec_baseline.test.ts +1 -1
- package/src/spec_completion.test.ts +74 -0
- package/src/spec_obligations.ts +64 -0
- package/src/spec_rehearsal.test.ts +182 -0
- package/src/spec_template_io.test.ts +11 -9
- package/src/surfaces.test.ts +2 -0
- package/src/surfaces.ts +1 -1
- package/src/validation.ts +7 -4
- package/src/verify.test.ts +18 -2
- package/src/verify.ts +7 -3
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { commandLimitsSchema } from "./command_limits";
|
|
4
|
+
import { OrlyError, type JsonObject } from "./model";
|
|
5
|
+
|
|
6
|
+
const SCHEMA_VERSION = 1;
|
|
7
|
+
const DIGEST_PATTERN = /^(?:sha256:)?[a-f0-9]{64}$/;
|
|
8
|
+
const VERSION_PATTERN = /^$|^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/;
|
|
9
|
+
const nonempty = z.string().min(1).refine((value) => !value.includes("\0"));
|
|
10
|
+
const configurationSchema = z.object({
|
|
11
|
+
schema_version: z.literal(SCHEMA_VERSION),
|
|
12
|
+
orly_version: z.string().regex(VERSION_PATTERN).optional(),
|
|
13
|
+
packs: z.array(nonempty).optional(),
|
|
14
|
+
commands: z.unknown().optional(),
|
|
15
|
+
surfaces: z.unknown().optional(),
|
|
16
|
+
managed: z.array(nonempty).optional(),
|
|
17
|
+
digests: z.record(nonempty, z.string().regex(DIGEST_PATTERN)).optional(),
|
|
18
|
+
execution: z.unknown().optional(),
|
|
19
|
+
limits: z.record(nonempty, commandLimitsSchema).optional(),
|
|
20
|
+
}).strict();
|
|
21
|
+
|
|
22
|
+
export function validateConfiguration(value: JsonObject): void {
|
|
23
|
+
const parsed = configurationSchema.safeParse(value);
|
|
24
|
+
if (!parsed.success) throw new OrlyError(`.orly/orly.json invalid repository configuration: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ")}`);
|
|
25
|
+
}
|
package/src/criteria.test.ts
CHANGED
|
@@ -159,7 +159,7 @@ describe("repo.config", () => {
|
|
|
159
159
|
test("a repository with no config names the install command", async () => {
|
|
160
160
|
const project = newSpecRepository();
|
|
161
161
|
const context = { root: project, model: await modelFor(project), acceptDirty: false };
|
|
162
|
-
rmSync(join(project, ".
|
|
162
|
+
rmSync(join(project, ".orly/orly.json"));
|
|
163
163
|
const criterion = criteriaFor(WORK, context).find((entry) => entry.name === REPO_CONFIG);
|
|
164
164
|
const verdict = criterion?.evaluate(context);
|
|
165
165
|
expect(verdict?.ok).toBeFalse();
|
package/src/criteria.ts
CHANGED
|
@@ -12,16 +12,16 @@ import { documentationSurfaces, scanSurfaces } from "./doc_rules";
|
|
|
12
12
|
import { isObject, JsonObject, objectValue, OrlyError, RulesModel } from "./model";
|
|
13
13
|
import { readConfigSync, RepoConfig } from "./config";
|
|
14
14
|
import { classifyBranch, SurfaceReport } from "./surfaces";
|
|
15
|
+
import { selectedCommands, CONDITIONAL_LANES, CONFORM_TIER, FAST_TIER, ALL_TIER, type CommandTier } from "./execution/plan";
|
|
15
16
|
import { commandSetupErrors } from "./validation";
|
|
17
|
+
import { limitsFor, type CommandLimits } from "./command_limits";
|
|
16
18
|
|
|
17
19
|
export type { Criterion, CriterionContext, CriterionResult, Verdict };
|
|
18
20
|
export { runCommand };
|
|
19
21
|
|
|
20
22
|
const DEFAULT_BRANCHES = ["master", "main"];
|
|
21
|
-
const CONFORM_COMMAND = "conform";
|
|
22
|
-
const VERIFY_PREFIX = "verify.";
|
|
23
23
|
const REPOSITORIES_LABEL = "repositories";
|
|
24
|
-
const UNINSTALLED = "no .
|
|
24
|
+
const UNINSTALLED = "no .orly/orly.json here — run `orly init` first";
|
|
25
25
|
const REV_PARSE = "rev-parse";
|
|
26
26
|
const HEAD = "HEAD";
|
|
27
27
|
const UPSTREAM = "@{upstream}";
|
|
@@ -45,15 +45,6 @@ const NAMED_FINDINGS = 3;
|
|
|
45
45
|
// rule audit could only run beside the lint and unit suites, so a repository
|
|
46
46
|
// paid minutes to learn something its seconds-long conform command already
|
|
47
47
|
// knew, and `orly gate` ran it twice on the way to `pr`.
|
|
48
|
-
const CONFORM_TIER = "conform";
|
|
49
|
-
const FAST_TIER = "fast";
|
|
50
|
-
const SLOW_TIER = "slow";
|
|
51
|
-
// The slow tier is a fixed name set, not a prefix rule: lint and version
|
|
52
|
-
// checks are verify.* too, and demoting them to skip-on-prose would be wrong.
|
|
53
|
-
const SLOW_COMMANDS = ["verify.integration", "verify.memory"];
|
|
54
|
-
const UNIT_COMMAND = "verify.unit";
|
|
55
|
-
const ALL_TIER = "all";
|
|
56
|
-
|
|
57
48
|
// Commit checks read the index. Push checks permit in-flight Sections and omit
|
|
58
49
|
// the boundary test suites. The final gate runs all declared verification
|
|
59
50
|
// itself, including checks a custom repository hook may not have invoked.
|
|
@@ -115,30 +106,23 @@ function repositoryConfig(): Criterion {
|
|
|
115
106
|
|
|
116
107
|
// Commands and source paths are repository-owned. Only integration and memory
|
|
117
108
|
// checks are conditional on code; every other declared lane always runs.
|
|
118
|
-
function commandCriteria(context: CriterionContext, tier:
|
|
109
|
+
function commandCriteria(context: CriterionContext, tier: CommandTier): Criterion[] {
|
|
119
110
|
const config = resolvedConfig(context);
|
|
120
111
|
if (!config) return [];
|
|
121
112
|
const commands = config.commands;
|
|
122
|
-
const selected =
|
|
113
|
+
const selected = selectedCommands(commands, tier);
|
|
123
114
|
return selected.map((key) => criterion(`cmd.${key}`, (inner) => {
|
|
124
|
-
if (
|
|
115
|
+
if (CONDITIONAL_LANES.includes(key) && report(inner, config.surfaces).code.length === 0) {
|
|
125
116
|
return { ok: true, detail: "skipped — no code files on this branch" };
|
|
126
117
|
}
|
|
127
|
-
return runInvocations(inner.root, commands[key]);
|
|
118
|
+
return runInvocations(inner.root, commands[key], limitsFor(config.limits, key));
|
|
128
119
|
}));
|
|
129
120
|
}
|
|
130
121
|
|
|
131
|
-
function tierOf(key: string): string {
|
|
132
|
-
if (key === CONFORM_COMMAND) return CONFORM_TIER;
|
|
133
|
-
if (key === UNIT_COMMAND || SLOW_COMMANDS.includes(key)) return SLOW_TIER;
|
|
134
|
-
if (key.startsWith(VERIFY_PREFIX)) return FAST_TIER;
|
|
135
|
-
return "";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
122
|
function docsUpdated(): Criterion {
|
|
139
123
|
return criterion(DOCS_UPDATED, (context) => {
|
|
140
124
|
const config = resolvedConfig(context);
|
|
141
|
-
if (!config?.surfaces) return { ok: true, detail: "no user surface declared in .
|
|
125
|
+
if (!config?.surfaces) return { ok: true, detail: "no user surface declared in .orly/orly.json" };
|
|
142
126
|
const surfaces = report(context, config.surfaces);
|
|
143
127
|
if (surfaces.userSurface.length === 0) return { ok: true, detail: "no user-surface files on this branch" };
|
|
144
128
|
if (surfaces.docs.length > 0) return { ok: true, detail: `${surfaces.userSurface.length} user-surface file(s), ${surfaces.docs.length} docs file(s) updated` };
|
|
@@ -191,13 +175,13 @@ function resolvedConfig(context: CriterionContext): RepoConfig | undefined {
|
|
|
191
175
|
}
|
|
192
176
|
}
|
|
193
177
|
|
|
194
|
-
function runInvocations(root: string, invocations: unknown): Verdict {
|
|
178
|
+
function runInvocations(root: string, invocations: unknown, limits: CommandLimits): Verdict {
|
|
195
179
|
if (!Array.isArray(invocations) || invocations.length === 0) return { ok: false, detail: "command group is empty" };
|
|
196
180
|
const evidence: string[] = [];
|
|
197
181
|
for (const invocation of invocations) {
|
|
198
182
|
if (!Array.isArray(invocation) || invocation.length === 0) return { ok: false, detail: "command invocation is empty" };
|
|
199
183
|
const argv = invocation.map((argument) => String(argument));
|
|
200
|
-
const result = runCommand(root, argv);
|
|
184
|
+
const result = runCommand(root, argv, limits);
|
|
201
185
|
evidence.push(`${argv.join(" ")} -> ${result.detail}`);
|
|
202
186
|
if (!result.ok) return { ok: false, detail: evidence.join(NEWLINE) };
|
|
203
187
|
}
|
package/src/criteria_spec.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { readConfigSync } from "./config";
|
|
4
4
|
import { Criterion, CriterionContext, criterion, gitOutput, runCommand, Verdict } from "./criteria_support";
|
|
5
5
|
import { branchDiff, classifyBranch, defaultMergeBase } from "./surfaces";
|
|
6
|
+
import { specDimensionsOf, unacknowledgedObligations } from "./spec_obligations";
|
|
6
7
|
|
|
7
8
|
const BASELINE_HEADER = "Test Baseline";
|
|
8
9
|
const REVISION_HEADER = "Baseline revision";
|
|
@@ -14,8 +15,8 @@ const NOT_APPLICABLE = /^n\/a\s+[—-]\s+\S/i;
|
|
|
14
15
|
|
|
15
16
|
const OPEN_QUESTION = "[?]";
|
|
16
17
|
const PRODUCT_CLARITY_HEADING = "## Product Clarity";
|
|
17
|
-
const DIMENSION_PREFIX = "- **Dimension ";
|
|
18
18
|
const DONE_MARKER = "DONE";
|
|
19
|
+
const ITEM_SEPARATOR = ", ";
|
|
19
20
|
const SPEC_GATE_SCRIPT = "audits/spec-template.sh";
|
|
20
21
|
const NO_SPEC_SKIP = "skipped — no active spec (quality gates still apply)";
|
|
21
22
|
|
|
@@ -27,10 +28,6 @@ const SPEC_MOVED = "spec.moved";
|
|
|
27
28
|
const SPEC_BASELINE = "spec.baseline";
|
|
28
29
|
const SPEC_ORDERING = "spec.ordering";
|
|
29
30
|
const SPEC_DEFERRALS = "spec.deferrals";
|
|
30
|
-
const STATUS_DONE = "Status: DONE";
|
|
31
|
-
const INDY_ACK = "> Indy (";
|
|
32
|
-
// deferred/deferral(s) only — never Zig's defer/errdefer keywords.
|
|
33
|
-
const DEFERRAL_CLAIM = /\bdeferr(ed|al|als)\b/i;
|
|
34
31
|
const SPEC_TREE_FILE = /^docs\/v[^/]+\/.+\.md$/;
|
|
35
32
|
|
|
36
33
|
// Wrap a spec-reading criterion: no active spec → skip-pass with the reason
|
|
@@ -43,7 +40,7 @@ function specCriterion(name: string, evaluate: (context: CriterionContext) => Ve
|
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
export function specGate(): Criterion {
|
|
46
|
-
return specCriterion(SPEC_GATE, (context) => runCommand(context.root, ["bash", SPEC_GATE_SCRIPT
|
|
43
|
+
return specCriterion(SPEC_GATE, (context) => runCommand(context.root, ["bash", context.root === context.model.root ? SPEC_GATE_SCRIPT : `.orly/${SPEC_GATE_SCRIPT}`, "--file", context.specPath ?? ""]));
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
export function openQuestions(): Criterion {
|
|
@@ -62,13 +59,14 @@ export function productClarity(): Criterion {
|
|
|
62
59
|
|
|
63
60
|
export function specDimensions(): Criterion {
|
|
64
61
|
return specCriterion(SPEC_DIMENSIONS, (context) => {
|
|
65
|
-
const dimensions =
|
|
66
|
-
|
|
62
|
+
const dimensions = specDimensionsOf(context.specText ?? "");
|
|
63
|
+
if (!dimensions.length) return { ok: false, detail: "no Dimensions declared in Sections" };
|
|
64
|
+
const open = dimensions.filter((dimension) => !dimension.done);
|
|
67
65
|
return {
|
|
68
66
|
ok: open.length === 0,
|
|
69
67
|
detail: open.length === 0
|
|
70
68
|
? `${dimensions.length} dimension(s) marked ${DONE_MARKER}`
|
|
71
|
-
: `${open.length} of ${dimensions.length} not ${DONE_MARKER}: ${
|
|
69
|
+
: `${open.length} of ${dimensions.length} not ${DONE_MARKER}: ${open.map((dimension) => dimension.id).join(ITEM_SEPARATOR)}`,
|
|
72
70
|
};
|
|
73
71
|
});
|
|
74
72
|
}
|
|
@@ -134,27 +132,21 @@ export function specOrdering(): Criterion {
|
|
|
134
132
|
// the spec; agent-unilateral deferral is incomplete scope, not deferral.
|
|
135
133
|
export function specDeferrals(): Criterion {
|
|
136
134
|
return specCriterion(SPEC_DEFERRALS, (context) => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
? { ok: true, detail: `${claims.length} deferral line(s), Indy ack quote present` }
|
|
142
|
-
: { ok: false, detail: `${claims.length} deferral line(s) with no "${INDY_ACK}" ack quote — agent-unilateral deferral is incomplete scope` };
|
|
135
|
+
const missing = unacknowledgedObligations(context.specText ?? "");
|
|
136
|
+
return missing.length === 0
|
|
137
|
+
? { ok: true, detail: "named deferrals and transfers have item-bound owner quotes in Discovery; quote provenance and scope require review" }
|
|
138
|
+
: { ok: false, detail: `${missing.join(ITEM_SEPARATOR)} has no item-bound Indy acknowledgement in Discovery — agent-unilateral deferral is incomplete scope` };
|
|
143
139
|
});
|
|
144
140
|
}
|
|
145
141
|
|
|
146
142
|
function statusDone(context: CriterionContext): boolean {
|
|
147
|
-
return
|
|
143
|
+
return header(context.specText ?? "", "Status") === DONE_MARKER;
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
function specLines(context: CriterionContext): string[] {
|
|
151
147
|
return (context.specText ?? "").split(/\r?\n/);
|
|
152
148
|
}
|
|
153
149
|
|
|
154
|
-
function dimensionLabels(lines: string[]): string {
|
|
155
|
-
return lines.map((line) => line.trim().slice(DIMENSION_PREFIX.length).split("*")[0]?.trim() ?? "?").join(", ");
|
|
156
|
-
}
|
|
157
|
-
|
|
158
150
|
function baselineEvidence(context: CriterionContext): Verdict {
|
|
159
151
|
const text = context.specText ?? "";
|
|
160
152
|
const revision = header(text, REVISION_HEADER);
|
package/src/criteria_support.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
|
2
2
|
import { RulesModel } from "./model";
|
|
3
3
|
import { SurfaceReport } from "./surfaces";
|
|
4
4
|
|
|
5
|
+
export { runCommand } from "./command_runner";
|
|
6
|
+
|
|
5
7
|
const PIPE_OUTPUT = "pipe";
|
|
6
|
-
const NO_OUTPUT = "no output";
|
|
7
8
|
|
|
8
9
|
export type Verdict = { ok: boolean; detail: string };
|
|
9
10
|
export type CriterionResult = Verdict & { name: string };
|
|
@@ -30,24 +31,6 @@ export function criterion(name: string, evaluate: (context: CriterionContext) =>
|
|
|
30
31
|
return { name, evaluate: (context) => ({ name, ...evaluate(context) }) };
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export function runCommand(root: string, command: string[]): Verdict {
|
|
34
|
-
// Profile command surfaces are repository-declared, so a named binary may
|
|
35
|
-
// simply not be installed here. Bun.spawnSync THROWS in that case; a gate
|
|
36
|
-
// must report every criterion as a verdict, never abort the run, so the
|
|
37
|
-
// missing binary becomes a red line naming the command.
|
|
38
|
-
let result;
|
|
39
|
-
try {
|
|
40
|
-
result = Bun.spawnSync(command, { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
|
|
41
|
-
} catch (error) {
|
|
42
|
-
return { ok: false, detail: `${command[0] ?? "command"} could not be run: ${error instanceof Error ? error.message : String(error)}` };
|
|
43
|
-
}
|
|
44
|
-
const output = [result.stdout.toString(), result.stderr.toString()].filter(Boolean).join("\n").trim();
|
|
45
|
-
if (output) return { ok: result.exitCode === 0, detail: `exit ${result.exitCode}:\n${output}` };
|
|
46
|
-
return result.exitCode === 0
|
|
47
|
-
? { ok: true, detail: "exit 0" }
|
|
48
|
-
: { ok: false, detail: `exit ${result.exitCode}: ${NO_OUTPUT}` };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
34
|
export function gitOutput(root: string, command: string[]): string {
|
|
52
35
|
const result = Bun.spawnSync(["git", ...command], { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
|
|
53
36
|
return result.exitCode === 0 ? result.stdout.toString().trim() : "";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { cleanupTemporaryDirectories, newRepository, ROOT } from "./gates_test_support";
|
|
6
|
+
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
7
|
+
|
|
8
|
+
const READ_LOG = ".git/orly/doc-reads.jsonl";
|
|
9
|
+
const CITATION = 'Quoted "section", tab\tline\nnext\rline and control\u0001 with \\ slash.';
|
|
10
|
+
const AUDIT_SCRIPT = "audits/doc-read.sh";
|
|
11
|
+
const READ_HELPER = "audits/doc-reads.ts";
|
|
12
|
+
|
|
13
|
+
afterEach(cleanupTemporaryDirectories);
|
|
14
|
+
|
|
15
|
+
for (const filename of ['we"ird.md', "tab\tpage.md", "line\npage.md", "control\u0001page.md"]) {
|
|
16
|
+
test(`a document read round-trips the path ${JSON.stringify(filename)} and complete citation`, () => {
|
|
17
|
+
const root = newRepository();
|
|
18
|
+
writeFileSync(join(root, filename), "Read this rule.\n");
|
|
19
|
+
const result = Bun.spawnSync(["bash", join(ROOT, AUDIT_SCRIPT), "log", filename, CITATION], { cwd: root, env: { ...UNSCOPED_ENVIRONMENT, ORLY_ROOT: root } });
|
|
20
|
+
expect(result.exitCode).toBe(0);
|
|
21
|
+
const rows = readFileSync(join(root, READ_LOG), "utf8").trimEnd().split("\n").map((line) => JSON.parse(line));
|
|
22
|
+
expect(rows).toHaveLength(1);
|
|
23
|
+
expect(rows[0].path).toBe(filename);
|
|
24
|
+
expect(rows[0].section).toBe(CITATION);
|
|
25
|
+
const current = Bun.spawnSync([process.execPath, join(ROOT, READ_HELPER), "current", join(root, READ_LOG), root]);
|
|
26
|
+
expect(current.exitCode).toBe(0);
|
|
27
|
+
const fields = current.stdout.toString().trimEnd().split("\t");
|
|
28
|
+
expect(fields).toHaveLength(3);
|
|
29
|
+
expect(JSON.parse(`"${fields[0]}"`)).toBe(filename);
|
|
30
|
+
expect(JSON.parse(`"${fields[2]}"`)).toBe(CITATION);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("malformed saved rows cannot invent a current document read", () => {
|
|
35
|
+
const root = newRepository();
|
|
36
|
+
mkdirSync(join(root, ".git/orly"));
|
|
37
|
+
writeFileSync(join(root, READ_LOG), 'broken JSON\n{"path":"README.md","blob":"bad","ts":"not a time","section":null}\n');
|
|
38
|
+
appendFileSync(join(root, READ_LOG), JSON.stringify({ path: "../../outside", blob: "abc", ts: 1, section: "invented" }) + "\n");
|
|
39
|
+
const result = Bun.spawnSync([process.execPath, join(ROOT, READ_HELPER), "current", join(root, READ_LOG), root]);
|
|
40
|
+
expect(result.exitCode).toBe(0);
|
|
41
|
+
expect(result.stdout.toString()).toBe("");
|
|
42
|
+
});
|
package/src/doc_rules.ts
CHANGED
|
@@ -53,7 +53,7 @@ function isRulePage(file: string): boolean {
|
|
|
53
53
|
return RULE_PAGE_FILES.includes(name);
|
|
54
54
|
}
|
|
55
55
|
const README = "README.md";
|
|
56
|
-
const CONFIG_RELATIVE_PATH = ".
|
|
56
|
+
const CONFIG_RELATIVE_PATH = ".orly/orly.json";
|
|
57
57
|
const REGISTRY_FILE = "registry.json";
|
|
58
58
|
const CODE_PLACEHOLDER = "Code";
|
|
59
59
|
const EMPTY = "";
|
|
@@ -130,7 +130,7 @@ const RULE_NAMES: Record<string, string> = {
|
|
|
130
130
|
};
|
|
131
131
|
|
|
132
132
|
// The pages orly renders into a repository, plus its readme. The materialised
|
|
133
|
-
// set is authoritative and it is recorded, not guessed: `.
|
|
133
|
+
// set is authoritative and it is recorded, not guessed: `.orly/orly.json`
|
|
134
134
|
// names every file orly wrote here. In the engine's own checkout the registry
|
|
135
135
|
// names the sources those files are rendered from, which is the same set one
|
|
136
136
|
// step earlier. README.md joins either list by the scope rule in
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { cleanupTemporaryDirectories, ROOT, temporaryDirectory } from "./gates_test_support";
|
|
6
|
+
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
7
|
+
|
|
8
|
+
const BASH = "bash";
|
|
9
|
+
const PIPE = "pipe";
|
|
10
|
+
const OWNER_FILE = "owner.txt";
|
|
11
|
+
const OWNER_CONTENT = "caller-owned bytes\n";
|
|
12
|
+
const LEDGER_LIBRARY = "evals/ledger/lib.sh";
|
|
13
|
+
const TEST_TIMEOUT_MS = 20_000;
|
|
14
|
+
const INTERRUPTED_EXIT = 143;
|
|
15
|
+
const FAILURE_EXIT = 7;
|
|
16
|
+
|
|
17
|
+
afterEach(cleanupTemporaryDirectories);
|
|
18
|
+
|
|
19
|
+
function environment(root: string) {
|
|
20
|
+
writeFileSync(join(root, OWNER_FILE), OWNER_CONTENT);
|
|
21
|
+
return { ...UNSCOPED_ENVIRONMENT, TMPDIR: root, LEDGER_LIBRARY: join(ROOT, LEDGER_LIBRARY) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function expectClean(root: string) {
|
|
25
|
+
expect(readdirSync(root)).toEqual([OWNER_FILE]);
|
|
26
|
+
expect(readFileSync(join(root, OWNER_FILE), "utf8")).toBe(OWNER_CONTENT);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const script of ["evals/ledger/run.sh", "evals/dispatch/parity.sh"]) {
|
|
30
|
+
test(`evaluation cleanup preserves caller state after ${script}`, () => {
|
|
31
|
+
const root = temporaryDirectory();
|
|
32
|
+
const result = Bun.spawnSync([BASH, join(ROOT, script)], { cwd: ROOT, env: environment(root), stdout: PIPE, stderr: PIPE, timeout: TEST_TIMEOUT_MS });
|
|
33
|
+
expect(result.exitCode).toBe(0);
|
|
34
|
+
expect(result.stdout.toString()).toContain("passed, 0 failed");
|
|
35
|
+
expectClean(root);
|
|
36
|
+
}, TEST_TIMEOUT_MS);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("ledger command-substitution fixtures are removed after a failing caller", () => {
|
|
40
|
+
const root = temporaryDirectory();
|
|
41
|
+
const result = Bun.spawnSync([BASH, "-c", 'source "$LEDGER_LIBRARY"; sb="$(mk_root)"; [[ -d "$sb" ]] || exit 1; exit 7'], { env: environment(root), stdout: PIPE, stderr: PIPE });
|
|
42
|
+
expect(result.exitCode).toBe(FAILURE_EXIT);
|
|
43
|
+
expectClean(root);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("ledger command-substitution fixtures are removed after termination", async () => {
|
|
47
|
+
const root = temporaryDirectory();
|
|
48
|
+
const child = Bun.spawn([BASH, "-c", 'source "$LEDGER_LIBRARY"; sb="$(mk_root)"; printf "%s\\n" "$sb"; read -r pending'], { env: environment(root), stdin: PIPE, stdout: PIPE, stderr: PIPE });
|
|
49
|
+
try {
|
|
50
|
+
const reader = child.stdout.getReader();
|
|
51
|
+
const ready = await reader.read();
|
|
52
|
+
expect(ready.done).toBeFalse();
|
|
53
|
+
const fixture = new TextDecoder().decode(ready.value).trim();
|
|
54
|
+
expect(existsSync(fixture)).toBeTrue();
|
|
55
|
+
expect(fixture.startsWith(root)).toBeTrue();
|
|
56
|
+
child.kill("SIGTERM");
|
|
57
|
+
expect(await child.exited).toBe(INTERRUPTED_EXIT);
|
|
58
|
+
expectClean(root);
|
|
59
|
+
} finally {
|
|
60
|
+
child.stdin.end();
|
|
61
|
+
child.kill();
|
|
62
|
+
await child.exited;
|
|
63
|
+
}
|
|
64
|
+
}, TEST_TIMEOUT_MS);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { OrlyError } from "../model";
|
|
3
|
+
|
|
4
|
+
export const DISABLED_MODE = "disabled";
|
|
5
|
+
const MAX_ARGUMENTS = 32;
|
|
6
|
+
const MAX_ARGUMENT_LENGTH = 1024;
|
|
7
|
+
const argument = z.string().min(1).max(MAX_ARGUMENT_LENGTH).refine((value) => !value.includes("\0"));
|
|
8
|
+
const executionSchema = z.object({
|
|
9
|
+
remote: z.object({
|
|
10
|
+
mode: z.literal(DISABLED_MODE),
|
|
11
|
+
launcher: z.array(argument).min(1).max(MAX_ARGUMENTS).optional(),
|
|
12
|
+
}).strict(),
|
|
13
|
+
}).strict();
|
|
14
|
+
|
|
15
|
+
export type ExecutionConfig = z.infer<typeof executionSchema>;
|
|
16
|
+
|
|
17
|
+
export function readExecution(value: unknown): ExecutionConfig | undefined {
|
|
18
|
+
if (value === undefined) return undefined;
|
|
19
|
+
const parsed = executionSchema.safeParse(value);
|
|
20
|
+
if (!parsed.success) throw new OrlyError("execution.remote requires mode=disabled and an optional nonempty launcher argument array; remote activation is unsupported");
|
|
21
|
+
return parsed.data;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readConfig, seedConfig, writeConfig } from "../config";
|
|
4
|
+
import { cleanupTemporaryDirectories, newRepository, ROOT } from "../gates_test_support";
|
|
5
|
+
import { runGate } from "../gates";
|
|
6
|
+
import { RulesModel } from "../model";
|
|
7
|
+
import { readExecution } from "./config";
|
|
8
|
+
import { lifecyclePlan } from "./plan";
|
|
9
|
+
|
|
10
|
+
afterEach(cleanupTemporaryDirectories);
|
|
11
|
+
|
|
12
|
+
test("lifecycle_plan_names_actual_commands", async () => {
|
|
13
|
+
const repo = newRepository();
|
|
14
|
+
const config = await seedConfig(repo);
|
|
15
|
+
config.commands = { conform: [["true"]], "verify.lint": [["true"]], "verify.unit": [["false"]], "verify.integration": [["false"]] };
|
|
16
|
+
await writeConfig(repo, config);
|
|
17
|
+
const plan = lifecyclePlan(repo);
|
|
18
|
+
const verify = plan.stages.find((stage) => stage.name === "VERIFY")!;
|
|
19
|
+
expect("pushCommands" in verify && verify.pushCommands?.map((command) => command.lane)).toEqual(["verify.lint"]);
|
|
20
|
+
expect("boundaryCommands" in verify && verify.boundaryCommands?.map((command) => command.lane)).toEqual(["verify.integration", "verify.lint", "verify.unit"]);
|
|
21
|
+
expect(plan.remote.evidence).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("disabled_adapter_has_no_side_effects", async () => {
|
|
25
|
+
const repo = newRepository();
|
|
26
|
+
const config = await seedConfig(repo);
|
|
27
|
+
const sentinel = join(repo, "worker-ran");
|
|
28
|
+
config.execution = { remote: { mode: "disabled", launcher: ["touch", sentinel] } };
|
|
29
|
+
config.commands = { conform: [["false"]], "verify.unit": [["true"]] };
|
|
30
|
+
await writeConfig(repo, config);
|
|
31
|
+
expect(lifecyclePlan(repo).remote.launches).toBe(0);
|
|
32
|
+
const result = runGate(await RulesModel.load(ROOT), repo, "work", true);
|
|
33
|
+
expect(result.ok).toBe(false);
|
|
34
|
+
expect(result.results.find((row) => row.name === "cmd.conform")?.ok).toBe(false);
|
|
35
|
+
expect(await Bun.file(sentinel).exists()).toBe(false);
|
|
36
|
+
expect((await readConfig(repo))?.execution).toEqual(config.execution);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test.each([
|
|
40
|
+
null, {}, { remote: {} }, { remote: { mode: "enabled" } },
|
|
41
|
+
{ remote: { mode: "disabled", launcher: "agentsfleet run" } },
|
|
42
|
+
{ remote: { mode: "disabled", launcher: [] } },
|
|
43
|
+
{ remote: { mode: "disabled", launcher: [""] } },
|
|
44
|
+
{ remote: { mode: "disabled", launcher: ["x\0y"] } },
|
|
45
|
+
{ remote: { mode: "disabled", activate: true } },
|
|
46
|
+
])("remote_adapter_is_disabled rejects malformed or active configuration: %j", (input) => {
|
|
47
|
+
expect(() => readExecution(input)).toThrow("remote activation is unsupported");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("disabled_adapter_never_becomes_pass", async () => {
|
|
51
|
+
const repo = newRepository();
|
|
52
|
+
const { configPath } = await import("../config");
|
|
53
|
+
const config = await seedConfig(repo);
|
|
54
|
+
await Bun.write(configPath(repo), JSON.stringify({ ...config, execution: { remote: { mode: "enabled" } } }));
|
|
55
|
+
expect(() => lifecyclePlan(repo)).toThrow("remote activation is unsupported");
|
|
56
|
+
const result = runGate(await RulesModel.load(ROOT), repo, "work", true);
|
|
57
|
+
expect(result.ok).toBe(false);
|
|
58
|
+
expect(result.results.find((row) => row.name === "repo.config")?.detail).toContain("remote activation is unsupported");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("missing adapter defaults to disabled and missing repository fails", () => {
|
|
62
|
+
expect(readExecution(undefined)).toBeUndefined();
|
|
63
|
+
expect(readExecution({ remote: { mode: "disabled" } })).toEqual({ remote: { mode: "disabled" } });
|
|
64
|
+
expect(() => lifecyclePlan(join(newRepository(), "uninstalled"))).toThrow("run orly init");
|
|
65
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readConfigSync, type RepoConfig } from "../config";
|
|
2
|
+
import { OrlyError } from "../model";
|
|
3
|
+
import { DISABLED_MODE } from "./config";
|
|
4
|
+
|
|
5
|
+
export const CONFORM_TIER = "conform";
|
|
6
|
+
export const FAST_TIER = "fast";
|
|
7
|
+
export const ALL_TIER = "all";
|
|
8
|
+
const COMMANDER = "commander";
|
|
9
|
+
const LOCAL = "local";
|
|
10
|
+
const VERIFY_PREFIX = "verify.";
|
|
11
|
+
const UNIT = "verify.unit";
|
|
12
|
+
export const CONDITIONAL_LANES = ["verify.integration", "verify.memory"];
|
|
13
|
+
export type CommandTier = typeof CONFORM_TIER | typeof FAST_TIER | typeof ALL_TIER;
|
|
14
|
+
|
|
15
|
+
// Used by both inspection and the actual gates so their command sets agree.
|
|
16
|
+
export function selectedCommands(commands: RepoConfig["commands"], tier: CommandTier): string[] {
|
|
17
|
+
return Object.keys(commands).filter((key) => {
|
|
18
|
+
if (tier === CONFORM_TIER) return key === CONFORM_TIER;
|
|
19
|
+
if (!key.startsWith(VERIFY_PREFIX)) return false;
|
|
20
|
+
return tier === ALL_TIER || (key !== UNIT && !CONDITIONAL_LANES.includes(key));
|
|
21
|
+
}).sort();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function lifecyclePlan(root: string) {
|
|
25
|
+
const config = readConfigSync(root);
|
|
26
|
+
if (!config) throw new OrlyError("lifecycle inspection requires repository configuration; run orly init");
|
|
27
|
+
const commands = (tier: CommandTier) => selectedCommands(config.commands, tier).map((lane) => ({
|
|
28
|
+
lane, argv: config.commands[lane], location: LOCAL, conditionalOnCode: CONDITIONAL_LANES.includes(lane),
|
|
29
|
+
}));
|
|
30
|
+
return {
|
|
31
|
+
schema_version: 1,
|
|
32
|
+
remote: { mode: DISABLED_MODE, launcher: config.execution?.remote.launcher ?? null, evidence: null, launches: 0 },
|
|
33
|
+
stages: [
|
|
34
|
+
stage("CHORE(open)", COMMANDER, "Create the scoped stream and record the comparison revision.", "Spec, branch and workspace; commander removes the owned workspace after landing."),
|
|
35
|
+
stage("PLAN", COMMANDER, "Read requirements and triggered rules; resolve scope and required evidence.", "Spec and agent context; no build prescribed."),
|
|
36
|
+
stage("EXECUTE", COMMANDER, "Implement and test the current Section.", "Source and tool-owned outputs; declared tools own their cleanup."),
|
|
37
|
+
{ ...stage("CONFORM", "orly gate work", "Run declared conformity commands.", "Command-owned caches and outputs."), commands: commands(CONFORM_TIER) },
|
|
38
|
+
{ ...stage("VERIFY", "orly gate verify / orly gate pr", "Push runs fast checks; the pull-request boundary runs every verification lane.", "Test outputs and comparison workspace; commander removes its baseline workspace."), pushCommands: commands(FAST_TIER), boundaryCommands: commands(ALL_TIER) },
|
|
39
|
+
stage("REVIEW", COMMANDER, "Run required adversarial review and resolve findings.", "Review receipts and bounded optional judgment replay."),
|
|
40
|
+
stage("DOCUMENT", COMMANDER, "Update affected user instructions and evidence.", "Tracked documentation."),
|
|
41
|
+
stage("COMMIT", "Git hooks", "Run installed work hook; push runs installed verify hook.", "Git objects; repository hooks may declare additional checks."),
|
|
42
|
+
stage("CHORE(close)", "commander / orly gate pr", "Close the spec only when scope and required evidence are complete.", "Committed spec and pull-request evidence."),
|
|
43
|
+
stage("LAND", COMMANDER, "After authorized merge, update the default branch and remove owned stream resources.", "Repository-specific shutdown and worktree cleanup."),
|
|
44
|
+
],
|
|
45
|
+
limits: "Inspection executes no commands. Command disk usage is tool-owned and is not measured or bounded by this plan. Remote delegation is disabled and supplies no check result.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stage(name: string, executor: string, work: string, resources: string) {
|
|
50
|
+
return { name, executor, work, resources, location: LOCAL };
|
|
51
|
+
}
|
package/src/gates.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type GateName = (typeof GATE_ORDER)[number];
|
|
|
11
11
|
|
|
12
12
|
const PIPE_OUTPUT = "pipe";
|
|
13
13
|
const GIT = "git";
|
|
14
|
+
const CURRENT_BRANCH_ARGUMENTS = ["rev-parse", "--abbrev-ref", "HEAD"];
|
|
14
15
|
const DOCS_DIRECTORY = "docs";
|
|
15
16
|
const ACTIVE_DIRECTORY = "active";
|
|
16
17
|
const DONE_DIRECTORY = "done";
|
|
@@ -23,7 +24,7 @@ const UTF8 = "utf8";
|
|
|
23
24
|
const PROTOTYPE_PATTERN = /^v\d+(\.\d+)*$/;
|
|
24
25
|
const MARKDOWN_EXTENSION = ".md";
|
|
25
26
|
const NEWLINE = "\n";
|
|
26
|
-
const MARKDOWN_HEADER_TOKEN_PATTERN = /^\s*\*\*([^*]+):\*\*\s
|
|
27
|
+
const MARKDOWN_HEADER_TOKEN_PATTERN = /^\s*\*\*([^*]+):\*\*\s*(?:`([^`]+)`|([^\s`]+))\s*$/;
|
|
27
28
|
const SPEC_IDENTIFIER_PATTERN = /^(M\d+_\d+)(?:_|\.md$)/;
|
|
28
29
|
// Strict trailer shape: "Orly-Override: <criterion> (<reason>)". A trailer
|
|
29
30
|
// that does not parse is not an override — the gate stays red rather than
|
|
@@ -110,10 +111,12 @@ export function branchOverrides(root: string): Override[] {
|
|
|
110
111
|
// Folded-into — the same relation closedSpecPath enforces in done/. Without
|
|
111
112
|
// this, folding a workstream into an open stream was impossible during the
|
|
112
113
|
// work and legal only after the close, which is the wrong way round: the fold
|
|
113
|
-
// is decided when the scope is, not when the spec moves.
|
|
114
|
+
// is decided when the scope is, not when the spec moves. Specs explicitly
|
|
115
|
+
// owned by another branch are excluded; undeclared ownership stays visible.
|
|
114
116
|
export function activeSpecPath(root: string): string | undefined {
|
|
117
|
+
const branch = gitOutput(root, CURRENT_BRANCH_ARGUMENTS);
|
|
115
118
|
const specs = specPathsUnder(root, ACTIVE_DIRECTORY).map(specMetadata)
|
|
116
|
-
.filter((spec): spec is SpecMetadata => spec !== undefined);
|
|
119
|
+
.filter((spec): spec is SpecMetadata => spec !== undefined && (spec.branch === undefined || spec.branch === branch));
|
|
117
120
|
if (specs.length === 0) return undefined;
|
|
118
121
|
return owningSpec(specs, "active spec", "active specs").path;
|
|
119
122
|
}
|
|
@@ -140,7 +143,7 @@ function owningSpec(specs: SpecMetadata[], one: string, many: string): SpecMetad
|
|
|
140
143
|
// be end-state theater. Discovery matches done/ specs whose Branch: header
|
|
141
144
|
// names the current branch; default branches never match a stream's spec.
|
|
142
145
|
export function closedSpecPath(root: string): string | undefined {
|
|
143
|
-
const branch = gitOutput(root,
|
|
146
|
+
const branch = gitOutput(root, CURRENT_BRANCH_ARGUMENTS);
|
|
144
147
|
if (!branch || DEFAULT_BRANCHES.includes(branch)) return undefined;
|
|
145
148
|
const specs = specPathsUnder(root, DONE_DIRECTORY)
|
|
146
149
|
.map(specMetadata)
|
|
@@ -168,8 +171,8 @@ function specMetadata(path: string): SpecMetadata | undefined {
|
|
|
168
171
|
for (const line of readFileSync(path, UTF8).split(/\r?\n/)) {
|
|
169
172
|
if (line.startsWith("## ")) break;
|
|
170
173
|
const match = line.match(MARKDOWN_HEADER_TOKEN_PATTERN);
|
|
171
|
-
if (match?.[1] === BRANCH_HEADER) metadata.branch = match[2];
|
|
172
|
-
if (match?.[1] === FOLDED_INTO_HEADER) metadata.foldedInto = match[2];
|
|
174
|
+
if (match?.[1] === BRANCH_HEADER) metadata.branch = match[2] ?? match[3];
|
|
175
|
+
if (match?.[1] === FOLDED_INTO_HEADER) metadata.foldedInto = match[2] ?? match[3];
|
|
173
176
|
}
|
|
174
177
|
return metadata;
|
|
175
178
|
} catch {
|