dev-loops 0.4.0 → 0.6.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/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/commands/auto.md +7 -0
- package/.claude/commands/continue.md +15 -0
- package/.claude/commands/info.md +7 -0
- package/.claude/commands/start-spike.md +16 -0
- package/.claude/commands/start.md +7 -0
- package/.claude/commands/status.md +6 -0
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/skills/dev-loop/SKILL.md +21 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/.claude/skills/local-implementation/SKILL.md +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +21 -1
- package/agents/dev-loop.agent.md +8 -1
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +2 -0
- package/extension/index.ts +10 -1
- package/extension/presentation.ts +15 -0
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +8 -3
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/comment-issue.mjs +181 -0
- package/scripts/github/fetch-ci-logs.mjs +215 -0
- package/scripts/github/list-issues.mjs +191 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-conductor-cycle.mjs +5 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/projects/resolve-active-board-item.mjs +193 -0
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/scaffold-spike-file.mjs +183 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/scripts/release/extract-changelog-section.mjs +111 -0
- package/skills/dev-loop/SKILL.md +24 -2
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/skills/local-implementation/SKILL.md +1 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
|
|
6
|
+
import { buildParseError, formatCliError, parseJsonText } from "../_core-helpers.mjs";
|
|
7
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
8
|
+
import { validatePlanFile } from "./validate-plan-file.mjs";
|
|
9
|
+
import { extractSection, isDirectCliRun } from "./_refine-helpers.mjs";
|
|
10
|
+
import {
|
|
11
|
+
refinePlanFileInPlace,
|
|
12
|
+
PLAN_FILE_REFINE_STOP,
|
|
13
|
+
} from "@dev-loops/core/loop/plan-file-refine-contract";
|
|
14
|
+
import { PLAN_FILE_REFINEMENT_SECTIONS } from "@dev-loops/core/loop/plan-file-intake-contract";
|
|
15
|
+
import { classifyDocsGrillFinding } from "../loop/docs-grill-contract.mjs";
|
|
16
|
+
|
|
17
|
+
const USAGE = `Usage:
|
|
18
|
+
refine-plan-file.mjs --plan-file <path> --payload <path> [--json]
|
|
19
|
+
Refine a local plan file in place: write the refiner-produced Acceptance
|
|
20
|
+
criteria, Definition of done, coverage matrix, and recorded docs-grill
|
|
21
|
+
findings into the plan file, advance the intake state to
|
|
22
|
+
plan_refined_ready_for_promotion, and stop at a local human-review
|
|
23
|
+
checkpoint. Read-only against the tracker: no GitHub calls, no issue/PR/
|
|
24
|
+
comment, no promotion.
|
|
25
|
+
|
|
26
|
+
Required:
|
|
27
|
+
--plan-file <path> Path to the phase-doc-format plan file to refine in place
|
|
28
|
+
--payload <path> Path to a JSON file with the refiner output
|
|
29
|
+
({ acceptanceCriteria, definitionOfDone, coverageMatrix,
|
|
30
|
+
grillFindings? })
|
|
31
|
+
Optional:
|
|
32
|
+
--json Machine-readable JSON output
|
|
33
|
+
--help Show this help
|
|
34
|
+
Exit codes:
|
|
35
|
+
0 Refinement succeeded; plan written in place; stop for local human review
|
|
36
|
+
1 Argument error, or fail-closed refine/grill/ambiguous state (no write)`.trim();
|
|
37
|
+
|
|
38
|
+
const parseError = buildParseError(USAGE);
|
|
39
|
+
|
|
40
|
+
export function parseRefinePlanFileCliArgs(argv) {
|
|
41
|
+
const { tokens } = parseArgs({
|
|
42
|
+
args: [...argv],
|
|
43
|
+
options: {
|
|
44
|
+
help: { type: "boolean", short: "h" },
|
|
45
|
+
"plan-file": { type: "string" },
|
|
46
|
+
payload: { type: "string" },
|
|
47
|
+
json: { type: "boolean" },
|
|
48
|
+
},
|
|
49
|
+
allowPositionals: true,
|
|
50
|
+
strict: false,
|
|
51
|
+
tokens: true,
|
|
52
|
+
});
|
|
53
|
+
const options = { help: false, planFile: undefined, payload: undefined, json: false };
|
|
54
|
+
for (const token of tokens) {
|
|
55
|
+
if (token.kind === "positional") {
|
|
56
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
57
|
+
}
|
|
58
|
+
if (token.kind !== "option") continue;
|
|
59
|
+
if (token.name === "help") {
|
|
60
|
+
options.help = true;
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
if (token.name === "plan-file") {
|
|
64
|
+
options.planFile = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (token.name === "payload") {
|
|
68
|
+
options.payload = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (token.name === "json") {
|
|
72
|
+
options.json = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof options.planFile !== "string" || options.planFile.trim().length === 0) {
|
|
78
|
+
throw parseError("refine-plan-file requires --plan-file <path>");
|
|
79
|
+
}
|
|
80
|
+
if (typeof options.payload !== "string" || options.payload.trim().length === 0) {
|
|
81
|
+
throw parseError("refine-plan-file requires --payload <path>");
|
|
82
|
+
}
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
87
|
+
const options = parseRefinePlanFileCliArgs(argv);
|
|
88
|
+
if (options.help) {
|
|
89
|
+
stdout.write(`${USAGE}\n`);
|
|
90
|
+
return { ok: true, help: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const planPath = path.resolve(options.planFile);
|
|
94
|
+
const markdownText = await readFile(planPath, "utf8");
|
|
95
|
+
const payload = parseJsonText(await readFile(path.resolve(options.payload), "utf8"));
|
|
96
|
+
|
|
97
|
+
// Read the section-presence facts with the P1 surfaces, then hand them to the
|
|
98
|
+
// pure refine contract. The contract owns the state-machine gate and the
|
|
99
|
+
// in-place rewrite; this script owns I/O only.
|
|
100
|
+
// The docs-grill runs as a step of refinement. Classify each finding here (this
|
|
101
|
+
// script owns the scripts/ boundary) with #948's classifier and pass the
|
|
102
|
+
// dispositions to the pure core contract; an invalid finding yields a null
|
|
103
|
+
// disposition, which the contract fails closed on (docs_grill_failed).
|
|
104
|
+
const rawFindings = Array.isArray(payload?.grillFindings) ? payload.grillFindings : [];
|
|
105
|
+
const grillDispositions = rawFindings.map((finding) => {
|
|
106
|
+
const classified = classifyDocsGrillFinding(finding);
|
|
107
|
+
return {
|
|
108
|
+
kind: finding?.kind,
|
|
109
|
+
summary: typeof finding?.summary === "string" ? finding.summary : "",
|
|
110
|
+
disposition: classified.ok ? classified.disposition : null,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
|
|
115
|
+
const result = refinePlanFileInPlace({
|
|
116
|
+
markdownText,
|
|
117
|
+
baseSectionsValid: validatePlanFile(markdownText).ok,
|
|
118
|
+
hasAcceptanceCriteria: extractSection(markdownText, acHeading) ? true : false,
|
|
119
|
+
hasDefinitionOfDone: extractSection(markdownText, dodHeading) ? true : false,
|
|
120
|
+
payload: { ...payload, grillDispositions },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!result.ok) {
|
|
124
|
+
// Fail closed: surface the reason, do not write, do not advance, do not promote.
|
|
125
|
+
if (options.json) {
|
|
126
|
+
stdout.write(`${JSON.stringify({ ok: false, reason: result.reason, planFileIntakeState: result.planFileIntakeState ?? null })}\n`);
|
|
127
|
+
} else {
|
|
128
|
+
stdout.write(`refine-plan-file: FAIL (${result.reason})\n`);
|
|
129
|
+
}
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Write the refined plan back in place — the single canonical artifact.
|
|
135
|
+
await writeFile(planPath, result.refinedMarkdown, "utf8");
|
|
136
|
+
|
|
137
|
+
const summary = {
|
|
138
|
+
ok: true,
|
|
139
|
+
planFile: planPath,
|
|
140
|
+
planFileIntakeState: result.planFileIntakeState,
|
|
141
|
+
stop: result.stop,
|
|
142
|
+
grillDispositions: result.grillDispositions,
|
|
143
|
+
};
|
|
144
|
+
if (options.json) {
|
|
145
|
+
stdout.write(`${JSON.stringify(summary)}\n`);
|
|
146
|
+
} else {
|
|
147
|
+
stdout.write(
|
|
148
|
+
[
|
|
149
|
+
"refine-plan-file: PASS",
|
|
150
|
+
` plan: ${planPath}`,
|
|
151
|
+
` state: ${result.planFileIntakeState}`,
|
|
152
|
+
` stop: ${result.stop.kind} (${PLAN_FILE_REFINE_STOP.LOCAL_HUMAN_REVIEW === result.stop.kind ? "stop for local human review; no tracker artifact created" : result.stop.kind})`,
|
|
153
|
+
` grill findings recorded: ${result.grillDispositions.length}`,
|
|
154
|
+
].join("\n") + "\n",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return summary;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
161
|
+
runCli().catch((error) => {
|
|
162
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Scaffold a STARTABLE spike findings artifact from an inline question (#988 P2).
|
|
3
|
+
//
|
|
4
|
+
// `/start-spike <question>` needs a findings artifact that passes
|
|
5
|
+
// `validateSpikeExplorationSections` (non-empty Question/Approach/Findings) so
|
|
6
|
+
// `resolve-dev-loop-startup --spike <path>` accepts it. This is the only new
|
|
7
|
+
// piece behind /start-spike: a pure section builder + a thin file writer. The
|
|
8
|
+
// Recommendation is left for the spike to fill in (the exit marker), matching
|
|
9
|
+
// the spike-mode contract — so this scaffolds an in-progress spike, not a
|
|
10
|
+
// ready-for-exit one. No new spike behavior; the intake/gate/exit machinery is
|
|
11
|
+
// the shipped #964/#965/#966 surface.
|
|
12
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
|
|
16
|
+
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
17
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
18
|
+
import { SPIKE_FILE_EXPLORATION_SECTIONS } from "./validate-spike-file.mjs";
|
|
19
|
+
|
|
20
|
+
// Placeholder bodies the operator fills in during the spike. Non-empty so the
|
|
21
|
+
// exploration scaffold validates; explicitly marked TBD so they are obviously
|
|
22
|
+
// stubs, not findings. (`Recommendation` is intentionally omitted — it is the
|
|
23
|
+
// exit marker, written when the spike concludes.)
|
|
24
|
+
const PLACEHOLDER = "TBD — filled in during the spike.";
|
|
25
|
+
|
|
26
|
+
const USAGE = `Usage:
|
|
27
|
+
scaffold-spike-file.mjs --question <text> --out <path>
|
|
28
|
+
|
|
29
|
+
Scaffold a startable spike findings artifact from an inline question. Writes a
|
|
30
|
+
file carrying the exploration scaffold (## Question filled from --question,
|
|
31
|
+
## Approach/## Findings stubbed) so \`resolve-dev-loop-startup --spike <path>\`
|
|
32
|
+
accepts it. The ## Recommendation is left for the spike to fill in.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--question <text> Required. The question the spike investigates.
|
|
36
|
+
--out <path> Required. Where to write the findings artifact.
|
|
37
|
+
--help, -h Show this help.
|
|
38
|
+
|
|
39
|
+
Output (stdout):
|
|
40
|
+
JSON: { ok: true, path: "<abs>", question: "<text>" }
|
|
41
|
+
|
|
42
|
+
${JQ_OUTPUT_USAGE}
|
|
43
|
+
`.trim();
|
|
44
|
+
|
|
45
|
+
// Pure builder: question text -> a findings-artifact markdown body carrying the
|
|
46
|
+
// exploration scaffold. Question gets the operator's text; Approach/Findings get
|
|
47
|
+
// non-empty placeholder bodies so the exploration scaffold validates.
|
|
48
|
+
export function buildSpikeScaffold(question) {
|
|
49
|
+
const trimmed = String(question ?? "").trim();
|
|
50
|
+
if (trimmed.length === 0) {
|
|
51
|
+
throw Object.assign(new Error("--question must be non-empty"), { code: "INVALID_ARGS" });
|
|
52
|
+
}
|
|
53
|
+
const bodies = { Question: trimmed, Approach: PLACEHOLDER, Findings: PLACEHOLDER };
|
|
54
|
+
const sections = SPIKE_FILE_EXPLORATION_SECTIONS.map(
|
|
55
|
+
(heading) => `## ${heading}\n\n${bodies[heading]}\n`,
|
|
56
|
+
);
|
|
57
|
+
return `# Spike\n\n${sections.join("\n")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseCliArgs(argv) {
|
|
61
|
+
const parseError = (message) => Object.assign(new Error(message), { usage: USAGE, code: "INVALID_ARGS" });
|
|
62
|
+
const requireValue = (token, message) => {
|
|
63
|
+
const v = token.value;
|
|
64
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
65
|
+
throw parseError(message);
|
|
66
|
+
}
|
|
67
|
+
return v;
|
|
68
|
+
};
|
|
69
|
+
// The question is free text embedded in the artifact body, never forwarded as
|
|
70
|
+
// a CLI option — so unlike --out/--jq it may legitimately begin with `-`
|
|
71
|
+
// (e.g. "- should we ...?"). Only require a non-empty value, matching the
|
|
72
|
+
// core free-text parser; leave the leading-`-` guard to the path/filter flags.
|
|
73
|
+
const requireQuestion = (token, message) => {
|
|
74
|
+
const v = token.value;
|
|
75
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
76
|
+
throw parseError(message);
|
|
77
|
+
}
|
|
78
|
+
return v;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const args = {};
|
|
82
|
+
const { tokens } = parseArgs({
|
|
83
|
+
args: [...argv],
|
|
84
|
+
options: {
|
|
85
|
+
question: { type: "string" },
|
|
86
|
+
out: { type: "string" },
|
|
87
|
+
json: { type: "boolean" },
|
|
88
|
+
help: { type: "boolean", short: "h" },
|
|
89
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
90
|
+
},
|
|
91
|
+
allowPositionals: true,
|
|
92
|
+
strict: false,
|
|
93
|
+
tokens: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
for (const token of tokens) {
|
|
97
|
+
if (token.kind === "positional") {
|
|
98
|
+
throw parseError(`Unexpected argument: ${token.value}`);
|
|
99
|
+
}
|
|
100
|
+
if (token.kind !== "option") {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
switch (token.name) {
|
|
104
|
+
case "help":
|
|
105
|
+
args.help = true;
|
|
106
|
+
break;
|
|
107
|
+
case "json":
|
|
108
|
+
// Output is JSON by default; accepted as a no-op for callers that pass it.
|
|
109
|
+
break;
|
|
110
|
+
case "question":
|
|
111
|
+
args.question = requireQuestion(token, "--question requires a value");
|
|
112
|
+
break;
|
|
113
|
+
case "out":
|
|
114
|
+
args.out = requireValue(token, "--out requires a value (path)");
|
|
115
|
+
break;
|
|
116
|
+
case "jq":
|
|
117
|
+
args.jq = requireValue(token, "--jq requires a filter");
|
|
118
|
+
break;
|
|
119
|
+
case "silent":
|
|
120
|
+
args.silent = true;
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return args;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function main(args, { writeFileImpl = writeFile, mkdirImpl = mkdir } = {}) {
|
|
130
|
+
if (!args.question) {
|
|
131
|
+
throw Object.assign(new Error("--question is required"), { usage: USAGE, code: "INVALID_ARGS" });
|
|
132
|
+
}
|
|
133
|
+
if (!args.out) {
|
|
134
|
+
throw Object.assign(new Error("--out is required"), { usage: USAGE, code: "INVALID_ARGS" });
|
|
135
|
+
}
|
|
136
|
+
const body = buildSpikeScaffold(args.question);
|
|
137
|
+
const outPath = path.resolve(args.out);
|
|
138
|
+
await mkdirImpl(path.dirname(outPath), { recursive: true });
|
|
139
|
+
// Fail closed on an existing file: the slash-command picks a fresh path and
|
|
140
|
+
// must never clobber a spike already in progress. `wx` makes write+exclusive
|
|
141
|
+
// atomic, so a concurrent create still loses (no overwrite).
|
|
142
|
+
try {
|
|
143
|
+
await writeFileImpl(outPath, body, { encoding: "utf8", flag: "wx" });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err?.code === "EEXIST") {
|
|
146
|
+
throw Object.assign(new Error(`--out already exists, refusing to overwrite: ${outPath}`), {
|
|
147
|
+
usage: USAGE,
|
|
148
|
+
code: "INVALID_ARGS",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, path: outPath, question: String(args.question).trim() };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
157
|
+
let args;
|
|
158
|
+
try {
|
|
159
|
+
args = parseCliArgs(argv);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (args.help) {
|
|
166
|
+
stdout.write(`${USAGE}\n`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const result = await main(args);
|
|
171
|
+
process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
|
|
172
|
+
} catch (err) {
|
|
173
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
174
|
+
process.exitCode = err.code === "INVALID_ARGS" ? 1 : 2;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
179
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
180
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
181
|
+
process.exitCode = 2;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { formatCliError } from "../_core-helpers.mjs";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_USAGE_SUFFIX,
|
|
7
|
+
checkBaseSections,
|
|
8
|
+
parseCheckerCliArgs,
|
|
9
|
+
writeCheckerOutput,
|
|
10
|
+
isDirectCliRun,
|
|
11
|
+
} from "./_refine-helpers.mjs";
|
|
12
|
+
|
|
13
|
+
const USAGE = `Usage:
|
|
14
|
+
validate-plan-file.mjs --input <path> [--json]
|
|
15
|
+
Validate the base authoring sections of a phase-doc-format plan file: Status, Objective, In scope, Explicit non-goals.${"\n"}${DEFAULT_USAGE_SUFFIX}`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base authoring sections a phase doc carries before refinement adds AC/DoD.
|
|
19
|
+
* Heading → distinct missing_* error code. Single source of truth: the section
|
|
20
|
+
* list is derived from these keys so the validator and the exported list cannot
|
|
21
|
+
* drift.
|
|
22
|
+
*/
|
|
23
|
+
const SECTION_CODES = {
|
|
24
|
+
Status: "missing_status",
|
|
25
|
+
Objective: "missing_objective",
|
|
26
|
+
"In scope": "missing_in_scope",
|
|
27
|
+
"Explicit non-goals": "missing_explicit_non_goals",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Base authoring section headings, in order (derived from SECTION_CODES). */
|
|
31
|
+
export const PLAN_FILE_BASE_SECTIONS = Object.keys(SECTION_CODES);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pure validator. Reports whether a plan file (phase-doc format) carries every
|
|
35
|
+
* base authoring section with a non-empty body. An absent or empty-body section
|
|
36
|
+
* is reported under that section's distinct missing_* code. No side effects.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} markdownText
|
|
39
|
+
* @returns {{ checker: "validate-plan-file", ok: boolean, errors: { code: string, message: string }[] }}
|
|
40
|
+
*/
|
|
41
|
+
export function validatePlanFile(markdownText) {
|
|
42
|
+
// checkBaseSections reports each absent/empty section under its distinct
|
|
43
|
+
// missing_* code; an empty-body section is malformed here too.
|
|
44
|
+
return checkBaseSections(markdownText, "validate-plan-file", SECTION_CODES);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
48
|
+
const options = parseCheckerCliArgs(argv, USAGE, "validate-plan-file");
|
|
49
|
+
if (options.help) {
|
|
50
|
+
stdout.write(`${USAGE}\n`);
|
|
51
|
+
return { ok: true, help: true };
|
|
52
|
+
}
|
|
53
|
+
const markdownText = await readFile(options.input, "utf8");
|
|
54
|
+
const result = validatePlanFile(markdownText);
|
|
55
|
+
writeCheckerOutput(result, { stdout, json: options.json });
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
60
|
+
runCli().catch((error) => {
|
|
61
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { formatCliError } from "../_core-helpers.mjs";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_USAGE_SUFFIX,
|
|
7
|
+
checkBaseSections,
|
|
8
|
+
parseCheckerCliArgs,
|
|
9
|
+
writeCheckerOutput,
|
|
10
|
+
isDirectCliRun,
|
|
11
|
+
} from "./_refine-helpers.mjs";
|
|
12
|
+
|
|
13
|
+
const USAGE = `Usage:
|
|
14
|
+
validate-spike-file.mjs --input <path> [--json]
|
|
15
|
+
Validate the base authoring sections of a spike artifact: Question, Approach, Findings, Recommendation.${"\n"}${DEFAULT_USAGE_SUFFIX}`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base authoring sections a spike artifact carries.
|
|
19
|
+
* Heading → distinct missing_* error code.
|
|
20
|
+
*/
|
|
21
|
+
const SECTION_CODES = {
|
|
22
|
+
Question: "missing_question",
|
|
23
|
+
Approach: "missing_approach",
|
|
24
|
+
Findings: "missing_findings",
|
|
25
|
+
Recommendation: "missing_recommendation",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const SPIKE_FILE_BASE_SECTIONS = Object.keys(SECTION_CODES);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Recommendation is the exit-marker section: present + non-empty means the
|
|
32
|
+
* spike has reached a recommendation and is ready for a discard/graduate exit
|
|
33
|
+
* decision. The exploration scaffold (Question/Approach/Findings) is what an
|
|
34
|
+
* in-progress spike must carry for `--spike` entry. (Mirrors the plan-file
|
|
35
|
+
* base-vs-refinement-section split.)
|
|
36
|
+
*/
|
|
37
|
+
export const SPIKE_FILE_EXIT_MARKER_SECTION = "Recommendation";
|
|
38
|
+
export const SPIKE_FILE_EXPLORATION_SECTIONS = SPIKE_FILE_BASE_SECTIONS.filter(
|
|
39
|
+
(h) => h !== SPIKE_FILE_EXIT_MARKER_SECTION,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const EXPLORATION_SECTION_CODES = Object.fromEntries(
|
|
43
|
+
SPIKE_FILE_EXPLORATION_SECTIONS.map((h) => [h, SECTION_CODES[h]]),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pure validator. Reports whether a spike artifact carries every base authoring
|
|
48
|
+
* section with a non-empty body. An absent or empty-body section is reported
|
|
49
|
+
* under that section's distinct missing_* code. No side effects.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} markdownText
|
|
52
|
+
* @returns {{ checker: "validate-spike-file", ok: boolean, errors: { code: string, message: string }[] }}
|
|
53
|
+
*/
|
|
54
|
+
export function validateSpikeFile(markdownText) {
|
|
55
|
+
return checkBaseSections(markdownText, "validate-spike-file", SECTION_CODES);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Entry-gate validator for `--spike`: a spike is startable as soon as it carries
|
|
60
|
+
* the exploration scaffold (Question/Approach/Findings) with non-empty bodies;
|
|
61
|
+
* the Recommendation is filled in DURING the spike, not required to begin one.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} markdownText
|
|
64
|
+
* @returns {{ checker: "validate-spike-file", ok: boolean, errors: { code: string, message: string }[] }}
|
|
65
|
+
*/
|
|
66
|
+
export function validateSpikeExplorationSections(markdownText) {
|
|
67
|
+
return checkBaseSections(markdownText, "validate-spike-file", EXPLORATION_SECTION_CODES);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
71
|
+
const options = parseCheckerCliArgs(argv, USAGE, "validate-spike-file");
|
|
72
|
+
if (options.help) {
|
|
73
|
+
stdout.write(`${USAGE}\n`);
|
|
74
|
+
return { ok: true, help: true };
|
|
75
|
+
}
|
|
76
|
+
const markdownText = await readFile(options.input, "utf8");
|
|
77
|
+
const result = validateSpikeFile(markdownText);
|
|
78
|
+
writeCheckerOutput(result, { stdout, json: options.json });
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
83
|
+
runCli().catch((error) => {
|
|
84
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { isDirectCliRun } from "../_core-helpers.mjs";
|
|
4
|
+
|
|
5
|
+
const USAGE = `Usage: extract-changelog-section.mjs --version <v> [--changelog <path>]
|
|
6
|
+
|
|
7
|
+
Prints the CHANGELOG.md section for <version> (the block from "## <version>"
|
|
8
|
+
up to the next "## " heading). Exits 1 if no such section exists or the section
|
|
9
|
+
is empty, so a release is never created for an undocumented version. Exits 2 on
|
|
10
|
+
usage/argument errors (missing --version, missing flag value, unreadable file).`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract the changelog block for a single version.
|
|
14
|
+
*
|
|
15
|
+
* Matches a heading line of the form "## <version>" optionally followed by
|
|
16
|
+
* " - <date>" (or any trailing text), and returns everything up to but not
|
|
17
|
+
* including the next "## " heading. Returns null when no matching section
|
|
18
|
+
* exists (fail closed — caller must not synthesize notes).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} changelog - Full CHANGELOG.md contents.
|
|
21
|
+
* @param {string} version - Version without a leading "v" (e.g. "0.5.0").
|
|
22
|
+
* @returns {string|null} Trimmed section body (without the heading), or null.
|
|
23
|
+
*/
|
|
24
|
+
export function extractChangelogSection(changelog, version) {
|
|
25
|
+
const lines = changelog.split("\n");
|
|
26
|
+
// A heading is "## " followed by the exact version token, then either end of
|
|
27
|
+
// line or a non-version-character (space / "-"). This avoids "0.5.0" matching
|
|
28
|
+
// "0.5.0-rc1" or "0.5.01".
|
|
29
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
|
+
const headingRe = new RegExp(`^##\\s+v?${escaped}(?=\\s|$)`);
|
|
31
|
+
|
|
32
|
+
let start = -1;
|
|
33
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
34
|
+
if (headingRe.test(lines[i])) {
|
|
35
|
+
start = i;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (start === -1) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const body = [];
|
|
45
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
46
|
+
if (/^##\s/.test(lines[i])) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
body.push(lines[i]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return body.join("\n").trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
56
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
57
|
+
process.stdout.write(`${USAGE}\n`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let version;
|
|
62
|
+
let changelogPath = "CHANGELOG.md";
|
|
63
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
64
|
+
if (argv[i] === "--version" || argv[i] === "--changelog") {
|
|
65
|
+
const flag = argv[i];
|
|
66
|
+
const value = argv[i + 1];
|
|
67
|
+
if (value === undefined) {
|
|
68
|
+
process.stderr.write(`error: ${flag} requires a value\n\n${USAGE}\n`);
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
if (flag === "--version") {
|
|
72
|
+
version = value;
|
|
73
|
+
} else {
|
|
74
|
+
changelogPath = value;
|
|
75
|
+
}
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!version) {
|
|
81
|
+
process.stderr.write(`error: --version is required\n\n${USAGE}\n`);
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const normalized = version.replace(/^v/, "");
|
|
86
|
+
|
|
87
|
+
let changelog;
|
|
88
|
+
try {
|
|
89
|
+
changelog = await readFile(changelogPath, "utf8");
|
|
90
|
+
} catch (error) {
|
|
91
|
+
process.stderr.write(`error: cannot read ${changelogPath}: ${error.message}\n`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const section = extractChangelogSection(changelog, normalized);
|
|
96
|
+
if (section === null || section === "") {
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`error: no section found for version ${normalized} in ${changelogPath}. ` +
|
|
99
|
+
`Refusing to create a release for an undocumented version.\n`,
|
|
100
|
+
);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
process.stdout.write(`${section}\n`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
109
|
+
const exitCode = await main();
|
|
110
|
+
process.exitCode = exitCode;
|
|
111
|
+
}
|
package/skills/dev-loop/SKILL.md
CHANGED
|
@@ -35,7 +35,14 @@ For async-required routes (config `workflow.asyncStartMode`, default `required`)
|
|
|
35
35
|
> Under the Claude Code harness the dev-loop runs as a single agent: run these steps directly — no read-only boundary and no separate async-subagent dispatch. See [Main Agent Contract](../docs/main-agent-contract.md).
|
|
36
36
|
|
|
37
37
|
<!-- pi-only -->
|
|
38
|
-
**CLI invocation (`<dev-loops-package-root>`):** dev-loop CLI commands below are invoked as `node <dev-loops-package-root>/cli/index.mjs <verb...>` using the package-local CLI rather than `npx`, so they resolve unambiguously from the installed package without a global install. Resolve `<dev-loops-package-root>`
|
|
38
|
+
**CLI invocation (`<dev-loops-package-root>`):** dev-loop CLI commands below are invoked as `node <dev-loops-package-root>/cli/index.mjs <verb...>` using the package-local CLI rather than `npx`, so they resolve unambiguously from the installed package without a global install. Resolve `<dev-loops-package-root>` via the first of these **bounded** candidates whose `cli/index.mjs` exists — never assume a single fixed layout (under a Pi user-level install the package lives at `~/.pi/agent/npm/node_modules/dev-loops/`, so the old `../../..` package-relative guess from `skills/dev-loop/SKILL.md` overshoots the package root):
|
|
39
|
+
|
|
40
|
+
1. **Node module resolution** (best-effort first try): `node -e "try{const p=require('node:path');console.log(p.resolve(p.dirname(require.resolve('dev-loops/cli/index.mjs')),'..'))}catch{process.exit(1)}"` — resolves the package root when `dev-loops` is reachable from Node's module search path (notably under `~/.pi/agent/npm`); this is cwd-dependent and commonly misses from a target-repo cwd, so the probe is wrapped in try/catch (no stack trace, exits non-zero on miss) — treat a non-zero exit as "probe missed, try the next candidate", not a hard failure.
|
|
41
|
+
2. **Pi user-agent npm root** (reliable for user-level installs): `~/.pi/agent/npm/node_modules/dev-loops`.
|
|
42
|
+
3. **Package-relative (legacy):** `../../..` from this skill's own directory (the original package-local install layout).
|
|
43
|
+
4. **Global npm root:** `$(npm root -g)/dev-loops`.
|
|
44
|
+
|
|
45
|
+
NEVER fall back to `find /` or any unbounded filesystem walk to locate the CLI — it stalls and trips the needs-attention timeout. If every bounded candidate fails, stop and ask the orchestrator/operator for the dev-loops package root rather than searching. (The `dev-loop` agent resolves it analogously.)
|
|
39
46
|
<!-- /pi-only -->
|
|
40
47
|
|
|
41
48
|
Resolve authoritative state via the startup resolver (`node <dev-loops-package-root>/cli/index.mjs loop startup --issue <n>` for issues, `node <dev-loops-package-root>/cli/index.mjs loop startup --pr <n>` for PRs), then immediately build the handoff envelope via `node <dev-loops-package-root>/cli/index.mjs loop build-envelope --input <resolver-output.json>`. The envelope determines `requiredReads`, `nextAction`, `stopRules`, and `acceptance` — load only those files, execute only that bounded task. It is the first handoff artifact consumed before loading any route pack. See [Workflow Handoff Contract](../docs/workflow-handoff-contract.md) for the derivation contract.
|
|
@@ -118,6 +125,21 @@ Info/handoff requests can be served directly via `node <dev-loops-package-root>/
|
|
|
118
125
|
- `node <dev-loops-package-root>/cli/index.mjs loop info --pr <n>` — human-readable PR state summary (branch, CI, threads, rounds, action)
|
|
119
126
|
- `node <dev-loops-package-root>/cli/index.mjs loop info --issue <n> --json` — machine-readable JSON output
|
|
120
127
|
|
|
128
|
+
## Reading tool output (token-economical convention)
|
|
129
|
+
|
|
130
|
+
When you need a fact from a dev-loops JSON-emitting script, climb this ladder and stop at the first rung that answers the question — never read more output than you need:
|
|
131
|
+
|
|
132
|
+
1. **Prefer the dev-loops subcommand / concise mode.** Use `loop info` or a script's `--concise`/`--summary` mode (e.g. `run-watch-cycle.mjs --concise`, `probe-copilot-review.mjs --concise`) for a human-readable digest. The concise modes surface loop state, Copilot round count, unresolved/actionable thread counts, round-cap-clean eligibility, CI status, next action, and the current round's new Copilot comment bodies.
|
|
133
|
+
2. **`--silent` / `-s` for a yes/no check.** Reads ZERO output: `… --jq '<predicate>' --silent; echo $?` exits `0` for true / `1` for false. Without `--jq`, `--silent` maps the script's success (`ok:true`) to exit `0`, failure to `1`. Example: `probe-copilot-review.mjs --repo o/r --pr N --jq '.status=="idle"' -s`.
|
|
134
|
+
3. **`--jq <filter>` to extract a single field.** The `--jq`-wired scripts (`probe-copilot-review.mjs`, `capture-review-threads.mjs`, `upsert-checkpoint-verdict.mjs`, the `scripts/projects/*` queue scripts) accept a gh-style `--jq` filter (jq subset: field access, `.[]`/`.[N]`, `|`, `select(...)`, `==`/`!=`/`<`/`<=`/`>`/`>=`, `length`, `keys`). It prints only the filtered value. An invalid filter fails closed (stderr + exit `2`), distinct from a clean predicate-false (silent exit `1`).
|
|
135
|
+
4. **Use a dev-loops wrapper for `gh` reads — never an agent-level raw `gh`.** With `workflow.requireRetrospectiveInternalTooling: true`, a raw `gh` call is a recorded retro violation (fail-closed). If no script covers the read you need, treat it as a tooling gap: file/build a thin wrapper (reuse `scripts/lib/jq-output.mjs`, like the three below), don't shell out. The reads that already have wrappers:
|
|
136
|
+
- CI run-log tail (a failing PR's job log) → `scripts/github/fetch-ci-logs.mjs --repo <o/r> --pr <n> [--failed-only] [--tail <n>]`, **never raw `gh run view --log`/`--log-failed`**. (`probe-ci-status.mjs` names the failed checks; this returns the LOG.)
|
|
137
|
+
- Issue list/filter → `scripts/github/list-issues.mjs --repo <o/r> [--state <open|closed|all>] [--label <l>] [--limit <n>]`, **never raw `gh issue list`**. (The queue tool lists the project board; this is for arbitrary issue queries.)
|
|
138
|
+
- Issue comment → `scripts/github/comment-issue.mjs --repo <o/r> --issue <n> (--body <text> | --body-file <path>)`, **never raw `gh issue comment`**. Returns `{ ok, commentUrl }`.
|
|
139
|
+
|
|
140
|
+
All three accept the same `--jq`/`--silent` output flags as the other JSON-emitting scripts.
|
|
141
|
+
5. **NEVER `| python3` or `node -e`** to parse tool JSON. If a field you need is missing from a script's output, add it to the script (or its concise mode), not an inline parser.
|
|
142
|
+
|
|
121
143
|
## Guard rules
|
|
122
144
|
|
|
123
145
|
**Handoff envelope precedence:** The dev-loop builds the envelope immediately after authoritative-state resolution and treats it as the first handoff artifact. Read it first, load only `requiredReads`, execute `nextAction`. See [Resolve authoritative state](#resolve-authoritative-state). Derivation contract: [Workflow Handoff Contract](../docs/workflow-handoff-contract.md).
|
|
@@ -128,7 +150,7 @@ Info/handoff requests can be served directly via `node <dev-loops-package-root>/
|
|
|
128
150
|
|
|
129
151
|
**Bounded async task contract:** Break work into discrete tasks with clear inputs, explicit outputs, bounded scope. No shell polling — use `run-watch-cycle.mjs` or `gh run watch`.
|
|
130
152
|
|
|
131
|
-
**Round-cap budget check (enforced):** After every watch cycle, fix pass, or reply-resolve, check whether completed Copilot review rounds have reached the maximum (default: 5). Stop re-requesting Copilot review when the limit is reached — never re-request after the cap.
|
|
153
|
+
**Round-cap budget check (enforced):** After every watch cycle, fix pass, or reply-resolve, check whether completed Copilot review rounds have reached the maximum (default: 5). Stop re-requesting Copilot review when the limit is reached — never re-request after the cap. Read these gate-cadence facts via the token-economical convention above (`run-watch-cycle.mjs --concise`, or `--jq`/`--silent` for a single field/predicate) — never `| python3` or `node -e`.
|
|
132
154
|
|
|
133
155
|
## Shorthand issue-based auto trigger contract
|
|
134
156
|
|