@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.
@@ -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;
@@ -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
- const owners = resolveRequiredAgents(chain, {});
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",