@joshuafolkken/kit 1.1094.0 → 1.1128.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/workflow-commands/SKILL.md +1 -1
- package/.claude/skills/workflow-commands/background-commands.md +36 -85
- package/.claude/skills/workflow-commands/backlogrun-progress.md +29 -32
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
- package/.claude/skills/workflow-commands/fullrun.md +5 -4
- package/.claude/skills/workflow-commands/halfrun.md +5 -4
- package/.claude/skills/workflow-commands/latest-gate.md +7 -15
- package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
- package/.claude/skills/workflow-commands/split-assessment.md +4 -18
- package/.github/actions/setup-node/action.yml +33 -0
- package/.github/actions/setup-pnpm/action.yml +4 -4
- package/.github/workflows/auto-tag.yml +2 -3
- package/.github/workflows/ci.yml +7 -8
- package/.github/workflows/dependabot-auto-merge.yml +1 -0
- package/.github/workflows/pr-classification.yml +1 -3
- package/.github/workflows/production.yml +1 -0
- package/.github/workflows/publish.yml +10 -9
- package/.github/workflows/sonar-qube.yml +17 -13
- package/.pnpmfile.mjs +29 -0
- package/AGENTS.md +4 -11
- package/CLAUDE.md +24 -17
- package/GEMINI.md +4 -13
- package/README.md +8 -3
- package/SECURITY.md +1 -0
- package/cspell/index.yaml +4 -0
- package/dist/CLAUDE.basic.md +6 -6
- package/dist/CLAUDE.md +24 -17
- package/dist/CLAUDE.static.md +6 -6
- package/dist/config-merge/index.js +33 -9
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-NKOG3XAV.js → chunk-4JZDP5X5.js} +2 -2
- package/dist/hooks/{chunk-3C4YCCEF.js → chunk-5SD2YM7Z.js} +4 -4
- package/dist/hooks/{chunk-OIZUHPJI.js → chunk-77J7IEQM.js} +1 -1
- package/dist/hooks/{chunk-KHWHKJOM.js → chunk-7HVB7SZG.js} +3 -3
- package/dist/hooks/{chunk-UD24EAIL.js → chunk-AIOIOMPX.js} +1875 -104
- package/dist/hooks/{chunk-M2XDCUKD.js → chunk-M7QKEKWO.js} +88 -1783
- package/dist/hooks/{chunk-E54KGFEQ.js → chunk-MKVGBQPA.js} +1 -1
- package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-NXYMNPUQ.js} +3 -3
- package/dist/hooks/{chunk-O4LL4HTI.js → chunk-QVLT52OV.js} +2 -2
- package/dist/hooks/{chunk-IL27YTPH.js → chunk-XYUIQOW3.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +12 -47
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/inputs.json +180 -179
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +17 -12
- package/dist/josh.js +79 -30
- package/dist/version/index.js +36 -20
- package/eslint/rules/unicorn.js +3 -2
- package/package.json +18 -2
- package/pnpm-workspace.yaml +5 -7
- package/prompts/collaboration-workflow/glossary.md +17 -0
- package/prompts/collaboration-workflow/issue-template.md +1 -1
- package/prompts/collaboration-workflow/operating-rules.md +1 -1
- package/prompts/collaboration-workflow/principles.md +9 -0
- package/prompts/collaboration-workflow.md +1 -0
- package/scripts/backlog/backlog-arrival.ts +30 -10
- package/scripts/backlog/backlog-budget-cli.ts +15 -16
- package/scripts/backlog/backlog-offer-cli.ts +6 -14
- package/scripts/backlog/backlog-plan.ts +29 -18
- package/scripts/backlog/backlog-ready.ts +20 -7
- package/scripts/config-merge/parse-jsonc.ts +33 -3
- package/scripts/config-merge/patch-json-key.ts +13 -6
- package/scripts/cost-runtime/cost-cli.ts +3 -7
- package/scripts/doctor/doctor-consumer.ts +3 -5
- package/scripts/doctor/doctor-io.ts +9 -13
- package/scripts/document/backlogrun-parent-read-set.ts +12 -1
- package/scripts/document/document-byte-budget.ts +1 -0
- package/scripts/document/entry-read-budget.ts +13 -7
- package/scripts/document/entry-read-set.ts +32 -8
- package/scripts/document/lane-child-read-set.ts +7 -0
- package/scripts/document/read-set-point-of-use.ts +8 -3
- package/scripts/document/read-set-trim.ts +3 -1
- package/scripts/epic/epic-audit.ts +3 -18
- package/scripts/gh/git-gh-issue-list.ts +12 -11
- package/scripts/gh/git-gh-issue-write.ts +4 -1
- package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
- package/scripts/git/git-command.ts +6 -1
- package/scripts/git/git-spawn-sync.ts +65 -0
- package/scripts/git/git-spawn.ts +5 -4
- package/scripts/git/stash/stash-pop-args.ts +1 -6
- package/scripts/init/init-file-action.ts +54 -0
- package/scripts/init/init-logic-migrate.ts +35 -17
- package/scripts/init/init-logic-secretlint.ts +1 -1
- package/scripts/init/init-logic.ts +6 -22
- package/scripts/init/init.ts +11 -68
- package/scripts/init/kit-written-paths.ts +2 -0
- package/scripts/init/project-profile.ts +3 -6
- package/scripts/init/start-steps.ts +2 -1
- package/scripts/issue/defect-rate-cli.ts +61 -20
- package/scripts/issue/issue-backlinks.ts +7 -14
- package/scripts/issue/issue-cite.ts +12 -6
- package/scripts/issue/issue-file.ts +5 -12
- package/scripts/issue/markdown-section.ts +21 -6
- package/scripts/issue/measure-rerun-cli.ts +73 -14
- package/scripts/josh/josh-commands-project.ts +1 -1
- package/scripts/josh/josh-commands-workflow.ts +1 -1
- package/scripts/josh/run-github-prerequisite.ts +3 -6
- package/scripts/lane/lane-await.ts +43 -13
- package/scripts/lane/lane-close.ts +4 -1
- package/scripts/lane/lane-launch-cli.ts +10 -16
- package/scripts/lane/openai-review-broker.ts +4 -1
- package/scripts/lib/cli-flags.ts +31 -2
- package/scripts/lib/error-message.ts +12 -1
- package/scripts/lines/line-targets.ts +0 -11
- package/scripts/lines/lines-command.ts +5 -3
- package/scripts/propagate/propagate-git.ts +3 -8
- package/scripts/refactor/refactor-scan.ts +2 -1
- package/scripts/registry-migration/migrate-logic.ts +28 -19
- package/scripts/registry-migration/migrate.ts +6 -5
- package/scripts/release/github-release-cli.ts +14 -3
- package/scripts/release/release-publish.ts +36 -2
- package/scripts/release/release-worktree.ts +97 -2
- package/scripts/review/review-checkout.ts +3 -2
- package/scripts/review/review-record-cli.ts +9 -16
- package/scripts/rules/early-heartbeat.ts +4 -3
- package/scripts/rules/lane-park.ts +9 -0
- package/scripts/rules/stop-rules.ts +7 -4
- package/scripts/run/run-carry-args.ts +8 -12
- package/scripts/run/run-cut-args.ts +1 -6
- package/scripts/run/run-ending-cli.ts +1 -5
- package/scripts/run/run-entry-cli.ts +40 -9
- package/scripts/run/run-entry-stop.ts +104 -0
- package/scripts/run/run-event-stream-emit.ts +7 -2
- package/scripts/run/run-hold.ts +4 -1
- package/scripts/run/run-liveness-cli.ts +1 -5
- package/scripts/run/run-merge-cli.ts +2 -10
- package/scripts/run/run-parent-cut-hook.ts +4 -1
- package/scripts/run/run-prep-cli.ts +4 -1
- package/scripts/run/run-progress-args.ts +20 -4
- package/scripts/run/run-progress-cli.ts +31 -29
- package/scripts/run/run-progress-clock.ts +4 -1
- package/scripts/run/run-ship-cli.ts +8 -16
- package/scripts/run/run-ship-probe.ts +4 -1
- package/scripts/run/run-status-cli.ts +2 -15
- package/scripts/run/run-tidy-lanes.ts +4 -1
- package/scripts/run/run-wake-cli.ts +2 -10
- package/scripts/run/run-watcher-guard.ts +9 -5
- package/scripts/safe-chain/pack-hook.ts +92 -0
- package/scripts/sync/sync-ai-files.ts +4 -0
- package/scripts/test/test-declared-changed.ts +13 -16
- package/scripts/test/test-declared.ts +3 -7
- package/scripts/version/version-commands.ts +11 -1
- package/templates/CLAUDE.basic.md +6 -6
- package/templates/workflows/ci.yml +16 -82
- package/templates/workflows/dependabot-auto-merge.yml +1 -0
- package/templates/workflows/github-release.yml +1 -3
- package/tsconfig.sonar.json +4 -22
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { git_command } from '#scripts/git/git-command'
|
|
3
|
+
import { git_remote_branch } from '#scripts/git/git-remote-branch'
|
|
3
4
|
import { git_worktree } from '#scripts/git/git-worktree'
|
|
4
5
|
import { lane_install } from '#scripts/lane/lane-install'
|
|
6
|
+
import { lane_leftover } from '#scripts/lane/lane-leftover'
|
|
5
7
|
import { lane_paths } from '#scripts/lane/lane-paths'
|
|
6
8
|
|
|
7
9
|
// The dedicated linked work tree `pnpm josh release` runs inside (joshuafolkken/kit#2411). The whole
|
|
@@ -51,18 +53,111 @@ async function install_or_clean(directory: string, branch_name: string): Promise
|
|
|
51
53
|
// tree on the default branch, so the tree is created directly on the release branch — `git worktree
|
|
52
54
|
// add --no-track -b release/v<version> <dir> origin/<default>`, which `worktree_add` already spells
|
|
53
55
|
// when it is given a start point.
|
|
56
|
+
//
|
|
57
|
+
// **A failed add takes back the branch it made** (joshuafolkken/kit#3058). `git worktree add -b`
|
|
58
|
+
// creates the branch before it looks at the path, so an add refused for a path that already exists
|
|
59
|
+
// still leaves `release/v<version>` behind — and the next run's branch guard then refuses that name
|
|
60
|
+
// as a release already opened. The guard ran before this call and found no such branch, so one
|
|
61
|
+
// present now is this call's own.
|
|
62
|
+
async function add_or_take_back(directory: string, branch_name: string): Promise<void> {
|
|
63
|
+
const default_branch = await git_command.get_default_branch()
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await git_worktree.worktree_add(directory, branch_name, `origin/${default_branch}`)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (await git_command.branch_exists(branch_name)) await git_worktree.branch_delete(branch_name)
|
|
69
|
+
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
async function create(branch_name: string): Promise<string> {
|
|
55
75
|
const directory = directory_for(process.cwd())
|
|
56
|
-
const default_branch = await git_command.get_default_branch()
|
|
57
76
|
|
|
58
|
-
await
|
|
77
|
+
await add_or_take_back(directory, branch_name)
|
|
59
78
|
await install_or_clean(directory, branch_name)
|
|
60
79
|
|
|
61
80
|
return directory
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
// **A run that never reached its `finally` leaves its tree and branch behind** (joshuafolkken/kit#3058)
|
|
84
|
+
// — a Ctrl-C, a closed terminal — and every later release then fails on them until a person removes
|
|
85
|
+
// both by hand. They are cleared here only when clearing loses nothing: a release branch origin does
|
|
86
|
+
// not have and that carries no commit beyond `origin/<default>`. Uncommitted edits in the tree are
|
|
87
|
+
// the version bump the next run writes again, so they are not counted as a loss. A branch that was
|
|
88
|
+
// pushed, or that holds a commit, may be a release still in flight, so it is reported and kept. A
|
|
89
|
+
// live run with neither yet is excluded by the caller: `release_publish.publish` holds the release
|
|
90
|
+
// lock, so no other release is alive when this runs.
|
|
91
|
+
const RELEASE_BRANCH_PATTERN = 'release/v*'
|
|
92
|
+
|
|
93
|
+
async function leftover_reason(branch_name: string, base: string): Promise<string | undefined> {
|
|
94
|
+
const answer = await git_remote_branch.ask(branch_name)
|
|
95
|
+
|
|
96
|
+
if (answer !== 'absent') return `origin answers \`${answer}\` for it`
|
|
97
|
+
|
|
98
|
+
const ahead = await git_command.commit_count_beyond(base, branch_name)
|
|
99
|
+
|
|
100
|
+
return ahead === 0 ? undefined : `it holds ${String(ahead)} commit(s) not on \`${base}\``
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function kept_message(directory: string, kept: ReadonlyArray<[string, string]>): string {
|
|
104
|
+
const lines = kept.map(([branch_name, reason]) => `- \`${branch_name}\`: ${reason}`)
|
|
105
|
+
const commands = [
|
|
106
|
+
`git worktree remove --force ${directory}`,
|
|
107
|
+
...kept.map(([branch_name]) => `git branch -D ${branch_name}`),
|
|
108
|
+
].join('; ')
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
'A previous `pnpm josh release` left release branches that may still be in use, so nothing was removed:',
|
|
112
|
+
...lines,
|
|
113
|
+
`If no release is running, finish or close its pull request, delete the remote branch if it was pushed, then run \`${commands}\` and \`pnpm josh release\` again.`,
|
|
114
|
+
].join('\n')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function kept_branches(branches: ReadonlyArray<string>): Promise<Array<[string, string]>> {
|
|
118
|
+
const base = `origin/${await git_command.get_default_branch()}`
|
|
119
|
+
const reasons = await Promise.all(branches.map(async (name) => await leftover_reason(name, base)))
|
|
120
|
+
|
|
121
|
+
return branches.flatMap((name, index) => {
|
|
122
|
+
const reason = reasons[index]
|
|
123
|
+
|
|
124
|
+
return reason === undefined ? [] : [[name, reason] as [string, string]]
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function is_registered(directory: string): Promise<boolean> {
|
|
129
|
+
const listing = await git_worktree.worktree_list()
|
|
130
|
+
|
|
131
|
+
return listing.split('\n').includes(`worktree ${directory}`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The tree goes before the branches, for the reason `remove` gives. A directory git does not register
|
|
135
|
+
// is `lane_leftover.reclaim`'s to remove or refuse — the same test a lane's leftover path gets.
|
|
136
|
+
// Registration is asked before existence: a directory deleted by hand stays registered, and git
|
|
137
|
+
// refuses to delete a branch a registered tree holds until `worktree remove` drops the registration.
|
|
138
|
+
async function remove_leftover_tree(directory: string): Promise<void> {
|
|
139
|
+
if (await is_registered(directory)) await git_worktree.worktree_remove(directory)
|
|
140
|
+
else await lane_leftover.reclaim(directory)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function clear_leftover(): Promise<void> {
|
|
144
|
+
const directory = directory_for(process.cwd())
|
|
145
|
+
const branches = await git_command.branch_names(RELEASE_BRANCH_PATTERN)
|
|
146
|
+
const kept = await kept_branches(branches)
|
|
147
|
+
|
|
148
|
+
if (kept.length > 0) throw new Error(kept_message(directory, kept))
|
|
149
|
+
|
|
150
|
+
await remove_leftover_tree(directory)
|
|
151
|
+
|
|
152
|
+
for (const branch_name of branches) {
|
|
153
|
+
// eslint-disable-next-line no-await-in-loop -- each `branch -D` takes the same ref lock
|
|
154
|
+
await git_worktree.branch_delete(branch_name)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
64
158
|
const release_worktree = {
|
|
65
159
|
directory_for,
|
|
160
|
+
clear_leftover,
|
|
66
161
|
create,
|
|
67
162
|
remove,
|
|
68
163
|
RELEASE_WORKTREE_NAME,
|
|
@@ -21,8 +21,9 @@ interface ReviewCheckout {
|
|
|
21
21
|
head: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// Three git
|
|
25
|
-
//
|
|
24
|
+
// Three git reads that do not depend on each other, so they are issued together rather than one after
|
|
25
|
+
// another: the brief is printed in front of a review the run is waiting on. The root is a near-instant
|
|
26
|
+
// synchronous `rev-parse` (joshuafolkken/kit#3065); the branch and head still overlap.
|
|
26
27
|
async function read_checkout(): Promise<ReviewCheckout> {
|
|
27
28
|
const [root, branch, head] = await Promise.all([
|
|
28
29
|
git_command.repository_root(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
-
import {
|
|
3
|
+
import { cli_flags } from '#scripts/lib/cli-flags'
|
|
4
4
|
import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
|
|
5
5
|
import { review_finding_ledger, type Finding } from './review-finding-ledger'
|
|
6
6
|
import { review_record, type RecordVerdict } from './review-record'
|
|
@@ -71,21 +71,14 @@ function parse_findings(specs: ReadonlyArray<string>): ReadonlyArray<Finding> |
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function parse_argv(argv: ReadonlyArray<string>): Parsed | undefined {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
check: parsed.values.check === true,
|
|
84
|
-
issue: parsed.values.issue,
|
|
85
|
-
positionals: parsed.positionals,
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
return undefined
|
|
74
|
+
const parsed = cli_flags.arguments_of(argv, OPTIONS)
|
|
75
|
+
|
|
76
|
+
if (parsed === undefined) return undefined
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
check: parsed.values.check === true,
|
|
80
|
+
issue: parsed.values.issue,
|
|
81
|
+
positionals: parsed.positionals,
|
|
89
82
|
}
|
|
90
83
|
}
|
|
91
84
|
|
|
@@ -185,10 +185,11 @@ function decide(call: GuardedCall, run: GuardRun, can_record: boolean): boolean
|
|
|
185
185
|
// is the half that makes the rule believable — the promise was kept and the interval was not.
|
|
186
186
|
const EARLY_HEARTBEAT_REASON =
|
|
187
187
|
'⛔ early heartbeat: this call arms a wait timer of its own, and the progress clock is not yours ' +
|
|
188
|
-
'to keep. `pnpm josh run:progress --wait`
|
|
188
|
+
'to keep. `pnpm josh run:progress --wait` keeps it for you ' +
|
|
189
189
|
'(`JOSH_PROGRESS_INTERVAL_MINUTES`, or `josh.progress_interval_minutes` in `package.json`, ' +
|
|
190
|
-
'default 20 minutes)
|
|
191
|
-
|
|
190
|
+
'default 20 minutes): start it once in the background and it reports the five labelled lines to ' +
|
|
191
|
+
"the run's event stream on its own, without waking you — relay nothing, and start it again only " +
|
|
192
|
+
'after it exits, which it does when newly runnable work arrives. Two timers armed at ' +
|
|
192
193
|
'once is what produced reports 3–5 minutes apart on a 15-minute setting ' +
|
|
193
194
|
'(joshuafolkken/kit#1570): a new one was armed on the turn a timer fired and again on the turn a ' +
|
|
194
195
|
"child's completion woke the run, and `--mark` recorded each report without anything refusing the " +
|
|
@@ -62,7 +62,15 @@ const NOTIFY_INVOCATION = /josh\s+(?:notify|nf)\b/u
|
|
|
62
62
|
// notify appears and a `--task-type confirmation` appears. That is looser than `is_confirmation_notify`
|
|
63
63
|
// on purpose — the guard's safe direction is to read a real stop notification as present, so a run
|
|
64
64
|
// that sent one is never told it did not.
|
|
65
|
+
//
|
|
66
|
+
// **A command that sent the Telegram itself prints this line instead** (joshuafolkken/kit#3099):
|
|
67
|
+
// `run:entry` notifies from inside its own stop, so no `josh notify` invocation reaches the transcript,
|
|
68
|
+
// and the line it prints is what the tail carries. One constant, printed there and matched here.
|
|
69
|
+
const COMMAND_NOTIFY_MARKER = 'stop notified: a confirmation Telegram was sent by this command'
|
|
70
|
+
|
|
65
71
|
function mentions_confirmation_notify(text: string): boolean {
|
|
72
|
+
if (text.includes(COMMAND_NOTIFY_MARKER)) return true
|
|
73
|
+
|
|
66
74
|
return NOTIFY_INVOCATION.test(text) && CONFIRMATION_TASK_TYPE.test(text)
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -117,6 +125,7 @@ const LANE_PARK_REASON =
|
|
|
117
125
|
'before it stops", and the park itself is `backlogrun-park.md` → "park and continue".'
|
|
118
126
|
|
|
119
127
|
const lane_park = {
|
|
128
|
+
COMMAND_NOTIFY_MARKER,
|
|
120
129
|
LANE_PARK_REASON,
|
|
121
130
|
is_confirmation_notify,
|
|
122
131
|
is_parking_child,
|
|
@@ -162,15 +162,18 @@ const HEADLESS_WAIT_MARKER = '⛔ headless parent:'
|
|
|
162
162
|
const HEADLESS_REFUSAL_CAP = 3
|
|
163
163
|
|
|
164
164
|
// The foreground waits as they appear in a transcript's tool-call input — the JSON `command` field, so
|
|
165
|
-
// the refusal's own prose, which names the same commands, never matches.
|
|
166
|
-
|
|
165
|
+
// the refusal's own prose, which names the same commands, never matches. `run:progress --wait` is not
|
|
166
|
+
// one (joshuafolkken/kit#3102): it streams its reports and runs until work arrives, so it is started in
|
|
167
|
+
// the background, where it lives as long as the turn the foreground wait keeps open.
|
|
168
|
+
const HEADLESS_WAIT_CALLS = ['"command":"pnpm josh lane:await']
|
|
167
169
|
|
|
168
170
|
const HEADLESS_WAIT_BODY =
|
|
169
171
|
' this is a `claude -p` `backlogrun` parent (`JOSH_RUN_HEADLESS`) with lanes still ' +
|
|
170
172
|
'in flight, and ending this turn ends the process — its background waits are killed with it and the ' +
|
|
171
173
|
'lanes are left without a parent. Wait in the foreground instead: run `pnpm josh lane:await <N...>` ' +
|
|
172
|
-
'for the in-flight lanes
|
|
173
|
-
'
|
|
174
|
+
'for the in-flight lanes as a foreground command within the tool timeout (with ' +
|
|
175
|
+
'`pnpm josh run:progress --wait` in the background for the heartbeat, which streams its reports ' +
|
|
176
|
+
'itself and lives while this turn does), act on what it reports, and continue the loop. End the turn only ' +
|
|
174
177
|
'after `pnpm josh run:carry --cut` or `--end` (`backlogrun-progress.md` → "The parent keeps no clock ' +
|
|
175
178
|
'of its own").'
|
|
176
179
|
|
|
@@ -76,17 +76,13 @@ type Request =
|
|
|
76
76
|
const READ_REQUEST: Request = { kind: 'read' }
|
|
77
77
|
|
|
78
78
|
function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
|
|
79
|
-
return cli_flags.
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function text_of(value: OptionValue): string | undefined {
|
|
83
|
-
return typeof value === 'string' ? value : undefined
|
|
79
|
+
return cli_flags.values_of(argv, OPTIONS)
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
// Absent is zero; present but not a count is `undefined`, which invalidates the whole invocation
|
|
87
83
|
// rather than quietly counting nothing.
|
|
88
84
|
function to_count(value: OptionValue): number | undefined {
|
|
89
|
-
const text =
|
|
85
|
+
const text = cli_flags.string_of(value)
|
|
90
86
|
|
|
91
87
|
if (text === undefined) return NO_INCREMENT
|
|
92
88
|
|
|
@@ -99,7 +95,7 @@ function to_count(value: OptionValue): number | undefined {
|
|
|
99
95
|
// than a zero: there is no issue to record, which is a different fact from recording issue zero. A
|
|
100
96
|
// present-but-malformed value invalidates the whole invocation, exactly as a bad `--merged` does.
|
|
101
97
|
function to_done(value: OptionValue): Pick<CarryChange, 'done'> | undefined {
|
|
102
|
-
const text =
|
|
98
|
+
const text = cli_flags.string_of(value)
|
|
103
99
|
|
|
104
100
|
if (text === undefined) return {}
|
|
105
101
|
|
|
@@ -165,7 +161,7 @@ function group_count(values: ParsedValues): number {
|
|
|
165
161
|
// the record simply declares no owner and every standing record reads as not provably live, which
|
|
166
162
|
// refuses rather than resumes.
|
|
167
163
|
function to_owner(value: OptionValue): CarryOwner | undefined {
|
|
168
|
-
const text =
|
|
164
|
+
const text = cli_flags.string_of(value)
|
|
169
165
|
|
|
170
166
|
if (text === undefined) return run_carry.NO_OWNER
|
|
171
167
|
|
|
@@ -179,7 +175,7 @@ function to_owner(value: OptionValue): CarryOwner | undefined {
|
|
|
179
175
|
// A non-empty `--summary`, or nothing. An empty string is nothing here, so it reads as absent and the
|
|
180
176
|
// pairing check below refuses it against `--retrospective`.
|
|
181
177
|
function summary_text(values: ParsedValues): string | undefined {
|
|
182
|
-
const summary =
|
|
178
|
+
const summary = cli_flags.string_of(values.summary)
|
|
183
179
|
|
|
184
180
|
return summary === undefined || summary === '' ? undefined : summary
|
|
185
181
|
}
|
|
@@ -220,17 +216,17 @@ function to_claim_request(
|
|
|
220
216
|
}
|
|
221
217
|
|
|
222
218
|
function to_other_request(values: ParsedValues, owner: CarryOwner): Request | undefined {
|
|
223
|
-
if (values.end === true) return { kind: 'end', stopped:
|
|
219
|
+
if (values.end === true) return { kind: 'end', stopped: cli_flags.string_of(values.stopped) }
|
|
224
220
|
|
|
225
221
|
return has_count(values) ? to_count_request(values, owner) : READ_REQUEST
|
|
226
222
|
}
|
|
227
223
|
|
|
228
224
|
function to_named_request(values: ParsedValues, owner: CarryOwner): Request | undefined {
|
|
229
|
-
const begun =
|
|
225
|
+
const begun = cli_flags.string_of(values.begin)
|
|
230
226
|
|
|
231
227
|
if (begun !== undefined) return to_claim_request(begun, owner, false)
|
|
232
228
|
|
|
233
|
-
const adopted =
|
|
229
|
+
const adopted = cli_flags.string_of(values.resume)
|
|
234
230
|
|
|
235
231
|
if (adopted !== undefined) return to_claim_request(adopted, owner, true)
|
|
236
232
|
|
|
@@ -47,12 +47,7 @@ type Request =
|
|
|
47
47
|
| { kind: 'end' }
|
|
48
48
|
|
|
49
49
|
function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
|
|
50
|
-
return cli_flags.
|
|
51
|
-
args: [...argv],
|
|
52
|
-
options: OPTIONS,
|
|
53
|
-
strict: true,
|
|
54
|
-
allowPositionals: true,
|
|
55
|
-
})
|
|
50
|
+
return cli_flags.arguments_of(argv, OPTIONS)
|
|
56
51
|
}
|
|
57
52
|
|
|
58
53
|
// The issue is the sole positional, and it must be a real issue number: a cut relaunches
|
|
@@ -56,10 +56,6 @@ interface ParsedArguments {
|
|
|
56
56
|
values: ParsedValues
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function read_arguments(argv: ReadonlyArray<string>): ParsedArguments | undefined {
|
|
60
|
-
return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, allowPositionals: true })
|
|
61
|
-
}
|
|
62
|
-
|
|
63
59
|
function is_valid(parsed: ParsedArguments): boolean {
|
|
64
60
|
return (
|
|
65
61
|
parsed.positionals.length === 1 &&
|
|
@@ -78,7 +74,7 @@ function to_request(parsed: ParsedArguments): EndingRequest {
|
|
|
78
74
|
}
|
|
79
75
|
|
|
80
76
|
function parse_request(argv: ReadonlyArray<string>): EndingRequest | undefined {
|
|
81
|
-
const parsed =
|
|
77
|
+
const parsed = cli_flags.arguments_of(argv, OPTIONS)
|
|
82
78
|
|
|
83
79
|
if (parsed === undefined || !is_valid(parsed)) return undefined
|
|
84
80
|
|
|
@@ -4,6 +4,7 @@ import { josh_command } from '#scripts/josh/josh-run'
|
|
|
4
4
|
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
5
5
|
import { run_cut_report } from './run-cut-report'
|
|
6
6
|
import { run_entry, type EntryParts } from './run-entry'
|
|
7
|
+
import { run_entry_stop } from './run-entry-stop'
|
|
7
8
|
import { run_halfrun_resume } from './run-halfrun-resume'
|
|
8
9
|
import { run_hold_cli } from './run-hold-cli'
|
|
9
10
|
import { run_next } from './run-next'
|
|
@@ -40,6 +41,7 @@ const NEWLINE = '\n'
|
|
|
40
41
|
const HOLD_STOP_NOTE =
|
|
41
42
|
'(tree not held — see the reason above; clean up and retry, or release with `pnpm josh run:release`)'
|
|
42
43
|
const COST_STOP_NOTE = '(session budget spent — see the figure above; resume in a fresh session)'
|
|
44
|
+
const { BUDGET_REASON, HOLD_REASON } = run_entry_stop
|
|
43
45
|
const HALFRUN_RESUME_TOKEN = 'halfrun'
|
|
44
46
|
|
|
45
47
|
// Exactly one issue number, or the call is refused — `run:entry` opens one run, and a second number
|
|
@@ -135,6 +137,23 @@ function stop_report(issue_number: string, hold: string, cost: string, report: s
|
|
|
135
137
|
return emit({ issue_number, hold, cost, verdict: run_entry.NO_VERDICT, report })
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
// A stop this command decided carries its own chores (joshuafolkken/kit#3099): the `confirmation`
|
|
141
|
+
// Telegram always, and the release where this call claimed the hold — `run_entry_stop` says why.
|
|
142
|
+
interface StopCall {
|
|
143
|
+
parts: Pick<EntryParts, 'issue_number' | 'hold' | 'cost' | 'report'>
|
|
144
|
+
command: StageCommand
|
|
145
|
+
reason: string
|
|
146
|
+
should_release: boolean
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function stop_run({ parts, command, reason, should_release }: StopCall): Promise<number> {
|
|
150
|
+
const code = stop_report(parts.issue_number, parts.hold, parts.cost, parts.report)
|
|
151
|
+
|
|
152
|
+
await run_entry_stop.stop({ issue_number: parts.issue_number, command, reason, should_release })
|
|
153
|
+
|
|
154
|
+
return code
|
|
155
|
+
}
|
|
156
|
+
|
|
138
157
|
interface Resume {
|
|
139
158
|
token: string
|
|
140
159
|
code: number
|
|
@@ -166,16 +185,22 @@ function resume_report(issue_number: string, resume: Resume): number {
|
|
|
166
185
|
// to and including implementation. **A `prrun` stopped before its merge is resumed the same way**
|
|
167
186
|
// (joshuafolkken/kit#3023), under the token that says what is left of it.
|
|
168
187
|
async function resume_stopped(
|
|
169
|
-
issue_number:
|
|
188
|
+
{ issue_number, command }: EntryRequest,
|
|
170
189
|
token: string,
|
|
171
190
|
stopped_run: { adopt: (issue: string) => Promise<boolean> },
|
|
172
191
|
): Promise<number> {
|
|
173
192
|
const cost = await check_cost()
|
|
174
193
|
|
|
175
|
-
if (cost === run_entry.COST_OVER)
|
|
194
|
+
if (cost === run_entry.COST_OVER) {
|
|
195
|
+
const parts = { issue_number, hold: token, cost, report: COST_STOP_NOTE }
|
|
196
|
+
|
|
197
|
+
return await stop_run({ parts, command, reason: BUDGET_REASON, should_release: false })
|
|
198
|
+
}
|
|
176
199
|
|
|
177
200
|
if (!(await stopped_run.adopt(issue_number))) {
|
|
178
|
-
|
|
201
|
+
const parts = { issue_number, hold: run_hold_cli.BUSY_VERDICT, cost, report: HOLD_STOP_NOTE }
|
|
202
|
+
|
|
203
|
+
return await stop_run({ parts, command, reason: HOLD_REASON, should_release: false })
|
|
179
204
|
}
|
|
180
205
|
|
|
181
206
|
console.info(`entry #${issue_number} — resume: ${token}`)
|
|
@@ -186,17 +211,17 @@ async function resume_stopped(
|
|
|
186
211
|
// The stopped run a decision picks up: a `halfrun` stop resumes at the gate, a `prrun` stop at
|
|
187
212
|
// `followup` under its own token. `undefined` for any other start, so the ordinary claim decides.
|
|
188
213
|
async function resume_any(
|
|
189
|
-
|
|
214
|
+
request: EntryRequest,
|
|
190
215
|
decision: StageDecision,
|
|
191
216
|
stage: StageRead,
|
|
192
217
|
): Promise<number | undefined> {
|
|
193
218
|
if (decision.state === run_stage.HALFRUN_STOPPED) {
|
|
194
|
-
return await resume_stopped(
|
|
219
|
+
return await resume_stopped(request, HALFRUN_RESUME_TOKEN, run_halfrun_resume)
|
|
195
220
|
}
|
|
196
221
|
|
|
197
222
|
return stage.prrun_token === undefined
|
|
198
223
|
? undefined
|
|
199
|
-
: await resume_stopped(
|
|
224
|
+
: await resume_stopped(request, stage.prrun_token, run_prrun_resume)
|
|
200
225
|
}
|
|
201
226
|
|
|
202
227
|
// **A command whose stopping point the issue has already reached redoes nothing** (joshuafolkken/kit#3042)
|
|
@@ -210,12 +235,18 @@ async function claim_run({ issue_number, command }: EntryRequest): Promise<numbe
|
|
|
210
235
|
const hold = await claim_hold(issue_number, command)
|
|
211
236
|
|
|
212
237
|
if (hold !== run_hold_cli.HOLD_VERDICT) {
|
|
213
|
-
|
|
238
|
+
const parts = { issue_number, hold, cost: run_entry.COST_SKIPPED, report: HOLD_STOP_NOTE }
|
|
239
|
+
|
|
240
|
+
return await stop_run({ parts, command, reason: HOLD_REASON, should_release: false })
|
|
214
241
|
}
|
|
215
242
|
|
|
216
243
|
const cost = await check_cost()
|
|
217
244
|
|
|
218
|
-
if (cost === run_entry.COST_OVER)
|
|
245
|
+
if (cost === run_entry.COST_OVER) {
|
|
246
|
+
const parts = { issue_number, hold, cost, report: COST_STOP_NOTE }
|
|
247
|
+
|
|
248
|
+
return await stop_run({ parts, command, reason: BUDGET_REASON, should_release: true })
|
|
249
|
+
}
|
|
219
250
|
|
|
220
251
|
const reads = await gather_reads(issue_number)
|
|
221
252
|
|
|
@@ -248,7 +279,7 @@ async function open_run(request: EntryRequest): Promise<number> {
|
|
|
248
279
|
|
|
249
280
|
if (command === run_stage.KICKOFF || is_settled(decision)) return SUCCESS_EXIT_CODE
|
|
250
281
|
|
|
251
|
-
return (await resume_any(
|
|
282
|
+
return (await resume_any(request, decision, stage)) ?? (await claim_run(request))
|
|
252
283
|
}
|
|
253
284
|
|
|
254
285
|
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { josh_command } from '#scripts/josh/josh-run'
|
|
2
|
+
import { telegram_notify } from '#scripts/notify/telegram-notify'
|
|
3
|
+
import { lane_park } from '#scripts/rules/lane-park'
|
|
4
|
+
|
|
5
|
+
// What `run:entry` does on the way out of a stop it decided itself (joshuafolkken/kit#3099).
|
|
6
|
+
//
|
|
7
|
+
// **The stop's chores used to be the agent's to remember, and the `Stop` hook was what reminded it.**
|
|
8
|
+
// `fullrun.md` step 3 asked the agent, on an `over` budget, to send the `confirmation` Telegram and then
|
|
9
|
+
// run `pnpm josh run:release` — two calls after a command had already decided the run was over. Across
|
|
10
|
+
// eleven measured sessions the hook sent a reply back five times for exactly those two calls, each one a
|
|
11
|
+
// further turn re-reading the whole context. The command that decides the stop knows everything the
|
|
12
|
+
// chores need, so it does them, and the hook stays a safety net that a normal run never trips.
|
|
13
|
+
//
|
|
14
|
+
// **The release is the claim's own undo, and only where this call made the claim.** A resume over a
|
|
15
|
+
// stopped `halfrun` / `prrun` stops on the budget before adopting anything, and the hold there is the
|
|
16
|
+
// stopped run's — kept on purpose over its verified diff — so that stop notifies and releases nothing.
|
|
17
|
+
// A `busy` / `unknown` hold is another run's, or none at all, so it is never released here either.
|
|
18
|
+
//
|
|
19
|
+
// **A dispatched lane child sends no Telegram from here.** Its stop must park the Issue before it
|
|
20
|
+
// notifies, and `lane_park`'s guard delivers that rule at the `josh notify` call — a Telegram sent from
|
|
21
|
+
// inside this command would pass that guard by and satisfy the `Stop` hook with no park behind it.
|
|
22
|
+
|
|
23
|
+
interface StopNotice {
|
|
24
|
+
issue_number: string
|
|
25
|
+
command: string
|
|
26
|
+
reason: string
|
|
27
|
+
should_release: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface StopPorts {
|
|
31
|
+
notify: (input: { issue_title: string; body: string; recovery: string }) => Promise<boolean>
|
|
32
|
+
release: (issue_number: string) => Promise<boolean>
|
|
33
|
+
is_parking_child: () => boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RECOVERY = 'If this did not arrive, the run:entry output in the session names the stop.'
|
|
37
|
+
const NOTIFY_FAILED_NOTE =
|
|
38
|
+
'(the confirmation Telegram could not be sent — send it with `pnpm josh notify --task-type confirmation` before stopping)'
|
|
39
|
+
const RELEASED_NOTE = '(hold released by run:entry — the tree is free for the resume)'
|
|
40
|
+
const RELEASE_FAILED_NOTE =
|
|
41
|
+
'(the hold could not be released — run `pnpm josh run:release` before stopping)'
|
|
42
|
+
const should_forward_stderr = true
|
|
43
|
+
const SUCCESS_EXIT_CODE = 0
|
|
44
|
+
|
|
45
|
+
const BUDGET_REASON = 'the session budget is spent'
|
|
46
|
+
const HOLD_REASON = 'the working tree is not held for this run — see the run:entry output'
|
|
47
|
+
|
|
48
|
+
async function release(issue_number: string): Promise<boolean> {
|
|
49
|
+
const released = await josh_command.josh_run(['run:release', issue_number], should_forward_stderr)
|
|
50
|
+
|
|
51
|
+
return released.code === SUCCESS_EXIT_CODE
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function is_parking_child(): boolean {
|
|
55
|
+
return lane_park.is_parking_child()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_PORTS: StopPorts = { notify: telegram_notify.confirm, release, is_parking_child }
|
|
59
|
+
|
|
60
|
+
function body_of(notice: StopNotice): string {
|
|
61
|
+
return `${notice.command} #${notice.issue_number} stopped at entry: ${notice.reason}.\nResume with \`${notice.command} #${notice.issue_number}\`.`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The release runs first, so the resume the Telegram names finds the tree free when a person acts on it.
|
|
65
|
+
// The marker line is printed only for a Telegram that went out: the `Stop` hook reads it as the notify,
|
|
66
|
+
// and a failed send has to leave the hook still asking for one. The released note, likewise, is printed
|
|
67
|
+
// only for a release that succeeded, so a failed one leaves the agent still asked to release.
|
|
68
|
+
async function release_claim(notice: StopNotice, ports: StopPorts): Promise<void> {
|
|
69
|
+
if (!notice.should_release) return
|
|
70
|
+
|
|
71
|
+
const is_released = await ports.release(notice.issue_number)
|
|
72
|
+
|
|
73
|
+
console.info(is_released ? RELEASED_NOTE : RELEASE_FAILED_NOTE)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function notify_stop(notice: StopNotice, ports: StopPorts): Promise<void> {
|
|
77
|
+
if (ports.is_parking_child()) return
|
|
78
|
+
|
|
79
|
+
const is_sent = await ports.notify({
|
|
80
|
+
issue_title: `${notice.command} #${notice.issue_number} stopped`,
|
|
81
|
+
body: body_of(notice),
|
|
82
|
+
recovery: RECOVERY,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
console.info(is_sent ? lane_park.COMMAND_NOTIFY_MARKER : NOTIFY_FAILED_NOTE)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function stop(notice: StopNotice, ports: StopPorts = DEFAULT_PORTS): Promise<void> {
|
|
89
|
+
await release_claim(notice, ports)
|
|
90
|
+
await notify_stop(notice, ports)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const run_entry_stop = {
|
|
94
|
+
BUDGET_REASON,
|
|
95
|
+
HOLD_REASON,
|
|
96
|
+
NOTIFY_FAILED_NOTE,
|
|
97
|
+
RELEASED_NOTE,
|
|
98
|
+
RELEASE_FAILED_NOTE,
|
|
99
|
+
body_of,
|
|
100
|
+
stop,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { run_entry_stop }
|
|
104
|
+
export type { StopNotice, StopPorts }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { error_text } from '#scripts/lib/error-message'
|
|
1
2
|
import { run_carry } from './run-carry'
|
|
2
3
|
import { run_event_scope } from './run-event-scope'
|
|
3
4
|
import { run_event_stream, type RunEvent } from './run-event-stream'
|
|
@@ -62,8 +63,10 @@ async function emit_once(kind: string, text: string): Promise<boolean> {
|
|
|
62
63
|
if (target === undefined || run_event_stream.read_last(target)?.kind === kind) return false
|
|
63
64
|
|
|
64
65
|
return run_event_stream.append(target, kind, text, now_iso()).appended
|
|
65
|
-
} catch {
|
|
66
|
+
} catch (error) {
|
|
66
67
|
// Best-effort: a failed append is dropped rather than raised into the loop's work.
|
|
68
|
+
error_text.trace_swallowed('run_event_stream_emit.emit_once', error)
|
|
69
|
+
|
|
67
70
|
return false
|
|
68
71
|
}
|
|
69
72
|
}
|
|
@@ -98,8 +101,10 @@ async function emit_once_since(kind: string, text: string, reset_kind: string):
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
return run_event_stream.append(target, kind, text, now_iso()).appended
|
|
101
|
-
} catch {
|
|
104
|
+
} catch (error) {
|
|
102
105
|
// Best-effort: a failed append is dropped rather than raised into the caller's work.
|
|
106
|
+
error_text.trace_swallowed('run_event_stream_emit.emit_once_since', error)
|
|
107
|
+
|
|
103
108
|
return false
|
|
104
109
|
}
|
|
105
110
|
}
|
package/scripts/run/run-hold.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { git_command } from '#scripts/git/git-command'
|
|
2
2
|
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
3
|
+
import { error_text } from '#scripts/lib/error-message'
|
|
3
4
|
import { z } from 'zod'
|
|
4
5
|
|
|
5
6
|
// joshuafolkken/kit#1091: `epicrun` asks `epic-busy.ts` whether a *repository* already has a child in
|
|
@@ -196,7 +197,9 @@ async function is_tree_dirty(directory?: string): Promise<boolean> {
|
|
|
196
197
|
const status = await git_command.status(directory)
|
|
197
198
|
|
|
198
199
|
return status.trim() !== ''
|
|
199
|
-
} catch {
|
|
200
|
+
} catch (error) {
|
|
201
|
+
error_text.trace_swallowed('run_hold.is_tree_dirty', error)
|
|
202
|
+
|
|
200
203
|
return true
|
|
201
204
|
}
|
|
202
205
|
}
|
|
@@ -89,10 +89,6 @@ interface ParsedArguments {
|
|
|
89
89
|
values: ParsedValues
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function read_arguments(argv: ReadonlyArray<string>): ParsedArguments | undefined {
|
|
93
|
-
return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, allowPositionals: true })
|
|
94
|
-
}
|
|
95
|
-
|
|
96
92
|
function is_valid_target(parsed: ParsedArguments): boolean {
|
|
97
93
|
return (
|
|
98
94
|
parsed.positionals.length === 1 &&
|
|
@@ -136,7 +132,7 @@ function to_request(parsed: ParsedArguments): LivenessRequest {
|
|
|
136
132
|
}
|
|
137
133
|
|
|
138
134
|
function parse_request(argv: ReadonlyArray<string>): LivenessRequest | undefined {
|
|
139
|
-
const parsed =
|
|
135
|
+
const parsed = cli_flags.arguments_of(argv, OPTIONS)
|
|
140
136
|
|
|
141
137
|
if (parsed === undefined || !is_valid(parsed)) return undefined
|
|
142
138
|
|