@gr8ful/spf 0.8.0 → 0.8.2
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/dist/chains/steps.d.ts +17 -0
- package/dist/chains/steps.js +23 -1
- package/dist/cli/commands/doctor.js +44 -11
- package/dist/cli/commands/watch.js +12 -0
- package/package.json +1 -1
package/dist/chains/steps.d.ts
CHANGED
|
@@ -316,6 +316,23 @@ export declare function refine(opts?: {
|
|
|
316
316
|
retries?: number;
|
|
317
317
|
extraGates?: string[];
|
|
318
318
|
}): Step;
|
|
319
|
+
/**
|
|
320
|
+
* `cli/commands/watch.ts`'s `runRefine()` reads BOTH `refine_publish.json`
|
|
321
|
+
* and `refine_questions.json` back, unconditionally, after this chain
|
|
322
|
+
* exits — it has no other way to learn what THIS run did, since a chain's
|
|
323
|
+
* return value is just an exit code. A resumed spec (`continue-refinement`)
|
|
324
|
+
* reruns this entire chain from `request` on, into the SAME deterministic
|
|
325
|
+
* `context_handoff_dir` a PRIOR round already wrote into. Without clearing
|
|
326
|
+
* the file this run is NOT about to write, a stale `refine_questions.json`
|
|
327
|
+
* from an earlier escalation round survives a LATER round's successful
|
|
328
|
+
* publish — `runRefine` then reports those old questions as if raised
|
|
329
|
+
* again THIS round, so `runSpec` escalates a second time even though real
|
|
330
|
+
* issues were already created on the tracker seconds earlier. Called
|
|
331
|
+
* before EITHER branch writes, so exactly one of the two files reflects
|
|
332
|
+
* this run when the phase returns, never a leftover from a previous one.
|
|
333
|
+
* `force: true` — a first-ever run has neither file yet, which is fine.
|
|
334
|
+
*/
|
|
335
|
+
export declare function clearStaleRefineOutputFiles(contextHandoffDir: string): void;
|
|
319
336
|
export declare function publishIssues(opts?: {
|
|
320
337
|
description?: string;
|
|
321
338
|
}): Step;
|
package/dist/chains/steps.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* chain is a load-time problem the loader can report against a file and a
|
|
41
41
|
* line, never a phase that blows up ten minutes into an unattended run.
|
|
42
42
|
*/
|
|
43
|
-
import { writeFileSync } from "node:fs";
|
|
43
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
44
44
|
import path from "node:path";
|
|
45
45
|
import * as changesLib from "../core/changes.js";
|
|
46
46
|
import * as gates from "../core/gates.js";
|
|
@@ -723,6 +723,27 @@ function parsePriorityOption(raw) {
|
|
|
723
723
|
}
|
|
724
724
|
return raw;
|
|
725
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* `cli/commands/watch.ts`'s `runRefine()` reads BOTH `refine_publish.json`
|
|
728
|
+
* and `refine_questions.json` back, unconditionally, after this chain
|
|
729
|
+
* exits — it has no other way to learn what THIS run did, since a chain's
|
|
730
|
+
* return value is just an exit code. A resumed spec (`continue-refinement`)
|
|
731
|
+
* reruns this entire chain from `request` on, into the SAME deterministic
|
|
732
|
+
* `context_handoff_dir` a PRIOR round already wrote into. Without clearing
|
|
733
|
+
* the file this run is NOT about to write, a stale `refine_questions.json`
|
|
734
|
+
* from an earlier escalation round survives a LATER round's successful
|
|
735
|
+
* publish — `runRefine` then reports those old questions as if raised
|
|
736
|
+
* again THIS round, so `runSpec` escalates a second time even though real
|
|
737
|
+
* issues were already created on the tracker seconds earlier. Called
|
|
738
|
+
* before EITHER branch writes, so exactly one of the two files reflects
|
|
739
|
+
* this run when the phase returns, never a leftover from a previous one.
|
|
740
|
+
* `force: true` — a first-ever run has neither file yet, which is fine.
|
|
741
|
+
*/
|
|
742
|
+
export function clearStaleRefineOutputFiles(contextHandoffDir) {
|
|
743
|
+
for (const name of ["refine_questions.json", "refine_publish.json"]) {
|
|
744
|
+
rmSync(path.join(contextHandoffDir, name), { force: true });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
726
747
|
export function publishIssues(opts = {}) {
|
|
727
748
|
preflightDescription("publish", opts.description);
|
|
728
749
|
const fn = async (run, state) => {
|
|
@@ -738,6 +759,7 @@ export function publishIssues(opts = {}) {
|
|
|
738
759
|
owner: "tracker",
|
|
739
760
|
description: opts.description ?? "Create the feature/story tree on the tracker, in dependency order, and link each to its parent",
|
|
740
761
|
}), async (ph) => {
|
|
762
|
+
clearStaleRefineOutputFiles(run.context_handoff_dir);
|
|
741
763
|
if (questions.length > 0) {
|
|
742
764
|
writeFileSync(path.join(run.context_handoff_dir, "refine_questions.json"), JSON.stringify(questions, null, 2));
|
|
743
765
|
ph.log({ escalated: questions.length });
|
|
@@ -53,6 +53,33 @@ async function withProbeStatus(label, run) {
|
|
|
53
53
|
* a plain `✓` so a real negative finding (e.g. "unreachable", a `/v1`
|
|
54
54
|
* double-path warning) can't be mistaken for a pass at a glance.
|
|
55
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* Cross-check ONE chain's own `requiredAgents`/`requiredSuites` (derived
|
|
58
|
+
* from what it actually does — `resolveRequiredAgents`/`resolveRequiredSuites`,
|
|
59
|
+
* same as `spf list`/a real dispatch would resolve) against what `cfg`
|
|
60
|
+
* actually has. Shared by the repo-chain loop below (every `.spf/chains/
|
|
61
|
+
* *.yaml` chain) and `watch.chain`/`watch.refine.chain` — the two chains
|
|
62
|
+
* `spf watch` will ACTUALLY dispatch, built-in or not. Extracted specifically
|
|
63
|
+
* because the repo-chain loop used to be the ONLY place this ran, and it
|
|
64
|
+
* skips every built-in chain by construction (`.filter((c) => c.source !==
|
|
65
|
+
* undefined)`) — so a `watch.chain` naming a built-in like `plan-build-test`
|
|
66
|
+
* never went through it at all: `spf doctor` could report clean while `spf
|
|
67
|
+
* watch`'s very first claimed issue failed at runtime on a missing
|
|
68
|
+
* `quality.suites` entry doctor never actually looked for.
|
|
69
|
+
*/
|
|
70
|
+
function checkChainRequirements(report, label, chain, cfg) {
|
|
71
|
+
const owners = resolveRequiredAgents(chain, {});
|
|
72
|
+
const missingOwners = owners.filter((o) => !cfg.agents.some((a) => a.name === o));
|
|
73
|
+
check(report, `${label} owners`, missingOwners.length === 0, missingOwners.length === 0 ? owners.join(", ") || "(none)" : `unknown agent(s): ${missingOwners.join(", ")} — not in cfg.agents`);
|
|
74
|
+
const suiteNames = resolveRequiredSuites(chain, {});
|
|
75
|
+
const missingSuites = suiteNames.filter((s) => !(s in cfg.quality.suites));
|
|
76
|
+
const missingChecks = missingSuites.length === 0 ? suiteNames.flatMap((s) => cfg.quality.suites[s].filter((n) => !cfg.quality.checks.some((c) => c.name === n))) : [];
|
|
77
|
+
check(report, `${label} suites`, missingSuites.length === 0 && missingChecks.length === 0, missingSuites.length > 0
|
|
78
|
+
? `unknown suite(s): ${missingSuites.join(", ")} — not in cfg.quality.suites`
|
|
79
|
+
: missingChecks.length > 0
|
|
80
|
+
? `suite(s) name unknown check(s): ${missingChecks.join(", ")}`
|
|
81
|
+
: suiteNames.join(", ") || "(none)");
|
|
82
|
+
}
|
|
56
83
|
function check(report, name, ok, detail, severity) {
|
|
57
84
|
report.checks.push({ name, ok, detail, severity });
|
|
58
85
|
if (!ok)
|
|
@@ -468,17 +495,7 @@ export async function doctorCommand(argv) {
|
|
|
468
495
|
check(report, `repo chain ${problem.file}`, false, problem.message);
|
|
469
496
|
}
|
|
470
497
|
for (const chain of allChains().filter((c) => c.source !== undefined)) {
|
|
471
|
-
|
|
472
|
-
const missingOwners = owners.filter((o) => !cfg.agents.some((a) => a.name === o));
|
|
473
|
-
check(report, `repo chain "${chain.name}" owners`, missingOwners.length === 0, missingOwners.length === 0 ? owners.join(", ") || "(none)" : `unknown agent(s): ${missingOwners.join(", ")} — not in cfg.agents`);
|
|
474
|
-
const suiteNames = resolveRequiredSuites(chain, {});
|
|
475
|
-
const missingSuites = suiteNames.filter((s) => !(s in cfg.quality.suites));
|
|
476
|
-
const missingChecks = missingSuites.length === 0 ? suiteNames.flatMap((s) => cfg.quality.suites[s].filter((n) => !cfg.quality.checks.some((c) => c.name === n))) : [];
|
|
477
|
-
check(report, `repo chain "${chain.name}" suites`, missingSuites.length === 0 && missingChecks.length === 0, missingSuites.length > 0
|
|
478
|
-
? `unknown suite(s): ${missingSuites.join(", ")} — not in cfg.quality.suites`
|
|
479
|
-
: missingChecks.length > 0
|
|
480
|
-
? `suite(s) name unknown check(s): ${missingChecks.join(", ")}`
|
|
481
|
-
: suiteNames.join(", ") || "(none)");
|
|
498
|
+
checkChainRequirements(report, `repo chain "${chain.name}"`, chain, cfg);
|
|
482
499
|
// Informational: the derived sequence itself, same string `spf list`
|
|
483
500
|
// shows — printed here so the chain's author can eyeball what they
|
|
484
501
|
// actually wrote without a second command.
|
|
@@ -537,6 +554,19 @@ export async function doctorCommand(argv) {
|
|
|
537
554
|
// same way it would for `spf watch` itself, no special-casing needed.
|
|
538
555
|
const watchChain = findChain(cfg.watch.chain);
|
|
539
556
|
check(report, "watch.chain", Boolean(watchChain), watchChain ? `${cfg.watch.chain}${watchChain.source ? ` (repo: ${repoChainLabel(watchChain.source)})` : ""}` : `"${cfg.watch.chain}" is not a registered chain`);
|
|
557
|
+
// The "repo chain X suites/owners" loop above only covers chains with a
|
|
558
|
+
// `.source` (loaded from `.spf/chains/*.yaml`) — a BUILT-IN chain named
|
|
559
|
+
// by `watch.chain` (the common case: `plan-build-test`, etc.) never went
|
|
560
|
+
// through that check, and the "roster + suites validate" call earlier
|
|
561
|
+
// only validates suites already present in `cfg.quality.suites`, not
|
|
562
|
+
// whether `watch.chain` actually NEEDS one that's missing entirely. That
|
|
563
|
+
// gap is exactly how `spf doctor` can report clean while `spf watch`'s
|
|
564
|
+
// very first claimed issue fails at runtime with "quality.suites.\"test\"
|
|
565
|
+
// is not configured" — this closes it by checking the chain `spf watch`
|
|
566
|
+
// will ACTUALLY dispatch, the same way the repo-chain loop already does.
|
|
567
|
+
if (watchChain) {
|
|
568
|
+
checkChainRequirements(report, `watch.chain "${cfg.watch.chain}"`, watchChain, cfg);
|
|
569
|
+
}
|
|
540
570
|
// Informational only, never a failure: a chain that skips review and/or
|
|
541
571
|
// never commits is a legitimate choice (e.g. `scout`, `plan`) — this is
|
|
542
572
|
// here so an unattended `spf watch` posture is a visible fact, not a
|
|
@@ -558,6 +588,9 @@ export async function doctorCommand(argv) {
|
|
|
558
588
|
if (cfg.watch.refine.enabled) {
|
|
559
589
|
const refineChain = findChain(cfg.watch.refine.chain);
|
|
560
590
|
check(report, "watch.refine.chain", Boolean(refineChain), refineChain ? `${cfg.watch.refine.chain}${refineChain.source ? ` (repo: ${repoChainLabel(refineChain.source)})` : ""}` : `"${cfg.watch.refine.chain}" is not a registered chain`);
|
|
591
|
+
if (refineChain) {
|
|
592
|
+
checkChainRequirements(report, `watch.refine.chain "${cfg.watch.refine.chain}"`, refineChain, cfg);
|
|
593
|
+
}
|
|
561
594
|
check(report, "watch.refine issue authoring", cfg.watch.issue_provider === "github" || cfg.watch.issue_provider === "jira", cfg.watch.issue_provider === "github"
|
|
562
595
|
? "github supports issue authoring (createIssue/sub-issues)"
|
|
563
596
|
: cfg.watch.issue_provider === "jira"
|
|
@@ -457,6 +457,18 @@ export async function watchCommand(argv) {
|
|
|
457
457
|
catch {
|
|
458
458
|
// best-effort, same as above — no questions file means this run wasn't an escalation
|
|
459
459
|
}
|
|
460
|
+
// Defense in depth: `steps.publishIssues()` now clears whichever of
|
|
461
|
+
// these two files it's NOT about to write, so both should never be
|
|
462
|
+
// non-empty here — but if some future change (or an older worktree's
|
|
463
|
+
// leftover files, before that fix existed) ever produces both, a real
|
|
464
|
+
// completed publish must never be silently overridden by a stale
|
|
465
|
+
// question. `runSpec` checks `questions.length > 0` first, so without
|
|
466
|
+
// this it would re-escalate over issues that already landed on the
|
|
467
|
+
// tracker seconds earlier.
|
|
468
|
+
if (created.length > 0 && questions.length > 0) {
|
|
469
|
+
console.error(`watch: ${opts.adwId}: refine_publish.json AND refine_questions.json both had content — treating the ${created.length} published issue(s) as authoritative and discarding the stale question(s)`);
|
|
470
|
+
questions = [];
|
|
471
|
+
}
|
|
460
472
|
return { accepted: true, adwId: opts.adwId, detail: "", created, questions };
|
|
461
473
|
};
|
|
462
474
|
// `IssueAuthoringProvider`'s read-back half — `isAuthoringProvider()` is a
|