@try-works/dsh-recursive-mode 0.4.5 → 0.4.7
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/README.md +15 -4
- package/lib/client/contract.d.ts +10 -0
- package/lib/client/doc-viewer.d.ts +50 -0
- package/lib/client/spec-sheet-view.d.ts +237 -0
- package/lib/client/spec-sheet.d.ts +103 -0
- package/lib/client/use-live.d.ts +17 -1
- package/lib/client.js +988 -14
- package/lib/errors.d.ts +47 -1
- package/lib/index.js +483 -48
- package/lib/recursive_ask.tool.d.ts +93 -16
- package/lib/recursive_closeout.tool.d.ts +10 -0
- package/lib/recursive_init.tool.d.ts +8 -0
- package/lib/recursive_phase.tool.d.ts +8 -0
- package/lib/recursive_scratch.tool.d.ts +10 -0
- package/lib/recursive_worktree.tool.d.ts +16 -0
- package/lib/run-id.d.ts +62 -0
- package/lib/run-spec.d.ts +78 -0
- package/lib/run-start.d.ts +27 -0
- package/package.json +1 -1
- package/src/client/contract.ts +10 -0
- package/src/client/doc-viewer.tsx +131 -12
- package/src/client/slots.ts +12 -0
- package/src/client/spec-sheet-view.ts +325 -0
- package/src/client/spec-sheet.tsx +409 -0
- package/src/client/styles.ts +298 -1
- package/src/client/use-live.ts +23 -2
- package/src/errors.ts +48 -2
- package/src/recursive_ask.tool.ts +217 -41
- package/src/recursive_closeout.tool.ts +53 -35
- package/src/recursive_init.tool.ts +20 -4
- package/src/recursive_phase.tool.ts +22 -2
- package/src/recursive_scratch.tool.ts +17 -1
- package/src/recursive_worktree.tool.ts +27 -2
- package/src/run-id.ts +100 -0
- package/src/run-spec.ts +148 -0
- package/src/run-start.ts +68 -3
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
6
|
|
|
@@ -7,13 +8,29 @@ import type { RecursiveRuntime } from './runtime.ts'
|
|
|
7
8
|
* `recursive_worktree` — create a linked git worktree for a run and/or
|
|
8
9
|
* promote a branch up the dev/stage/main chain. Workspace-scoped: the
|
|
9
10
|
* operations run under the SESSION's control-plane root only.
|
|
11
|
+
*
|
|
12
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, and this is the worst place to be without the rule: a `create`
|
|
13
|
+
* builds TWO things out of the id — the linked worktree directory `.worktrees/<runId>` AND the git branch
|
|
14
|
+
* `recursive/<runId>` (git accepts '/' inside a ref) — so a path-shaped id used to leave a worktree and a
|
|
15
|
+
* ref behind, not just a folder. MEASURED pre-fix, per id, against a fresh repo: `nested/child-run`
|
|
16
|
+
* returned ok:true and created BOTH `.worktrees/nested/child-run` and
|
|
17
|
+
* `refs/heads/recursive/nested/child-run`, while the shapes git itself refuses as ref syntax
|
|
18
|
+
* (`recursive//tmp/x`, `recursive/C:…`, `.hidden-run`, a trailing space) failed the worktree add and
|
|
19
|
+
* created neither. The rule is `run-id.ts` and is not restated here; the gate sits at this boundary, ahead
|
|
20
|
+
* of `createRunWorktree`, so the refusal no longer depends on git happening to dislike the ref name.
|
|
21
|
+
*
|
|
22
|
+
* The refusal is `BAD_RUN_ID` (RM1107), composed exactly as `recursive_init` composes it — same code, same
|
|
23
|
+
* detail, same sentence. One message for one rule is what keeps a caller from having to learn a second
|
|
24
|
+
* vocabulary for the same defect, and the shared remedy it carries ("call recursive_init again") is right
|
|
25
|
+
* for this tool as well: a `create` for a run that does not exist yet is exactly what `recursive_init`
|
|
26
|
+
* with `createWorktree: true` does.
|
|
10
27
|
*/
|
|
11
28
|
export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
|
|
12
29
|
return defineTool({
|
|
13
30
|
name: 'recursive_worktree',
|
|
14
31
|
description: 'Create a linked git worktree for a recursive-mode run and/or promote a branch up the dev/stage/main chain. Workspace-scoped under the current session workspace.',
|
|
15
32
|
parameters: {
|
|
16
|
-
runId: { type: 'string', description: 'Run id the worktree is created for (e.g. 03-something). Required for create.' },
|
|
33
|
+
runId: { type: 'string', description: 'Run id the worktree is created for — the NAME of the run (e.g. 03-something), never a path: ' + RUN_ID_RULE + '. A path-shaped id is refused for create. Required for create.' },
|
|
17
34
|
action: { type: 'string', description: 'create | promote | status. Default: create.' },
|
|
18
35
|
fromBranch: { type: 'string', description: 'Source branch for a promote action (the branch holding the new commits).' },
|
|
19
36
|
toBranch: { type: 'string', description: 'Promotion target branch for a promote action (feature -> dev -> stage -> main).' },
|
|
@@ -35,7 +52,15 @@ export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
|
|
|
35
52
|
if (!args.runId || args.runId.trim() === '') {
|
|
36
53
|
return { error: toolError('MISSING_CREATE_RUN_ID') } as const
|
|
37
54
|
}
|
|
38
|
-
const
|
|
55
|
+
const runId = args.runId.trim()
|
|
56
|
+
// BEFORE `createRunWorktree`: that call builds `.worktrees/<runId>` and cuts the branch
|
|
57
|
+
// `recursive/<runId>`, so a path-shaped id that reaches it has already created both. The gate is
|
|
58
|
+
// on the name; `run-id.ts` says why the join itself is not the thing to change.
|
|
59
|
+
const problem = runIdProblem(runId)
|
|
60
|
+
if (problem !== null) {
|
|
61
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
62
|
+
}
|
|
63
|
+
const result = recursive.createRunWorktree(root, runId, args.baseBranch?.trim() || undefined)
|
|
39
64
|
return result as unknown as JsonValue
|
|
40
65
|
}
|
|
41
66
|
if (action === 'promote') {
|
package/src/run-id.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A RUN ID IS A NAME, NOT A PATH.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. Every consumer of a run id JOINS it onto a directory
|
|
5
|
+
* that already carries the meaning "the run layer":
|
|
6
|
+
*
|
|
7
|
+
* join(root, '.recursive', 'run', runId) // runtime.ts, run.ts, handoff.ts, scratch.ts
|
|
8
|
+
* join(repoRoot, '.worktrees', runId) // worktree.ts (a linked worktree)
|
|
9
|
+
* 'recursive/' + runId // worktree.ts (the run's git branch)
|
|
10
|
+
*
|
|
11
|
+
* `join` is a PATH operation: absolute paths, drive specifiers and `..` segments
|
|
12
|
+
* are all legal input to it, and each one silently changes what the call means.
|
|
13
|
+
* A caller who passes `E:\tmp\rm-live-diagnostics\01-calculator-lib` is asking
|
|
14
|
+
* for a run "on another drive"; what they get is a `mkdir` of
|
|
15
|
+
*
|
|
16
|
+
* <workspace>\.recursive\run\E:\tmp\rm-live-diagnostics\01-calculator-lib
|
|
17
|
+
*
|
|
18
|
+
* which is not drive-qualified at all — on POSIX and Windows alike the colon is
|
|
19
|
+
* just another character in a relative component. The result is a bogus nested
|
|
20
|
+
* folder INSIDE the workspace, created before anything can refuse it, surfacing
|
|
21
|
+
* far away as an ENOENT-shaped runtime failure (RM5501) with the operator's
|
|
22
|
+
* filesystem already dirty.
|
|
23
|
+
*
|
|
24
|
+
* SO THE RULE IS ENFORCED WHERE THE NAME ENTERS, and NOT by teaching the runtime
|
|
25
|
+
* to accept a path. The joins in `runtime.ts` are CORRECT for a name; what was
|
|
26
|
+
* missing was a gate on the name. Do not "fix" this back: a run on another drive
|
|
27
|
+
* or in a worktree is reached through the session's control-plane root
|
|
28
|
+
* (`recursive_worktree`, `00-worktree.md`) — the run layer is never relocated by
|
|
29
|
+
* smuggling a path into the id.
|
|
30
|
+
*
|
|
31
|
+
* The charset below is deliberately the SAME one the read path already uses
|
|
32
|
+
* (`live-route.ts` `DOC_SAFE_RE`) so a name this gate accepts is a name that
|
|
33
|
+
* route can serve.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The accepted shape, as prose that can be embedded in a model-facing parameter
|
|
38
|
+
* description and in a refusal detail, so the rule is stated once.
|
|
39
|
+
*/
|
|
40
|
+
export const RUN_ID_RULE = 'letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no ".." segment'
|
|
41
|
+
|
|
42
|
+
/** Two ids in the shapes the scaffold convention actually produces. */
|
|
43
|
+
export const RUN_ID_EXAMPLES = '01-calculator-lib, fixture-run'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Longest run id accepted. Directory-name components cap at 255 bytes on NTFS
|
|
47
|
+
* and ext4; a run id also becomes a git ref component (`recursive/<runId>`) and
|
|
48
|
+
* a prefix of every lock/receipt filename inside the run, so the ceiling is set
|
|
49
|
+
* well below the filesystem limit rather than at it.
|
|
50
|
+
*/
|
|
51
|
+
export const RUN_ID_MAX_LENGTH = 100
|
|
52
|
+
|
|
53
|
+
/** Directory-name charset — the read path's `DOC_SAFE_RE`, verbatim. */
|
|
54
|
+
const RUN_ID_CHARS = /^[A-Za-z0-9._-]+$/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Why a run id is refused, or `null` when it is a usable NAME.
|
|
58
|
+
*
|
|
59
|
+
* The returned string is the SPECIFIC problem (which rule the id broke), with no
|
|
60
|
+
* trailing punctuation and no sentence of its own, so a caller can hand it to
|
|
61
|
+
* `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
|
|
62
|
+
*
|
|
63
|
+
* The order of the checks is part of the message quality: a Windows absolute
|
|
64
|
+
* path is reported as a drive-qualified path (what the caller passed) rather
|
|
65
|
+
* than as a separator complaint (what that path is made of).
|
|
66
|
+
*/
|
|
67
|
+
export function runIdProblem(raw: string): string | null {
|
|
68
|
+
if (raw === '') return 'runId is empty'
|
|
69
|
+
if (raw.length > RUN_ID_MAX_LENGTH) return 'runId is ' + raw.length + ' characters, over the ' + RUN_ID_MAX_LENGTH + ' allowed'
|
|
70
|
+
// A Windows drive-QUALIFIED path (`E:\x`, and the drive-relative `E:x` too).
|
|
71
|
+
if (/^[A-Za-z]:/.test(raw)) return 'runId is a Windows drive-qualified path, starting with "' + raw.slice(0, 2) + '"'
|
|
72
|
+
// Path separators: an absolute POSIX path, a UNC path, or any nested path.
|
|
73
|
+
if (raw.includes('/') || raw.includes('\\')) {
|
|
74
|
+
return 'runId contains the path separator "' + (raw.includes('/') ? '/' : '\\') + '"'
|
|
75
|
+
}
|
|
76
|
+
// A colon that is not a drive prefix is still unmappable on Windows (NTFS
|
|
77
|
+
// alternate data streams), and the run id is a Windows directory name.
|
|
78
|
+
if (raw.includes(':')) return 'runId contains a colon (":"), which is a drive and stream separator on Windows'
|
|
79
|
+
// `..` makes the join resolve to the PARENT of the run layer.
|
|
80
|
+
if (raw.includes('..')) return 'runId contains a ".." segment, which escapes the run directory'
|
|
81
|
+
// `.` and `..` are the parent/current directory segments, and a trailing dot is
|
|
82
|
+
// stripped by the Win32 path parser — `03-foo.` and `03-foo` would then be two
|
|
83
|
+
// names for one directory.
|
|
84
|
+
if (raw.startsWith('.')) return 'runId starts with ".", which makes it a hidden name or a relative path segment'
|
|
85
|
+
if (raw.endsWith('.')) return 'runId ends with "."'
|
|
86
|
+
if (!RUN_ID_CHARS.test(raw)) {
|
|
87
|
+
// Whitespace inside the name is called out separately because the caller can
|
|
88
|
+
// see the id they typed and cannot see why it is refused: the tool trims the
|
|
89
|
+
// ENDS (so `" 03-x "` already names `03-x`), but an interior space is not
|
|
90
|
+
// normalized anywhere and would create a directory the caller cannot retype.
|
|
91
|
+
if (/\s/.test(raw)) return 'runId contains a space or other whitespace character inside the name'
|
|
92
|
+
return 'runId contains a character outside the allowed set'
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when `raw` is a usable run NAME. Convenience for callers that only branch. */
|
|
98
|
+
export function isValidRunId(raw: string): boolean {
|
|
99
|
+
return runIdProblem(raw) === null
|
|
100
|
+
}
|
package/src/run-spec.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS THIS RUN SPEC STILL A HOLLOW TEMPLATE? — one answer, two callers.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. `recursive_init` scaffolds Phase 0 as a TEMPLATE: the requirement block
|
|
5
|
+
* still reads `### \`R1\` <short title>`, the acceptance criteria are still `[observable condition 1]`,
|
|
6
|
+
* the checklists are still unchecked and both gates still read `FAIL`. The owner's defect report is that
|
|
7
|
+
* `recursive_ask gate=run-start` raised the "start this run or hold?" gate while the document was in
|
|
8
|
+
* exactly that state — *"i was never shown the spec before that so how could i approve if i havent seen
|
|
9
|
+
* it"*. Approving an unfilled template is not a decision about a spec; there is no spec yet.
|
|
10
|
+
*
|
|
11
|
+
* So the QUESTION "is this document still the template?" must have ONE answer, and two consumers need it:
|
|
12
|
+
*
|
|
13
|
+
* 1. the SERVER gate (`recursive_ask`), which must REFUSE to raise the run-start question while the
|
|
14
|
+
* answer is yes, and
|
|
15
|
+
* 2. the CLIENT sheet (`client/spec-sheet.tsx`), which must SAY SO plainly rather than dress a hollow
|
|
16
|
+
* document up as an approvable one — the honesty rule of `client/settings-view.ts`, applied to a
|
|
17
|
+
* document instead of a value.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ THIS MODULE IS NODE-FREE ON PURPOSE. It is reached by both halves of the bundle, and the client bundle
|
|
20
|
+
* is a BROWSER closure: a `node:fs` import anywhere in its graph breaks the page. So the file reading stays
|
|
21
|
+
* in `run-start.ts` (which owns the artifact path) and this module takes TEXT and returns a VERDICT.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ AND IT PINS THE TEMPLATE, NOT A COPY OF IT. The markers below are the literal lines
|
|
24
|
+
* `init-templates.ts::requirementsContent` writes. A checker that instead carried its own copy of the whole
|
|
25
|
+
* template would silently stop matching the moment the template changed; a checker that carried a CHECKSUM
|
|
26
|
+
* would call every edited document filled and every untouched one unfilled on the strength of a byte count.
|
|
27
|
+
* Naming the placeholder markers is the check that keeps meaning what it says.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** What the checker decided about one artifact's text. */
|
|
31
|
+
export type ArtifactVerdict = 'unfilled' | 'filled'
|
|
32
|
+
|
|
33
|
+
/** One piece of evidence found in the document, quoted with its line number. */
|
|
34
|
+
export interface ArtifactMarkerHit {
|
|
35
|
+
/** Stable id of the marker that matched. */
|
|
36
|
+
id: string
|
|
37
|
+
/** 1-based line number in the document as it was read. */
|
|
38
|
+
line: number
|
|
39
|
+
/** The line verbatim, trimmed of surrounding whitespace. */
|
|
40
|
+
text: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The verdict plus the evidence for it. */
|
|
44
|
+
export interface ArtifactVerdictResult {
|
|
45
|
+
verdict: ArtifactVerdict
|
|
46
|
+
/** Marker hits, in line order. Empty exactly when the verdict is `filled`. */
|
|
47
|
+
hits: ArtifactMarkerHit[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
|
|
51
|
+
export const ARTIFACT_MARKER_IDS = {
|
|
52
|
+
placeholder: 'placeholder',
|
|
53
|
+
uncheckedTodo: 'unchecked-todo',
|
|
54
|
+
failedGate: 'failed-gate',
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
export type ArtifactMarkerId = (typeof ARTIFACT_MARKER_IDS)[keyof typeof ARTIFACT_MARKER_IDS]
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `...` on a line of its own, or a bracketed/angle placeholder.
|
|
61
|
+
*
|
|
62
|
+
* The bracketed form is deliberately `<[^<>\n]+>` rather than `<.+>`: a greedy pattern would swallow a
|
|
63
|
+
* legitimate line that happens to contain two unrelated angle brackets, and the refusal it produced would
|
|
64
|
+
* name a line the person could not see a placeholder in.
|
|
65
|
+
*/
|
|
66
|
+
const PLACEHOLDER_RE = /(^\s*\.\.\.\s*$)|(\[[^[\]\n<>]{2,120}\])|(<[^<>\n]{2,120}>)/
|
|
67
|
+
|
|
68
|
+
/** An unchecked task box: the template ships five of them and a filled spec has none in the TODO block. */
|
|
69
|
+
const UNCHECKED_TODO_RE = /^\s*[-*]\s*\[ \]/
|
|
70
|
+
|
|
71
|
+
/** The two FAIL gates the template ships (`Coverage: FAIL` / `Approval: FAIL`). */
|
|
72
|
+
const FAILED_GATE_RE = /^\s*(Coverage|Approval):\s*FAIL\b/i
|
|
73
|
+
|
|
74
|
+
/** Every marker, in the order they are reported for a single line. */
|
|
75
|
+
const MARKER_PATTERNS: readonly { id: ArtifactMarkerId; re: RegExp }[] = [
|
|
76
|
+
{ id: ARTIFACT_MARKER_IDS.placeholder, re: PLACEHOLDER_RE },
|
|
77
|
+
{ id: ARTIFACT_MARKER_IDS.uncheckedTodo, re: UNCHECKED_TODO_RE },
|
|
78
|
+
{ id: ARTIFACT_MARKER_IDS.failedGate, re: FAILED_GATE_RE },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which marker a single line carries, or null.
|
|
83
|
+
*
|
|
84
|
+
* Exported because the client prints the marker NAMES beside the quoted lines, and a second classifier that
|
|
85
|
+
* re-derived them would be a second answer to the same question.
|
|
86
|
+
*/
|
|
87
|
+
export function markerIdsOnLine(line: string): ArtifactMarkerId[] {
|
|
88
|
+
const ids: ArtifactMarkerId[] = []
|
|
89
|
+
for (const pattern of MARKER_PATTERNS) {
|
|
90
|
+
if (pattern.re.test(line)) ids.push(pattern.id)
|
|
91
|
+
}
|
|
92
|
+
return ids
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Does this line still carry a marker that PROVES the document is an unfilled template?
|
|
97
|
+
*
|
|
98
|
+
* ⚠ THE EVIDENCE IS NOT SYMMETRIC, and that asymmetry is the whole design. An angle-bracket placeholder or
|
|
99
|
+
* `[observable condition 1]` cannot appear in a document somebody actually wrote, so it REFUTES the spec's
|
|
100
|
+
* readiness. An unchecked box or a `FAIL` gate is weaker: a person may legitimately hold `Coverage: FAIL`
|
|
101
|
+
* open as an objection while still having written real requirements. So the strong markers decide, the weak
|
|
102
|
+
* ones are reported as context, and a real document is never refused on their account.
|
|
103
|
+
*/
|
|
104
|
+
function refutes(line: string): boolean {
|
|
105
|
+
return markerIdsOnLine(line).includes(ARTIFACT_MARKER_IDS.placeholder)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Classify one artifact's text.
|
|
110
|
+
*
|
|
111
|
+
* A verdict of `unfilled` means the document still carries the template's own placeholder text — the
|
|
112
|
+
* evidence travels with it, line by line, so the refusal (and the client notice) can name what is missing
|
|
113
|
+
* instead of asserting a state the reader cannot check.
|
|
114
|
+
*/
|
|
115
|
+
export function classifyArtifact(text: string): ArtifactVerdictResult {
|
|
116
|
+
const lines = text.split(/\r?\n/)
|
|
117
|
+
const hits: ArtifactMarkerHit[] = []
|
|
118
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
119
|
+
const line = lines[i] as string
|
|
120
|
+
for (const id of markerIdsOnLine(line)) {
|
|
121
|
+
hits.push({ id, line: i + 1, text: line.trim() })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { verdict: hits.some((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder) ? 'unfilled' : 'filled', hits }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The unfilled evidence only (what a refusal names). */
|
|
128
|
+
export function unfilledEvidence(result: ArtifactVerdictResult): ArtifactMarkerHit[] {
|
|
129
|
+
return result.hits.filter((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The weak, contextual markers (unchecked boxes, FAIL gates). */
|
|
133
|
+
export function contextEvidence(result: ArtifactVerdictResult): ArtifactMarkerHit[] {
|
|
134
|
+
return result.hits.filter((hit) => hit.id !== ARTIFACT_MARKER_IDS.placeholder)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One line naming what is missing, for a refusal sentence.
|
|
139
|
+
*
|
|
140
|
+
* The line number and the text are both quoted: "line 12: <short title>" is a thing a reader can go and
|
|
141
|
+
* look at, while "the requirements are not filled in" is an assertion they would have to take on trust.
|
|
142
|
+
*/
|
|
143
|
+
export function describeEvidence(hits: readonly ArtifactMarkerHit[], limit = 3): string {
|
|
144
|
+
const shown = hits.slice(0, limit).map((hit) => 'line ' + String(hit.line) + ': ' + hit.text)
|
|
145
|
+
const rest = hits.length - shown.length
|
|
146
|
+
const suffix = rest > 0 ? ' (and ' + String(rest) + ' more)' : ''
|
|
147
|
+
return shown.join(' | ') + suffix
|
|
148
|
+
}
|
package/src/run-start.ts
CHANGED
|
@@ -20,9 +20,15 @@
|
|
|
20
20
|
* and nothing else, so the presence of a `Run Start` line is never on its own consent.
|
|
21
21
|
* 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
|
|
22
22
|
* blocking human channel, the same one plan-mode's exit uses — the question is PUT TO THE PERSON and
|
|
23
|
-
* only their selection is recorded; a caller-supplied answer cannot stand in for it
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* only their selection is recorded; a caller-supplied answer cannot stand in for it. A channel that
|
|
24
|
+
* RESOLVES with an answer the gate does not recognise is a person's decision the gate cannot record
|
|
25
|
+
* and it ends the call (RM5504). A channel that FAILS ends the call too (RM5503), naming the cause the
|
|
26
|
+
* channel threw — and there the caller may take the relayed route deliberately, with `relay=true`,
|
|
27
|
+
* which the result reports as `source: "relayed"` rather than as a person's own selection, so a
|
|
28
|
+
* composition whose channel cannot deliver the question can still start a run. A failure that means
|
|
29
|
+
* the question was cancelled, aborted, or timed out is never relayable. Only a composition with no
|
|
30
|
+
* channel at all falls back to the relayed answer unconditionally, which is the contract the other
|
|
31
|
+
* three gates have.
|
|
26
32
|
* 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
|
|
27
33
|
* approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
|
|
28
34
|
* branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
|
|
@@ -36,6 +42,8 @@
|
|
|
36
42
|
import { readFileSync } from 'node:fs'
|
|
37
43
|
import { join } from 'node:path'
|
|
38
44
|
import { getMdFieldValue } from './status.ts'
|
|
45
|
+
import { classifyArtifact, describeEvidence, unfilledEvidence } from './run-spec.ts'
|
|
46
|
+
import { toolError } from './errors.ts'
|
|
39
47
|
|
|
40
48
|
/** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
|
|
41
49
|
export const RUN_START_GATE_ID = 'run-start'
|
|
@@ -121,3 +129,60 @@ export function readRunStartApproval(root: string, runId: string): { approved: b
|
|
|
121
129
|
* the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
|
|
122
130
|
*/
|
|
123
131
|
export const RUN_START_NOT_APPROVED = 'run not started: phase 0 approval has not been granted'
|
|
132
|
+
|
|
133
|
+
/* ============================ THE ORDERING GUARD ============================ */
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* PHASE 0 — THE GATE CANNOT BE RAISED BEFORE THERE IS A SPEC TO DECIDE ABOUT.
|
|
137
|
+
*
|
|
138
|
+
* THE DEFECT. `recursive_init` scaffolds Phase 0 as a TEMPLATE, and `recursive_ask gate=run-start` raised
|
|
139
|
+
* "start this run or hold?" over it immediately — while every requirement was still `<short title>`, every
|
|
140
|
+
* acceptance criterion was still `[observable condition 1]`, and nothing put the document in front of the
|
|
141
|
+
* person at all. The owner: *"the card ui for accepting the spec appeared, but i was never shown the spec
|
|
142
|
+
* before that so how could i approve if i havent seen it"*. Approving an unfilled template is not a decision
|
|
143
|
+
* about a spec; there is no spec yet, and a card that asks the question anyway teaches a person to answer
|
|
144
|
+
* without reading.
|
|
145
|
+
*
|
|
146
|
+
* ⚠ WHAT THIS DOES *NOT* TOUCH. It does not weaken the gate's own contract, it does not add a second way to
|
|
147
|
+
* start a run, and it does not make the plugin the decider: it only refuses to ASK. A person's own answer
|
|
148
|
+
* still wins (`recordRunStartAnswer` is unchanged), a spec still creates no goal, and cancellation / abort /
|
|
149
|
+
* timeout are still unrelayable. The check runs BEFORE the question is put to anybody, so no card is shown
|
|
150
|
+
* for a document that cannot be approved meaningfully.
|
|
151
|
+
*
|
|
152
|
+
* ⚠ AND IT IS A CHECK ON THE DOCUMENT, NOT ON THE CALLER. A `runId` that does not resolve is not this
|
|
153
|
+
* refusal's business — the ask path already reports that — so the guard says `ok: true` there and lets the
|
|
154
|
+
* existing route handle it.
|
|
155
|
+
*/
|
|
156
|
+
export function runStartSpecGuard(root: string, runId: string): { ok: true } | { ok: false; reason: string } {
|
|
157
|
+
const content = readRunStartArtifact(root, runId)
|
|
158
|
+
if (content === null) {
|
|
159
|
+
// ⚠ A MISSING ARTIFACT IS REFUSED TOO, AND IT IS THE SAME DEFECT. Approving a run that has no Phase 0
|
|
160
|
+
// document is approving something nobody can read — the gate's own question ("approve phase 0 and start
|
|
161
|
+
// this run?") has no referent. The refusal names the path a reader can go and look at.
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
reason: toolError(
|
|
165
|
+
'RUN_START_SPEC_UNFILLED',
|
|
166
|
+
'there is no Phase 0 document to approve: ' + runStartArtifactPath(root, runId) + ' does not exist yet',
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const verdict = classifyArtifact(content)
|
|
171
|
+
if (verdict.verdict === 'filled') return { ok: true }
|
|
172
|
+
const evidence = unfilledEvidence(verdict)
|
|
173
|
+
const context = verdict.hits.filter((hit) => hit.id !== 'placeholder')
|
|
174
|
+
const contextNote = context.length === 0
|
|
175
|
+
? ''
|
|
176
|
+
: ' (it also carries ' + String(context.length) + ' unfinished marker(s) of its own, starting at line '
|
|
177
|
+
+ String(context[0]?.line ?? 0) + ')'
|
|
178
|
+
// The evidence — line numbers and the placeholder text VERBATIM — travels in the detail: a refusal that
|
|
179
|
+
// asserts "it is a template" without quoting it is a refusal the caller can only take on trust.
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: toolError(
|
|
183
|
+
'RUN_START_SPEC_UNFILLED',
|
|
184
|
+
RUN_START_ARTIFACT + ' for run ' + JSON.stringify(runId) + ' still carries the template scaffold'
|
|
185
|
+
+ contextNote + ': ' + describeEvidence(evidence),
|
|
186
|
+
),
|
|
187
|
+
}
|
|
188
|
+
}
|