@gobing-ai/spur 0.4.2 → 0.4.3
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-plugin/marketplace.json +1 -1
- package/config/decisions/maker-liveness.yaml +39 -0
- package/config/plugin-scripts.json +0 -4
- package/config/rules/README.md +1 -0
- package/config/rules/docs/sourced-api-claims.yaml +56 -12
- package/config/rules/strict/runtime-boundaries.yaml +7 -0
- package/config/script-placement-baseline.json +1 -19
- package/config/workflows/feature-lifecycle.yaml +46 -1
- package/config/workflows/feature-verification.yaml +11 -0
- package/package.json +9 -9
- package/plugins/sp/README.md +22 -16
- package/plugins/sp/agents/super-reviewer.md +43 -0
- package/plugins/sp/bunfig.toml +8 -5
- package/plugins/sp/hooks/careful-guard.ts +1 -1
- package/plugins/sp/hooks/context-post-tool.ts +7 -427
- package/plugins/sp/hooks/context-session-start.ts +5 -213
- package/plugins/sp/hooks/context-session-stop.ts +5 -191
- package/plugins/sp/hooks/pi/guard-extension.ts +6 -5
- package/plugins/sp/hooks/task-write-guard.ts +1 -1
- package/plugins/sp/{hooks → lib}/agent-hint.ts +1 -1
- package/plugins/sp/lib/context-post-tool.ts +432 -0
- package/plugins/sp/lib/context-session-start.ts +214 -0
- package/plugins/sp/lib/context-session-stop.ts +196 -0
- package/plugins/sp/lib/dogfood-pipeline.ts +265 -0
- package/plugins/sp/lib/dogfood-report.ts +278 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +3 -3
- package/plugins/sp/lib/inline-run-app.ts +40 -0
- package/plugins/sp/lib/inline-run.generated.d.mts +3 -3
- package/plugins/sp/lib/inline-run.generated.mjs +1347 -90
- package/plugins/sp/lib/plan-projection.generated.d.mts +150 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +591 -1
- package/plugins/sp/lib/quality-gate.generated.mjs +38 -25
- package/plugins/sp/lib/residual-scan.generated.d.mts +7 -1
- package/plugins/sp/lib/residual-scan.generated.mjs +71 -6
- package/plugins/sp/lib/run-summary-core.ts +34 -370
- package/plugins/sp/lib/transcript.ts +37 -228
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +586 -0
- package/plugins/sp/scripts/batch-preflight.mjs +23 -1
- package/plugins/sp/scripts/batch-preflight.ts +35 -295
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +108 -87
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +111 -104
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -1
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +7 -265
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +3 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +6 -272
- package/plugins/sp/scripts/feature-verification-steps.mjs +7 -123
- package/plugins/sp/scripts/feature-verification-steps.ts +29 -181
- package/plugins/sp/scripts/inline-run-dispatch.mjs +27 -26
- package/plugins/sp/scripts/inline-run-dispatch.ts +1 -21
- package/plugins/sp/scripts/inline-run-setup.mjs +149 -122
- package/plugins/sp/scripts/inline-run-setup.ts +1 -21
- package/plugins/sp/scripts/quality-gate.mjs +14 -2
- package/plugins/sp/scripts/residual-scan.mjs +73 -6
- package/plugins/sp/scripts/residual-scan.ts +8 -5
- package/plugins/sp/scripts/run-summary.mjs +151 -97
- package/plugins/sp/scripts/run-summary.ts +7 -5
- package/plugins/sp/scripts/session-timeline.mjs +46 -19
- package/plugins/sp/scripts/task-diffstat.mjs +11 -1
- package/plugins/sp/scripts/task-diffstat.ts +8 -39
- package/plugins/sp/skills/code-implementation/SKILL.md +7 -1
- package/plugins/sp/skills/code-verification/references/secu-review.md +2 -1
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +4 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +2 -2
- package/plugins/sp/skills/next-feature/SKILL.md +1 -1
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
- package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
- package/plugins/sp/skills/spur-cli/references/builder.md +3 -2
- package/plugins/sp/skills/spur-cli/references/decision.md +32 -7
- package/plugins/sp/skills/spur-cli/references/features.md +46 -5
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +1 -1
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +13 -3
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +22 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +14 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +29 -12
- package/spur.js +80681 -79296
- package/web/_astro/{BoardApp.B3GQZvj8.js → BoardApp.5IIl2O-z.js} +1 -1
- package/web/_astro/{BoardApp.BT7L2kSb.js → BoardApp.Drnx-KeM.js} +150 -150
- package/web/_astro/{TaskDetail.Cxov1_N_.js → TaskDetail.Ci4LNn9S.js} +1 -1
- package/web/_astro/{arc.vaT7tPOB.js → arc.j2EVkYVd.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.DRMmTjkN.js → architectureDiagram-3BPJPVTR.DZWSFqEG.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.cla0LO_q.js → blockDiagram-GPEHLZMM.DE6GpXCN.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.D4YAIBKn.js → c4Diagram-AAUBKEIU.DoVR1G6B.js} +1 -1
- package/web/_astro/channel.DmdDxGsq.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.BrSJbMRZ.js → chunk-2J33WTMH.Do6-Gbyf.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.CuLMgKF5.js → chunk-4BX2VUAB.BYV1HGHx.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.zkvgu54F.js → chunk-55IACEB6.CvI1zkrc.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DxWkACh8.js → chunk-727SXJPM.DNGSCRlX.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.42XrmIlH.js → chunk-AQP2D5EJ.DXhhgT5z.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Bao2wMK9.js → chunk-FMBD7UC4.dzkRKP2u.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.BhydojD1.js → chunk-ND2GUHAM.DujGLeEf.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.CmZ82g-H.js → chunk-QZHKN3VN.BWtIT3ZU.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.Blr83Gt_.js → classDiagram-4FO5ZUOK.DyApg0Nn.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.Blr83Gt_.js → classDiagram-v2-Q7XG4LA2.DyApg0Nn.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.qmfv0SPn.js → cose-bilkent-S5V4N54A.DJQB-u_l.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.BtELhXPR.js → cynefin-OW5HDTMX.bi3D_siV.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.AWmaAvXo.js → dagre-BM42HDAG.C-f7TrRg.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DdH34KDi.js → diagram-2AECGRRQ.DrPfjHQB.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.BmNPix1k.js → diagram-5GNKFQAL.C9S4WUHO.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.BNQd_Ppo.js → diagram-KO2AKTUF.Dzzye8QH.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.BAPgDi0e.js → diagram-LMA3HP47.DX-LRiof.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.DZ4zyLdC.js → diagram-OG6HWLK6.Z-kAKwIz.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.Q1TySMmw.js → erDiagram-TEJ5UH35.D6dyClSv.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.BXhFBicG.js → flowDiagram-I6XJVG4X.Cx3q2cxD.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.vHSgcuo2.js → ganttDiagram-6RSMTGT7.DU3gH6CT.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.Bi1f73C7.js → gitGraphDiagram-PVQCEYII.ISmCnJ87.js} +1 -1
- package/web/_astro/index.Do2lLdgo.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.DPvXZEWm.js → infoDiagram-5YYISTIA.DR6_iNOK.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.Cz829fsV.js → ishikawaDiagram-YF4QCWOH.D7wY-ZGA.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.enRnB9Me.js → journeyDiagram-JHISSGLW.DkP9-WK8.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.DnTNk6d2.js → kanban-definition-UN3LZRKU.wtgY98M2.js} +1 -1
- package/web/_astro/{linear.BzGnBwNL.js → linear.CI6djNi7.js} +1 -1
- package/web/_astro/{mermaid.core.CSbTpIVz.js → mermaid.core.CuQPxS8b.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.DGJDy8zT.js → mindmap-definition-RKZ34NQL.5HNIEG7R.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.HSPdi5_8.js → pieDiagram-4H26LBE5.CchhDFjE.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js → quadrantDiagram-W4KKPZXB.PYXXJPSJ.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.CnBCtFwa.js → requirementDiagram-4Y6WPE33.cUQuNAKU.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.5M8VWAu8.js → sankeyDiagram-5OEKKPKP.BO7MnRIA.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.DN41XN-U.js → sequenceDiagram-3UESZ5HK.D9bElyD6.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.ErOO3W8j.js → stateDiagram-AJRCARHV.DhGdRz7I.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.B_ZHICM-.js → stateDiagram-v2-BHNVJYJU.5GvLeAoL.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.CWdosr07.js → timeline-definition-PNZ67QCA.LtAT9haM.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.BHzYjNV5.js → vennDiagram-CIIHVFJN.DRth1yLA.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.h8x4IwPX.js → wardleyDiagram-YWT4CUSO.h9MlZfcr.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.RTapW2X8.js → xychartDiagram-2RQKCTM6.DHqy9DYA.js} +1 -1
- package/web/index.html +2 -2
- package/config/proportional-route-table.ts +0 -155
- package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
- package/web/_astro/channel.D4nG9YLf.js +0 -1
- package/web/_astro/index.BhSwN8yY.css +0 -1
- /package/plugins/sp/{hooks → lib}/context-owner.ts +0 -0
- /package/plugins/sp/{hooks → lib}/destructive-policy.ts +0 -0
- /package/plugins/sp/{hooks → lib}/task-file-policy.ts +0 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-session-start — SessionStart hook for indexed-context.
|
|
3
|
+
*
|
|
4
|
+
* Generates a session ID, writes `.spur/context/.session.json` (tracking the current session
|
|
5
|
+
* for PostToolUse and Stop hooks), and appends a `session_start` event to `token-ledger.jsonl`.
|
|
6
|
+
* Task 0246: best-effort agent/model hints on session file + event. Task 1159: the pointer records
|
|
7
|
+
* the host-native {@link SessionOwner} that created it, so a nested child can reuse the ledger for
|
|
8
|
+
* tool recording without being able to tear it down.
|
|
9
|
+
*
|
|
10
|
+
* **Fail-open contract:** every error path exits 0 with no output.
|
|
11
|
+
*
|
|
12
|
+
* Self-contained by design (task 0232/0246).
|
|
13
|
+
*
|
|
14
|
+
* Task 1174: this core lives in `plugins/sp/lib/`; `plugins/sp/hooks/context-session-start.ts` keeps the argv/stdin adapter.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { resolveAgentHint, resolveModelHint } from './agent-hint';
|
|
22
|
+
import { claudeHookOwnerFromPayloadText, type SessionOwner } from './context-owner';
|
|
23
|
+
import { checkContextFreshness } from './context-post-tool';
|
|
24
|
+
import { getEnvVar, getEnvVars } from './env';
|
|
25
|
+
|
|
26
|
+
// Owner identity + Claude payload derivation live in ./context-owner (pure, unit-testable);
|
|
27
|
+
// re-exported here because this module stays the hook surface consumers reach for.
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ancestor-run marker exported by `@gobing-ai/ts-ai-runner` ≥ 0.4.15 (`AGENT_RUN_ID_ENV`).
|
|
31
|
+
*
|
|
32
|
+
* `AiRunner` sets this in the agent subprocess environment whenever the caller supplies an
|
|
33
|
+
* `AgentRunCorrelation`, which Spur's pipeline always does
|
|
34
|
+
* (`packages/app/src/workflow/actions/agent-run.ts:148-152`) as does `spur agent run`
|
|
35
|
+
* (`packages/app/src/services/agent-service.ts:661`). execa's `extendEnv` default propagates it
|
|
36
|
+
* transitively, so every descendant of an agent run — including the hook subprocesses the host
|
|
37
|
+
* fires inside it — inherits the same value. Its mere presence answers "am I nested?" exactly.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately a string literal rather than an import: this hook is self-contained by design
|
|
40
|
+
* (tasks 0232/0246) and runs both as a standalone script and from Superskill's bundled runner, so
|
|
41
|
+
* it must not depend on the workspace's module graph. The name is a published contract on the
|
|
42
|
+
* ts-ai-runner side, so a literal here is a stable coupling, not a guess.
|
|
43
|
+
*/
|
|
44
|
+
export const AGENT_RUN_ID_ENV = 'SPUR_RUN_ID';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fallback idle window for hosts and code paths that do **not** propagate a run correlation
|
|
48
|
+
* (task 0398 R3).
|
|
49
|
+
*
|
|
50
|
+
* Only consulted when {@link AGENT_RUN_ID_ENV} is absent. With ts-ai-runner ≥ 0.4.15 the
|
|
51
|
+
* agent-run path is detected exactly, so this no longer covers the case that motivated it — it
|
|
52
|
+
* remains as a backstop for nested `SessionStart` fires that arrive outside a correlated run
|
|
53
|
+
* (a host that spawns its own helper processes, or an `agent.run` invoked without a correlation).
|
|
54
|
+
*
|
|
55
|
+
* ponytail: still a wall-clock heuristic on that residual path — two genuinely distinct
|
|
56
|
+
* uncorrelated sessions started inside the window merge into one ledger session. Kept because
|
|
57
|
+
* deleting it would regress every non-correlated nesting path back to the original bug, and the
|
|
58
|
+
* set of hosts is not enumerable from here. Delete it once every nesting path is known to carry a
|
|
59
|
+
* correlation.
|
|
60
|
+
*/
|
|
61
|
+
export const SESSION_REUSE_IDLE_MS = 4 * 60 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
interface SessionFileBody {
|
|
64
|
+
session?: unknown;
|
|
65
|
+
started?: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Return the id of the session already in flight, or null when a new one should be minted.
|
|
70
|
+
*
|
|
71
|
+
* **Why this exists (0398 R3).** `SessionStart` fires in every nested `agent.run` subprocess, not
|
|
72
|
+
* once per host session. Each firing used to mint a fresh `session-<date>-<HHMM>` id (now
|
|
73
|
+
* seconds-plus-random, 1166), append a
|
|
74
|
+
* `session_start` row, and overwrite the `.session.json` pointer that `context-post-tool` reads —
|
|
75
|
+
* so a single pipeline run registered as dozens of sessions and orphaned the parent's event
|
|
76
|
+
* stream. The H6 corpus shows the signature: 332 `session_start` against 157 `session_end` over
|
|
77
|
+
* 18 days, 298 distinct ids, 39 starts in a 2-day window that held a handful of real sessions.
|
|
78
|
+
*
|
|
79
|
+
* **Two signals, in precedence order.** (1) {@link AGENT_RUN_ID_ENV} present → definitively a
|
|
80
|
+
* descendant of an agent run: reuse the recorded session with no time bound, because a pipeline step
|
|
81
|
+
* legitimately runs for the full `implementTimeoutMs` (45 min) and longer batches for hours, so any
|
|
82
|
+
* wall-clock window would eventually split a run that is still in flight. (2) No marker → fall back
|
|
83
|
+
* to the {@link SESSION_REUSE_IDLE_MS} window on the `started` stamp, for hosts that nest without
|
|
84
|
+
* propagating a correlation. A crashed session leaves a stale `.session.json`: on (2) the idle window
|
|
85
|
+
* retires it, on (1) a stale file is only reachable from inside a live agent run, where reusing it is
|
|
86
|
+
* the correct answer anyway.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveActiveSession(dir: string, now: Date, env: NodeJS.ProcessEnv = getEnvVars()): string | null {
|
|
89
|
+
let body: SessionFileBody;
|
|
90
|
+
try {
|
|
91
|
+
// No in-flight session, unreadable file or corrupt pointer → mint a new one.
|
|
92
|
+
body = JSON.parse(readFileSync(join(dir, '.session.json'), 'utf-8')) as SessionFileBody;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof body.session !== 'string' || body.session.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
// Signal 1 — exact ancestry. No `started` parse, no time bound.
|
|
100
|
+
const runId = env[AGENT_RUN_ID_ENV];
|
|
101
|
+
if (typeof runId === 'string' && runId.trim().length > 0) return body.session;
|
|
102
|
+
|
|
103
|
+
// Signal 2 — residual heuristic for uncorrelated nesting.
|
|
104
|
+
if (typeof body.started !== 'string') return null;
|
|
105
|
+
|
|
106
|
+
const started = Date.parse(body.started);
|
|
107
|
+
if (Number.isNaN(started)) return null;
|
|
108
|
+
|
|
109
|
+
const idleMs = now.getTime() - started;
|
|
110
|
+
if (idleMs < 0 || idleMs > SESSION_REUSE_IDLE_MS) return null; // stale or clock-skewed → new
|
|
111
|
+
|
|
112
|
+
return body.session;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Core session-start path (testable). Returns session id on success, null on I/O failure.
|
|
117
|
+
*
|
|
118
|
+
* Idempotent per in-flight session (0398 R3): when `resolveActiveSession` finds one, this returns
|
|
119
|
+
* that id and writes nothing — no ledger row, no pointer rewrite.
|
|
120
|
+
*
|
|
121
|
+
* Ownership (task 1159): a **new** pointer records a valid `owner` only; a reused start never
|
|
122
|
+
* rewrites the pointer, so a nested child cannot take ownership from the parent that opened it,
|
|
123
|
+
* and only the recorded owner's shutdown can finalize the ledger.
|
|
124
|
+
*/
|
|
125
|
+
export function recordSessionStart(
|
|
126
|
+
dir: string,
|
|
127
|
+
env: NodeJS.ProcessEnv = getEnvVars(),
|
|
128
|
+
now: () => Date = () => new Date(),
|
|
129
|
+
agentFallback?: string,
|
|
130
|
+
owner?: SessionOwner,
|
|
131
|
+
): string | null {
|
|
132
|
+
try {
|
|
133
|
+
mkdirSync(dir, { recursive: true });
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const at = now();
|
|
139
|
+
|
|
140
|
+
const active = resolveActiveSession(dir, at, env);
|
|
141
|
+
if (active !== null) return active;
|
|
142
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
143
|
+
// Uniqueness suffix (1166): minute-granular ids collided when a session started in the same
|
|
144
|
+
// minute an earlier one ended, and the frozen end algorithm then suppressed the new session's
|
|
145
|
+
// `session_end` row as a duplicate. Seconds + a random suffix make collisions practically
|
|
146
|
+
// impossible; the id stays an opaque ledger key everywhere else.
|
|
147
|
+
const sessionId = `session-${at.toISOString().slice(0, 10)}-${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}-${randomUUID().slice(0, 8)}`;
|
|
148
|
+
const ts = at.toISOString();
|
|
149
|
+
const agent = resolveAgentHint(env, agentFallback);
|
|
150
|
+
const model = resolveModelHint(env);
|
|
151
|
+
|
|
152
|
+
const sessionBody: Record<string, unknown> = {
|
|
153
|
+
session: sessionId,
|
|
154
|
+
started: ts,
|
|
155
|
+
reads: 0,
|
|
156
|
+
writes: 0,
|
|
157
|
+
tokens: 0,
|
|
158
|
+
};
|
|
159
|
+
if (agent) sessionBody.agent = agent;
|
|
160
|
+
if (model) sessionBody.model = model;
|
|
161
|
+
const ownerId = typeof owner?.sessionId === 'string' ? owner.sessionId.trim() : '';
|
|
162
|
+
const host = owner?.host;
|
|
163
|
+
if (ownerId !== '' && (host === 'claude' || host === 'pi')) sessionBody.owner = { host, sessionId: ownerId };
|
|
164
|
+
|
|
165
|
+
const sessionFile = join(dir, '.session.json');
|
|
166
|
+
try {
|
|
167
|
+
writeFileSync(sessionFile, JSON.stringify(sessionBody));
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const startEvent: Record<string, unknown> = { ts, session: sessionId, type: 'session_start' };
|
|
173
|
+
if (agent) startEvent.agent = agent;
|
|
174
|
+
if (model) startEvent.model = model;
|
|
175
|
+
|
|
176
|
+
// Freshness check (task 0711 R4): report whether the context indexes were
|
|
177
|
+
// regenerated at the current HEAD. Best-effort, fail-open — git failures
|
|
178
|
+
// yield head=null, which marks stale only via the sidecar's own defects.
|
|
179
|
+
let headCommit: string | null = null;
|
|
180
|
+
try {
|
|
181
|
+
const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
182
|
+
const trimmed = out.trim();
|
|
183
|
+
headCommit = trimmed.length > 0 ? trimmed : null;
|
|
184
|
+
} catch {
|
|
185
|
+
headCommit = null;
|
|
186
|
+
}
|
|
187
|
+
let freshnessRaw: string | null = null;
|
|
188
|
+
try {
|
|
189
|
+
freshnessRaw = readFileSync(join(dir, '.freshness.json'), 'utf-8');
|
|
190
|
+
} catch {
|
|
191
|
+
freshnessRaw = null;
|
|
192
|
+
}
|
|
193
|
+
startEvent.contextFreshness = checkContextFreshness(freshnessRaw, headCommit);
|
|
194
|
+
|
|
195
|
+
const ledgerPath = join(dir, 'token-ledger.jsonl');
|
|
196
|
+
try {
|
|
197
|
+
appendFileSync(ledgerPath, `${JSON.stringify(startEvent)}\n`);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return sessionId;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Open (or reuse) the session for a raw Claude stdin payload (task 1159, testable half).
|
|
207
|
+
*
|
|
208
|
+
* Malformed/absent text still opens the session ownerless (legacy policy); the dir resolution lives
|
|
209
|
+
* here rather than in the entrypoint so the untestable subprocess body stays a few lines.
|
|
210
|
+
*/
|
|
211
|
+
export function recordSessionStartFromPayloadText(text: string, env: NodeJS.ProcessEnv = getEnvVars()): string | null {
|
|
212
|
+
const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
|
|
213
|
+
return recordSessionStart(dir, env, undefined, undefined, claudeHookOwnerFromPayloadText(text, env) ?? undefined);
|
|
214
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-session-stop — SessionEnd hook for indexed-context (session teardown).
|
|
3
|
+
*
|
|
4
|
+
* Scans `token-ledger.jsonl` for the current session's events, computes rollup totals, appends
|
|
5
|
+
* a `session_end` event, and cleans up `.session.json`. Registered on SessionEnd (not Stop) so it
|
|
6
|
+
* runs once at true session teardown: on Claude Code the rollup counts the whole session (Stop fired
|
|
7
|
+
* per-turn and tore down after turn 1), and on Pi/omp it maps to `session_shutdown` — off the
|
|
8
|
+
* `agent_end`/Stop event where blocking gates (e.g. cc/anti-hallucination) live and would collide.
|
|
9
|
+
*
|
|
10
|
+
* **Owner-gated finalization (task 1159).** A nested `agent.run` reuses its parent's pointer but
|
|
11
|
+
* used to finalize and delete it on its own shutdown, so every later parent tool event landed in a
|
|
12
|
+
* dead ledger. Shutdown now proceeds only when the caller's host-native identity equals the owner
|
|
13
|
+
* recorded on the pointer, so a child's shutdown is a no-op.
|
|
14
|
+
*
|
|
15
|
+
* **Fail-open contract:** every error path — missing `.spur/context/`, missing/unparseable
|
|
16
|
+
* `.session.json`, write failure — exits 0 with no output.
|
|
17
|
+
*
|
|
18
|
+
* Self-contained by design (task 0232). Installed hook configs use the portable
|
|
19
|
+
* `superskill hook run sp context-session-stop` entrypoint.
|
|
20
|
+
*
|
|
21
|
+
* Task 1174: this core lives in `plugins/sp/lib/`; `plugins/sp/hooks/context-session-stop.ts` keeps the argv/stdin adapter.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
// Type-only import: erased at bundle time, so this hook keeps its minimal
|
|
27
|
+
// node:fs/node:path/env dependency surface (sp-plugin-standalone contract).
|
|
28
|
+
import type { SessionOwner } from './context-owner';
|
|
29
|
+
import { claudeHookOwnerFromPayloadText } from './context-owner';
|
|
30
|
+
import { getEnvVar, getEnvVars } from './env';
|
|
31
|
+
|
|
32
|
+
interface LedgerEvent {
|
|
33
|
+
session: string;
|
|
34
|
+
type: string;
|
|
35
|
+
tokens?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SessionPointer {
|
|
39
|
+
session?: unknown;
|
|
40
|
+
owner?: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse and validate an owner read from the pointer or supplied by a caller. */
|
|
44
|
+
function validSessionOwner(raw: unknown): SessionOwner | null {
|
|
45
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
46
|
+
const host = (raw as { host?: unknown }).host;
|
|
47
|
+
const sessionId = (raw as { sessionId?: unknown }).sessionId;
|
|
48
|
+
if (host !== 'claude' && host !== 'pi') return null;
|
|
49
|
+
if (typeof sessionId !== 'string') return null;
|
|
50
|
+
const trimmed = sessionId.trim();
|
|
51
|
+
return trimmed.length > 0 ? { host, sessionId: trimmed } : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sameOwner(a: SessionOwner, b: SessionOwner): boolean {
|
|
55
|
+
return a.host === b.host && a.sessionId === b.sessionId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read and validate the private session pointer, or null when absent/corrupt. */
|
|
59
|
+
function readPointer(sessionFile: string): SessionPointer | null {
|
|
60
|
+
if (!existsSync(sessionFile)) return null;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(sessionFile, 'utf-8')) as SessionPointer;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Scan the token-ledger JSONL for this session's events and compute rollup totals. */
|
|
69
|
+
function computeSessionTotals(
|
|
70
|
+
ledgerPath: string,
|
|
71
|
+
sessionId: string,
|
|
72
|
+
): { reads: number; writes: number; tokens: number } {
|
|
73
|
+
let reads = 0;
|
|
74
|
+
let writes = 0;
|
|
75
|
+
let tokens = 0;
|
|
76
|
+
let lines: string[];
|
|
77
|
+
try {
|
|
78
|
+
lines = readFileSync(ledgerPath, 'utf-8').split('\n');
|
|
79
|
+
} catch {
|
|
80
|
+
return { reads, writes, tokens }; // missing/unreadable ledger → zero totals
|
|
81
|
+
}
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
if (!line.trim()) continue;
|
|
84
|
+
try {
|
|
85
|
+
const evt = JSON.parse(line) as LedgerEvent;
|
|
86
|
+
if (evt.session !== sessionId) continue;
|
|
87
|
+
if (evt.type === 'read') reads++;
|
|
88
|
+
else if (evt.type === 'write') writes++;
|
|
89
|
+
if (evt.tokens) tokens += evt.tokens;
|
|
90
|
+
} catch {
|
|
91
|
+
// skip unparseable lines
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { reads, writes, tokens };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Return the existing `session_end` row for `sessionId`, or null when the ledger has none. */
|
|
98
|
+
function findSessionEnd(ledgerPath: string, sessionId: string): Record<string, unknown> | null {
|
|
99
|
+
let lines: string[];
|
|
100
|
+
try {
|
|
101
|
+
lines = readFileSync(ledgerPath, 'utf-8').split('\n');
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
if (!line.trim()) continue;
|
|
107
|
+
try {
|
|
108
|
+
const evt = JSON.parse(line) as Record<string, unknown>;
|
|
109
|
+
if (evt.type === 'session_end' && evt.session === sessionId) return evt;
|
|
110
|
+
} catch {
|
|
111
|
+
// skip unparseable lines
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Finalize one session (task 0969 R3; owner-gated task 1159): read the `.session.json` pointer,
|
|
119
|
+
* compute the session's rollup totals, append the `session_end` row, remove the pointer
|
|
120
|
+
* (best-effort) and return the event. Shared by this hook's entrypoint and Pi's `session_shutdown`
|
|
121
|
+
* so both hosts roll up identically. Returns null on every fail-open path.
|
|
122
|
+
*
|
|
123
|
+
* `caller` is the identity of the process firing shutdown. Finalization requires it to be a valid
|
|
124
|
+
* identity that **equals** the pointer's recorded owner; a missing/mismatched/malformed caller, a
|
|
125
|
+
* legacy ownerless pointer or a corrupt pointer all return null with no writes and no removal.
|
|
126
|
+
* Ownerless pointers stay readable and reusable for tool recording — the bounded legacy policy is
|
|
127
|
+
* that an unattributable session is retired by the existing idle-start expiry, never claimed here.
|
|
128
|
+
*
|
|
129
|
+
* Repeat safety: before appending, an existing `session_end` row for the ledger suppresses a
|
|
130
|
+
* second one, so a replay after a failed pointer cleanup neither duplicates the row nor strands the
|
|
131
|
+
* pointer. Removal re-reads the pointer and requires both the ledger id and the owner to be
|
|
132
|
+
* unchanged, so a pointer replaced since the first read is left untouched.
|
|
133
|
+
*/
|
|
134
|
+
export function recordSessionEnd(
|
|
135
|
+
dir: string,
|
|
136
|
+
now: () => Date = () => new Date(),
|
|
137
|
+
caller?: SessionOwner,
|
|
138
|
+
): Record<string, unknown> | null {
|
|
139
|
+
const sessionFile = join(dir, '.session.json');
|
|
140
|
+
const pointer = readPointer(sessionFile);
|
|
141
|
+
if (!pointer) return null;
|
|
142
|
+
|
|
143
|
+
const sessionId = typeof pointer.session === 'string' ? pointer.session : '';
|
|
144
|
+
if (!sessionId) return null;
|
|
145
|
+
|
|
146
|
+
// Ownership gate: only the identity recorded at creation may finalize or remove.
|
|
147
|
+
const pointerOwner = validSessionOwner(pointer.owner);
|
|
148
|
+
const callerOwner = validSessionOwner(caller);
|
|
149
|
+
if (!pointerOwner || !callerOwner || !sameOwner(pointerOwner, callerOwner)) return null;
|
|
150
|
+
|
|
151
|
+
const ledgerPath = join(dir, 'token-ledger.jsonl');
|
|
152
|
+
|
|
153
|
+
let event: Record<string, unknown>;
|
|
154
|
+
const existing = findSessionEnd(ledgerPath, sessionId);
|
|
155
|
+
if (existing) {
|
|
156
|
+
// Replay: the end row survived an earlier failed pointer cleanup. Retry the removal below
|
|
157
|
+
// without appending a duplicate.
|
|
158
|
+
event = existing;
|
|
159
|
+
} else {
|
|
160
|
+
const totals = computeSessionTotals(ledgerPath, sessionId);
|
|
161
|
+
event = { ts: now().toISOString(), session: sessionId, type: 'session_end', totals };
|
|
162
|
+
try {
|
|
163
|
+
appendFileSync(ledgerPath, `${JSON.stringify(event)}\n`);
|
|
164
|
+
} catch {
|
|
165
|
+
return null; // retain the pointer so a later owning shutdown can retry
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Remove only the exact pointer finalized above: re-read and require the ledger id and owner
|
|
170
|
+
// to be unchanged, so a pointer replaced during recording survives.
|
|
171
|
+
try {
|
|
172
|
+
const current = readPointer(sessionFile);
|
|
173
|
+
const currentOwner = current ? validSessionOwner(current.owner) : null;
|
|
174
|
+
if (current?.session === sessionId && currentOwner && sameOwner(currentOwner, pointerOwner)) {
|
|
175
|
+
rmSync(sessionFile, { force: true });
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// cleanup is best-effort
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return event;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Finalize the session for a raw Claude stdin payload (task 1159, testable half).
|
|
186
|
+
*
|
|
187
|
+
* Malformed/absent text carries no identity, so shutdown fails closed; the dir resolution lives here
|
|
188
|
+
* rather than in the entrypoint so the untestable subprocess body stays a few lines.
|
|
189
|
+
*/
|
|
190
|
+
export function recordSessionEndFromPayloadText(
|
|
191
|
+
text: string,
|
|
192
|
+
env: NodeJS.ProcessEnv = getEnvVars(),
|
|
193
|
+
): Record<string, unknown> | null {
|
|
194
|
+
const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
|
|
195
|
+
return recordSessionEnd(dir, undefined, claudeHookOwnerFromPayloadText(text, env) ?? undefined);
|
|
196
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dogfood pipeline-driving detection — the pure detector core.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts` (task 1173, plan
|
|
5
|
+
* P7/ADR-130): the reusable detection logic lives in lib, and the script keeps only the live
|
|
6
|
+
* Phase 1.0 CLI gate. Callers and tests may keep importing the script — it re-exports this module.
|
|
7
|
+
*
|
|
8
|
+
* Contract (0274 §3 dogfood-pipeline-detect, 0277 R1):
|
|
9
|
+
* - Word-boundary matchers with `-` treated as a word char (NOT a boundary).
|
|
10
|
+
* A token counts only when it is a distinct hyphen-word, not when it is a
|
|
11
|
+
* substring of a longer alphanumeric or hyphen run. This removes the
|
|
12
|
+
* leading-space dependency of the @1.1 prose detector.
|
|
13
|
+
* - Two token shapes, both with strict `[^\w-]` boundaries:
|
|
14
|
+
* (a) complete tokens — `--next`, `dev-run`, `dev-runall`, `dev-wrap`,
|
|
15
|
+
* `dev-wrapall`, `dev-idea` — matched as whole hyphen-words so the
|
|
16
|
+
* slash form `/sp:dev-run` matches on its `dev-run` tail whether or
|
|
17
|
+
* not it is preceded by a space, but `--next` does NOT match inside
|
|
18
|
+
* `--next-gen` (the trailing `-gen` breaks the boundary).
|
|
19
|
+
* (b) bare nouns — `run`, `runall`, `wrap`, `wrapall`, `idea` — matched
|
|
20
|
+
* only as standalone words, so `run` matches in
|
|
21
|
+
* `bun ... task run 0042` but NOT inside `runaway`/`prerun`, and
|
|
22
|
+
* `idea` does NOT match inside `idealist`/`ideal`.
|
|
23
|
+
* - Listing both shapes is what makes the detector leading-space invariant
|
|
24
|
+
* (the slash form is caught by its `dev-*` tail; the bare noun is caught
|
|
25
|
+
* when it appears as its own word) without letting `run` leak into every
|
|
26
|
+
* `-run-` identifier.
|
|
27
|
+
*
|
|
28
|
+
* Non-goals of detectPipelineDriving: decides ambiguity only. It does NOT decide
|
|
29
|
+
* observe vs fix — any explicit `--max-retry` value proceeds regardless.
|
|
30
|
+
* Implement-heavy advisory is a separate helper (detectImplementHeavy).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tokens whose presence makes a testee pipeline-driving. Ordered for stable
|
|
35
|
+
* diagnostics. Two shapes per the contract above: complete `dev-*` / `--next`
|
|
36
|
+
* tokens first, then bare nouns.
|
|
37
|
+
*/
|
|
38
|
+
const PIPELINE_TOKENS = [
|
|
39
|
+
'--next',
|
|
40
|
+
'dev-runall',
|
|
41
|
+
'dev-wrapall',
|
|
42
|
+
'dev-refineall',
|
|
43
|
+
'dev-verifyall',
|
|
44
|
+
'dev-run',
|
|
45
|
+
'dev-wrap',
|
|
46
|
+
'dev-idea',
|
|
47
|
+
'refineall',
|
|
48
|
+
'verifyall',
|
|
49
|
+
'runall',
|
|
50
|
+
'wrapall',
|
|
51
|
+
'run',
|
|
52
|
+
'wrap',
|
|
53
|
+
'idea',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/** Refuse message when pipeline-driving and `--max-retry` was not passed (exact string). */
|
|
57
|
+
export const PIPELINE_DRIVING_REFUSE_MESSAGE =
|
|
58
|
+
'⚠ pipeline-driving testee detected; pass --max-retry 0 (observe-only) or --max-retry N (fix mode, tree mutation acknowledged)';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Refuse message when the testee carries a mutating `--fix` mode (`--fix all` /
|
|
62
|
+
* `--fix blockers-first`) and `--max-retry` was not passed. A mutating fix mode
|
|
63
|
+
* mutates the working tree independent of pipeline-driving (task 0293 R1/R2):
|
|
64
|
+
* `--max-retry 0` bounds the **driver** only — the testee's own `--fix` pass
|
|
65
|
+
* still mutates the tree — so the message must not imply otherwise.
|
|
66
|
+
*/
|
|
67
|
+
export const MUTATING_FIX_REFUSE_MESSAGE =
|
|
68
|
+
'⚠ mutating --fix mode detected (--fix all | --fix blockers-first); pass --max-retry 0 (observe-only for the driver; the testee still mutates the tree) or --max-retry N (fix mode, driver + testee both mutate)';
|
|
69
|
+
|
|
70
|
+
/** Advisory when pipeline-driving + implement-heavy derived steps (exact string). */
|
|
71
|
+
export const IMPLEMENT_HEAVY_ADVISORY_MESSAGE =
|
|
72
|
+
'⚠ implement-heavy pipeline dogfood: prefer --max-retry 0 (observe-only) or step-split; operator --max-retry N overrides';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Tokens that mark a derived step as implement-heavy (mutates product/code or
|
|
76
|
+
* drives a mutating pipeline leg). Deliberately excludes verify/review/unit-only
|
|
77
|
+
* surfaces even when they carry `--next`.
|
|
78
|
+
*/
|
|
79
|
+
const IMPLEMENT_HEAVY_TOKENS = [
|
|
80
|
+
'dev-runall',
|
|
81
|
+
'dev-wrapall',
|
|
82
|
+
'dev-run',
|
|
83
|
+
'dev-wrap',
|
|
84
|
+
'dev-idea',
|
|
85
|
+
'runall',
|
|
86
|
+
'wrapall',
|
|
87
|
+
// bare `run` / `wrap` / `idea` — only when not clearly a non-mutating surface
|
|
88
|
+
'run',
|
|
89
|
+
'wrap',
|
|
90
|
+
'idea',
|
|
91
|
+
'implement',
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A verify/review surface stops being non-mutating the moment it carries a
|
|
96
|
+
* repair mode: `--fix all` / `--fix blockers-first` applies Edit/Write repairs
|
|
97
|
+
* to the working tree (0280 dogfood, finding P2). `--fix none` stays
|
|
98
|
+
* observational. Boundary-guarded so `--prefix all` / `--focus all` never match.
|
|
99
|
+
* Exported so the Phase 1.0 gate and tests share one matcher (task 0293 R1/R4).
|
|
100
|
+
*/
|
|
101
|
+
export function hasMutatingFixMode(step: string): boolean {
|
|
102
|
+
return /(?<![\w-])--fix[=\s]+(all|blockers-first)(?![\w-])/i.test(step);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Match a token at a word boundary. A "word boundary" here is the position
|
|
107
|
+
* between a non-`[\w-]` char (or string start) and the token, and between the
|
|
108
|
+
* token and a non-`[\w-]` char (or string end). Treating `-` as a word char
|
|
109
|
+
* is what makes `--next` reject `--next-gen` and `dev-run` reject `dev-runner`.
|
|
110
|
+
*/
|
|
111
|
+
function tokenMatches(testee: string, token: string): boolean {
|
|
112
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
113
|
+
// (?<![\w-]) — not preceded by a word char or hyphen.
|
|
114
|
+
// (?![\w-]) — not followed by a word char or hyphen.
|
|
115
|
+
const re = new RegExp(`(?<![\\w-])${escaped}(?![\\w-])`, 'i');
|
|
116
|
+
return re.test(testee);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns whether the testee string is pipeline-driving (contains any of the
|
|
121
|
+
* pipeline-driving tokens as a distinct hyphen-word). Machine-checked
|
|
122
|
+
* counterpart of the prose list in SKILL.md §Pipeline-driving detection and
|
|
123
|
+
* dev-dogfood.md.
|
|
124
|
+
*/
|
|
125
|
+
export function detectPipelineDriving(testee: string): boolean {
|
|
126
|
+
if (typeof testee !== 'string' || testee.length === 0) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return PIPELINE_TOKENS.some((token) => tokenMatches(testee, token));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* True when a single step label (or the whole testee) is implement-heavy —
|
|
134
|
+
* it chains into real implementation / wrap / idea work, not verify-only.
|
|
135
|
+
*
|
|
136
|
+
* Non-mutating surfaces (`dev-verify`, `dev-review`, `dev-unit`, plain
|
|
137
|
+
* `dev-refine` without a further run) are never implement-heavy even if
|
|
138
|
+
* pipeline-driving via `--next` — UNLESS they carry a mutating repair mode
|
|
139
|
+
* (`--fix all` / `--fix blockers-first`), which turns the verify/review leg
|
|
140
|
+
* into a tree-mutating fix pass (0280 dogfood, finding P2).
|
|
141
|
+
*/
|
|
142
|
+
export function isImplementHeavyStep(step: string): boolean {
|
|
143
|
+
if (typeof step !== 'string' || step.length === 0) return false;
|
|
144
|
+
// Explicit non-mutating surfaces win unless a mutating token co-occurs.
|
|
145
|
+
const nonMutatingOnly =
|
|
146
|
+
tokenMatches(step, 'dev-verify') || tokenMatches(step, 'dev-review') || tokenMatches(step, 'dev-unit');
|
|
147
|
+
const hasMutating = IMPLEMENT_HEAVY_TOKENS.some((token) => tokenMatches(step, token)) || hasMutatingFixMode(step);
|
|
148
|
+
if (nonMutatingOnly && !hasMutating) return false;
|
|
149
|
+
// refine alone is planning, not implement-heavy; refine+run/--next chain is.
|
|
150
|
+
if (tokenMatches(step, 'dev-refine') && !hasMutating && !tokenMatches(step, '--next')) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (tokenMatches(step, 'dev-refine') && tokenMatches(step, '--next')) {
|
|
154
|
+
// refine --next chains into run → implement-heavy
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return hasMutating;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* True when the testee is implement-heavy: it carries a pipeline-driving token,
|
|
162
|
+
* a mutating `--fix` mode, OR at least one derived step (or the testee itself
|
|
163
|
+
* when no steps given) is implement-heavy. Used for the W8 Phase 1 advisory
|
|
164
|
+
* after step derivation.
|
|
165
|
+
*
|
|
166
|
+
* Task 0293 R3: a mutating `--fix` mode alone is implement-heavy even with no
|
|
167
|
+
* pipeline token — the verify/review leg itself becomes a tree-mutating fix
|
|
168
|
+
* pass.
|
|
169
|
+
*/
|
|
170
|
+
export function detectImplementHeavy(testee: string, derivedSteps: string[] = []): boolean {
|
|
171
|
+
if (isImplementHeavyStep(testee)) return true;
|
|
172
|
+
if (!detectPipelineDriving(testee)) return false;
|
|
173
|
+
return derivedSteps.some((step) => isImplementHeavyStep(step));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface GateResult {
|
|
177
|
+
pipelineDriving: boolean;
|
|
178
|
+
mutatingFix: boolean;
|
|
179
|
+
maxRetryPresent: boolean;
|
|
180
|
+
implementHeavy: boolean;
|
|
181
|
+
refuse: boolean;
|
|
182
|
+
advisory: boolean;
|
|
183
|
+
message: string | null;
|
|
184
|
+
exitCode: 0 | 1 | 2;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Phase 1.0 + W8 gate decision. Pure — no I/O. The CLI wrapper prints and exits.
|
|
189
|
+
*
|
|
190
|
+
* Refuse condition (task 0293 R1) is the union of two **independent** mutation
|
|
191
|
+
* sources, each gated on `!maxRetryPresent`:
|
|
192
|
+
*
|
|
193
|
+
* 1. pipeline-driving testee (`--next`, `dev-run`, …) — refuses because the
|
|
194
|
+
* testee chains lifecycle legs whose cumulative blast radius the driver
|
|
195
|
+
* cannot pre-attribute.
|
|
196
|
+
* 2. mutating `--fix` mode (`--fix all` / `--fix blockers-first`) — refuses
|
|
197
|
+
* because the testee itself applies Edit/Write repairs even with no
|
|
198
|
+
* pipeline token. `--max-retry 0` bounds the **driver** only; the testee
|
|
199
|
+
* still mutates (honesty note in MUTATING_FIX_REFUSE_MESSAGE).
|
|
200
|
+
*
|
|
201
|
+
* Pipeline-driving is reported first when both co-occur (its refuse message is
|
|
202
|
+
* the superset — chain + tree mutation).
|
|
203
|
+
*/
|
|
204
|
+
export function evaluateDogfoodGate(
|
|
205
|
+
testee: string,
|
|
206
|
+
options: { maxRetryPresent?: boolean; steps?: string[] } = {},
|
|
207
|
+
): GateResult {
|
|
208
|
+
const maxRetryPresent = options.maxRetryPresent === true;
|
|
209
|
+
const steps = options.steps ?? [];
|
|
210
|
+
const pipelineDriving = detectPipelineDriving(testee);
|
|
211
|
+
const mutatingFix = hasMutatingFixMode(testee);
|
|
212
|
+
const implementHeavy = detectImplementHeavy(testee, steps);
|
|
213
|
+
|
|
214
|
+
if (pipelineDriving && !maxRetryPresent) {
|
|
215
|
+
return {
|
|
216
|
+
pipelineDriving,
|
|
217
|
+
mutatingFix,
|
|
218
|
+
maxRetryPresent,
|
|
219
|
+
implementHeavy,
|
|
220
|
+
refuse: true,
|
|
221
|
+
advisory: false,
|
|
222
|
+
message: PIPELINE_DRIVING_REFUSE_MESSAGE,
|
|
223
|
+
exitCode: 2,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (mutatingFix && !maxRetryPresent) {
|
|
228
|
+
return {
|
|
229
|
+
pipelineDriving,
|
|
230
|
+
mutatingFix,
|
|
231
|
+
maxRetryPresent,
|
|
232
|
+
implementHeavy,
|
|
233
|
+
refuse: true,
|
|
234
|
+
advisory: false,
|
|
235
|
+
message: MUTATING_FIX_REFUSE_MESSAGE,
|
|
236
|
+
exitCode: 2,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (implementHeavy) {
|
|
241
|
+
return {
|
|
242
|
+
pipelineDriving,
|
|
243
|
+
mutatingFix,
|
|
244
|
+
maxRetryPresent,
|
|
245
|
+
implementHeavy,
|
|
246
|
+
refuse: false,
|
|
247
|
+
advisory: true,
|
|
248
|
+
message: IMPLEMENT_HEAVY_ADVISORY_MESSAGE,
|
|
249
|
+
exitCode: 0,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
pipelineDriving,
|
|
255
|
+
mutatingFix,
|
|
256
|
+
maxRetryPresent,
|
|
257
|
+
implementHeavy,
|
|
258
|
+
refuse: false,
|
|
259
|
+
advisory: false,
|
|
260
|
+
message: null,
|
|
261
|
+
exitCode: 0,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export { PIPELINE_TOKENS };
|