dorfl 0.1.2 → 0.2.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/dist/advance-drivers.d.ts +1 -1
- package/dist/advance-drivers.d.ts.map +1 -1
- package/dist/advance-drivers.js +15 -3
- package/dist/advance-drivers.js.map +1 -1
- package/dist/advance-lifecycle-template.d.ts.map +1 -1
- package/dist/advance-lifecycle-template.js +76 -4
- package/dist/advance-lifecycle-template.js.map +1 -1
- package/dist/advance-treeless-publish.d.ts +23 -0
- package/dist/advance-treeless-publish.d.ts.map +1 -1
- package/dist/advance-treeless-publish.js +41 -0
- package/dist/advance-treeless-publish.js.map +1 -1
- package/dist/advance.d.ts +59 -7
- package/dist/advance.d.ts.map +1 -1
- package/dist/advance.js +389 -82
- package/dist/advance.js.map +1 -1
- package/dist/advancing-lock.d.ts +30 -2
- package/dist/advancing-lock.d.ts.map +1 -1
- package/dist/advancing-lock.js +50 -0
- package/dist/advancing-lock.js.map +1 -1
- package/dist/agent-launch.d.ts +12 -0
- package/dist/agent-launch.d.ts.map +1 -1
- package/dist/agent-launch.js +22 -12
- package/dist/agent-launch.js.map +1 -1
- package/dist/agent-stop.d.ts +40 -2
- package/dist/agent-stop.d.ts.map +1 -1
- package/dist/agent-stop.js +30 -2
- package/dist/agent-stop.js.map +1 -1
- package/dist/apply-decide.d.ts +17 -3
- package/dist/apply-decide.d.ts.map +1 -1
- package/dist/apply-decide.js +36 -7
- package/dist/apply-decide.js.map +1 -1
- package/dist/apply-merge-action.d.ts +17 -5
- package/dist/apply-merge-action.d.ts.map +1 -1
- package/dist/apply-merge-action.js +43 -8
- package/dist/apply-merge-action.js.map +1 -1
- package/dist/apply-persist.d.ts +57 -26
- package/dist/apply-persist.d.ts.map +1 -1
- package/dist/apply-persist.js +166 -37
- package/dist/apply-persist.js.map +1 -1
- package/dist/apply-stuck-action.d.ts +151 -0
- package/dist/apply-stuck-action.d.ts.map +1 -0
- package/dist/apply-stuck-action.js +125 -0
- package/dist/apply-stuck-action.js.map +1 -0
- package/dist/brand.d.ts +12 -1
- package/dist/brand.d.ts.map +1 -1
- package/dist/brand.js +2 -1
- package/dist/brand.js.map +1 -1
- package/dist/cli-spinner.d.ts +1 -1
- package/dist/cli-spinner.js +1 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +320 -63
- package/dist/cli.js.map +1 -1
- package/dist/close-job-template.js +1 -1
- package/dist/complete.d.ts +6 -3
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +91 -37
- package/dist/complete.js.map +1 -1
- package/dist/config.d.ts +55 -9
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +56 -4
- package/dist/config.js.map +1 -1
- package/dist/continue-branch.d.ts.map +1 -1
- package/dist/continue-branch.js +22 -0
- package/dist/continue-branch.js.map +1 -1
- package/dist/decision-engine.d.ts +38 -10
- package/dist/decision-engine.d.ts.map +1 -1
- package/dist/decision-engine.js +10 -6
- package/dist/decision-engine.js.map +1 -1
- package/dist/do-autopick.d.ts.map +1 -1
- package/dist/do-autopick.js +11 -1
- package/dist/do-autopick.js.map +1 -1
- package/dist/do-config.d.ts +1 -1
- package/dist/do.d.ts +49 -56
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +419 -30
- package/dist/do.js.map +1 -1
- package/dist/env-config.d.ts.map +1 -1
- package/dist/env-config.js +12 -4
- package/dist/env-config.js.map +1 -1
- package/dist/failure-cause.d.ts +3 -2
- package/dist/failure-cause.d.ts.map +1 -1
- package/dist/failure-cause.js +28 -1
- package/dist/failure-cause.js.map +1 -1
- package/dist/format.d.ts +6 -6
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +10 -27
- package/dist/format.js.map +1 -1
- package/dist/frontmatter.d.ts +16 -4
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +27 -1
- package/dist/frontmatter.js.map +1 -1
- package/dist/gc.d.ts +65 -6
- package/dist/gc.d.ts.map +1 -1
- package/dist/gc.js +126 -9
- package/dist/gc.js.map +1 -1
- package/dist/github.d.ts +14 -0
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +73 -0
- package/dist/github.js.map +1 -1
- package/dist/harness.d.ts +25 -0
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/install-ci-branch-protection.d.ts +132 -38
- package/dist/install-ci-branch-protection.d.ts.map +1 -1
- package/dist/install-ci-branch-protection.js +190 -48
- package/dist/install-ci-branch-protection.js.map +1 -1
- package/dist/install-ci-core.d.ts +21 -1
- package/dist/install-ci-core.d.ts.map +1 -1
- package/dist/install-ci-core.js +10 -3
- package/dist/install-ci-core.js.map +1 -1
- package/dist/install-ci-github.d.ts +32 -0
- package/dist/install-ci-github.d.ts.map +1 -1
- package/dist/install-ci-github.js +73 -0
- package/dist/install-ci-github.js.map +1 -1
- package/dist/install-skills.d.ts +123 -0
- package/dist/install-skills.d.ts.map +1 -0
- package/dist/install-skills.js +100 -0
- package/dist/install-skills.js.map +1 -0
- package/dist/intake-trigger-template.js +1 -1
- package/dist/intake.d.ts +17 -4
- package/dist/intake.d.ts.map +1 -1
- package/dist/intake.js +23 -7
- package/dist/intake.js.map +1 -1
- package/dist/integration-core.d.ts +10 -7
- package/dist/integration-core.d.ts.map +1 -1
- package/dist/integration-core.js +174 -60
- package/dist/integration-core.js.map +1 -1
- package/dist/integrator.d.ts +1 -1
- package/dist/integrator.d.ts.map +1 -1
- package/dist/integrator.js +19 -3
- package/dist/integrator.js.map +1 -1
- package/dist/isolation.d.ts +2 -2
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +9 -0
- package/dist/isolation.js.map +1 -1
- package/dist/item-lock.d.ts +196 -129
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +329 -254
- package/dist/item-lock.js.map +1 -1
- package/dist/ledger-write.d.ts +31 -26
- package/dist/ledger-write.d.ts.map +1 -1
- package/dist/ledger-write.js +100 -122
- package/dist/ledger-write.js.map +1 -1
- package/dist/lifecycle-gather.d.ts +17 -0
- package/dist/lifecycle-gather.d.ts.map +1 -1
- package/dist/lifecycle-gather.js +7 -1
- package/dist/lifecycle-gather.js.map +1 -1
- package/dist/lifecycle-pools.d.ts +40 -3
- package/dist/lifecycle-pools.d.ts.map +1 -1
- package/dist/lifecycle-pools.js +26 -6
- package/dist/lifecycle-pools.js.map +1 -1
- package/dist/merge-question-surfacer.d.ts +15 -1
- package/dist/merge-question-surfacer.d.ts.map +1 -1
- package/dist/merge-question-surfacer.js +18 -3
- package/dist/merge-question-surfacer.js.map +1 -1
- package/dist/migrate-stuck-locks.d.ts +129 -0
- package/dist/migrate-stuck-locks.d.ts.map +1 -0
- package/dist/migrate-stuck-locks.js +355 -0
- package/dist/migrate-stuck-locks.js.map +1 -0
- package/dist/needs-attention.d.ts +293 -16
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +532 -57
- package/dist/needs-attention.js.map +1 -1
- package/dist/orphan-sidecar.d.ts +8 -4
- package/dist/orphan-sidecar.d.ts.map +1 -1
- package/dist/orphan-sidecar.js +35 -2
- package/dist/orphan-sidecar.js.map +1 -1
- package/dist/pi-harness.d.ts +16 -0
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +82 -2
- package/dist/pi-harness.js.map +1 -1
- package/dist/placement.d.ts +3 -3
- package/dist/placement.js +1 -1
- package/dist/prd-to-spec.d.ts.map +1 -1
- package/dist/prd-to-spec.js +9 -5
- package/dist/prd-to-spec.js.map +1 -1
- package/dist/protocol/CLAIM-PROTOCOL.md +17 -10
- package/dist/protocol/REVIEW-PROTOCOL.md +4 -1
- package/dist/protocol/SURFACE-PROTOCOL.md +16 -2
- package/dist/protocol/TASKING-PROTOCOL.md +3 -1
- package/dist/protocol/WORK-CONTRACT.md +22 -18
- package/dist/protocol/task-template.md +1 -1
- package/dist/reap-branches.d.ts +12 -9
- package/dist/reap-branches.d.ts.map +1 -1
- package/dist/reap-branches.js +25 -7
- package/dist/reap-branches.js.map +1 -1
- package/dist/recover-isolated.d.ts +12 -0
- package/dist/recover-isolated.d.ts.map +1 -1
- package/dist/recover-isolated.js +5 -0
- package/dist/recover-isolated.js.map +1 -1
- package/dist/repo-config.d.ts +23 -2
- package/dist/repo-config.d.ts.map +1 -1
- package/dist/repo-config.js +53 -7
- package/dist/repo-config.js.map +1 -1
- package/dist/repo-mirror.d.ts.map +1 -1
- package/dist/repo-mirror.js +18 -2
- package/dist/repo-mirror.js.map +1 -1
- package/dist/review-verdict.js +1 -1
- package/dist/review-verdict.js.map +1 -1
- package/dist/run.d.ts +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +54 -19
- package/dist/run.js.map +1 -1
- package/dist/scan.d.ts +17 -8
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +22 -8
- package/dist/scan.js.map +1 -1
- package/dist/select-priority.d.ts +2 -2
- package/dist/select-priority.js +1 -1
- package/dist/select-priority.js.map +1 -1
- package/dist/sidecar-apply.js +1 -1
- package/dist/sidecar-apply.js.map +1 -1
- package/dist/sidecar.d.ts +45 -10
- package/dist/sidecar.d.ts.map +1 -1
- package/dist/sidecar.js +82 -2
- package/dist/sidecar.js.map +1 -1
- package/dist/skills/answer-questions/SKILL.md +89 -0
- package/dist/skills/capture-signal/SKILL.md +52 -0
- package/dist/skills/convert-from-prd-to-spec/SKILL.md +90 -0
- package/dist/skills/drive-tasks/SKILL.md +218 -0
- package/dist/skills/from-idea/SKILL.md +83 -0
- package/dist/skills/merge-prs/SKILL.md +70 -0
- package/dist/skills/orchestrate/SKILL.md +101 -0
- package/dist/skills/promote/SKILL.md +35 -0
- package/dist/skills/review/SKILL.md +16 -0
- package/dist/skills/setup/SKILL.md +258 -0
- package/dist/skills/setup/protocol/ADR-FORMAT.md +47 -0
- package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +224 -0
- package/dist/skills/setup/protocol/REVIEW-PROTOCOL.md +122 -0
- package/dist/skills/setup/protocol/SURFACE-PROTOCOL.md +135 -0
- package/dist/skills/setup/protocol/TASKING-PROTOCOL.md +124 -0
- package/dist/skills/setup/protocol/WORK-CONTRACT.md +280 -0
- package/dist/skills/setup/protocol/spec-template.md +71 -0
- package/dist/skills/setup/protocol/task-template.md +65 -0
- package/dist/skills/surface-questions/SKILL.md +16 -0
- package/dist/skills/to-spec/SKILL.md +34 -0
- package/dist/skills/to-task/SKILL.md +19 -0
- package/dist/skills/triage-observations/SKILL.md +78 -0
- package/dist/skills/work/SKILL.md +51 -0
- package/dist/slug-namespace.d.ts +5 -5
- package/dist/slug-namespace.js +5 -5
- package/dist/slug-namespace.js.map +1 -1
- package/dist/spec-complete.d.ts.map +1 -1
- package/dist/spec-complete.js +2 -3
- package/dist/spec-complete.js.map +1 -1
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +51 -57
- package/dist/start.js.map +1 -1
- package/dist/surface-gate.d.ts.map +1 -1
- package/dist/surface-gate.js +10 -3
- package/dist/surface-gate.js.map +1 -1
- package/dist/surface-persist.js +1 -1
- package/dist/surface-persist.js.map +1 -1
- package/dist/tasker-review-loop.d.ts +4 -3
- package/dist/tasker-review-loop.d.ts.map +1 -1
- package/dist/tasker-review-loop.js.map +1 -1
- package/dist/tasking-lock.d.ts +2 -2
- package/dist/tasking-lock.d.ts.map +1 -1
- package/dist/tasking-lock.js +53 -32
- package/dist/tasking-lock.js.map +1 -1
- package/dist/tasking.d.ts +40 -6
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +224 -12
- package/dist/tasking.js.map +1 -1
- package/dist/triage-persist.d.ts +10 -6
- package/dist/triage-persist.d.ts.map +1 -1
- package/dist/triage-persist.js +34 -4
- package/dist/triage-persist.js.map +1 -1
- package/dist/vendor/incur/agents.d.ts +58 -0
- package/dist/vendor/incur/agents.d.ts.map +1 -0
- package/dist/vendor/incur/agents.js +343 -0
- package/dist/vendor/incur/agents.js.map +1 -0
- package/dist/verify-workflow-template.js +1 -1
- package/dist/watch-session.d.ts +11 -3
- package/dist/watch-session.d.ts.map +1 -1
- package/dist/watch-session.js +94 -7
- package/dist/watch-session.js.map +1 -1
- package/dist/work-layout.d.ts +7 -3
- package/dist/work-layout.d.ts.map +1 -1
- package/dist/work-layout.js +6 -3
- package/dist/work-layout.js.map +1 -1
- package/dist/workspace.d.ts +1 -1
- package/package.json +2 -2
- package/src/advance-drivers.ts +16 -3
- package/src/advance-lifecycle-template.ts +89 -4
- package/src/advance-treeless-publish.ts +45 -0
- package/src/advance.ts +467 -87
- package/src/advancing-lock.ts +96 -1
- package/src/agent-launch.ts +37 -12
- package/src/agent-stop.ts +60 -2
- package/src/apply-decide.ts +36 -7
- package/src/apply-merge-action.ts +45 -7
- package/src/apply-persist.ts +228 -54
- package/src/apply-stuck-action.ts +260 -0
- package/src/brand.ts +14 -2
- package/src/cli-spinner.ts +1 -1
- package/src/cli.ts +401 -70
- package/src/close-job-template.ts +1 -1
- package/src/complete.ts +110 -52
- package/src/config.ts +107 -13
- package/src/continue-branch.ts +22 -0
- package/src/decision-engine.ts +55 -17
- package/src/do-autopick.ts +12 -1
- package/src/do-config.ts +1 -1
- package/src/do.ts +511 -38
- package/src/env-config.ts +12 -4
- package/src/failure-cause.ts +30 -1
- package/src/format.ts +10 -30
- package/src/frontmatter.ts +35 -5
- package/src/gc.ts +172 -9
- package/src/github.ts +78 -0
- package/src/harness.ts +25 -0
- package/src/index.ts +10 -0
- package/src/install-ci-branch-protection.ts +282 -57
- package/src/install-ci-core.ts +30 -3
- package/src/install-ci-github.ts +86 -0
- package/src/install-skills.ts +166 -0
- package/src/intake-trigger-template.ts +1 -1
- package/src/intake.ts +46 -11
- package/src/integration-core.ts +199 -65
- package/src/integrator.ts +22 -9
- package/src/isolation.ts +11 -2
- package/src/item-lock.ts +485 -341
- package/src/ledger-write.ts +135 -150
- package/src/lifecycle-gather.ts +29 -6
- package/src/lifecycle-pools.ts +67 -9
- package/src/merge-question-surfacer.ts +28 -4
- package/src/migrate-stuck-locks.ts +451 -0
- package/src/needs-attention.ts +857 -84
- package/src/orphan-sidecar.ts +47 -6
- package/src/pi-harness.ts +82 -2
- package/src/placement.ts +3 -3
- package/src/prd-to-spec.ts +13 -5
- package/src/reap-branches.ts +38 -14
- package/src/recover-isolated.ts +17 -0
- package/src/repo-config.ts +55 -6
- package/src/repo-mirror.ts +23 -1
- package/src/review-verdict.ts +1 -1
- package/src/run.ts +64 -18
- package/src/scan.ts +22 -8
- package/src/select-priority.ts +3 -3
- package/src/sidecar-apply.ts +1 -1
- package/src/sidecar.ts +138 -11
- package/src/slug-namespace.ts +7 -7
- package/src/spec-complete.ts +2 -3
- package/src/start.ts +53 -59
- package/src/surface-gate.ts +10 -3
- package/src/surface-persist.ts +1 -1
- package/src/tasker-review-loop.ts +4 -3
- package/src/tasking-lock.ts +52 -36
- package/src/tasking.ts +261 -17
- package/src/triage-persist.ts +56 -12
- package/src/vendor/incur/LICENSE +21 -0
- package/src/vendor/incur/README.md +19 -0
- package/src/vendor/incur/agents.ts +392 -0
- package/src/verify-workflow-template.ts +1 -1
- package/src/watch-session.ts +102 -7
- package/src/work-layout.ts +6 -3
- package/src/workspace.ts +1 -1
package/dist/item-lock.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { runAsync } from './git.js';
|
|
3
|
-
import { resolveSidecarIdentity } from './sidecar.js';
|
|
3
|
+
import { resolveSidecarIdentity, sidecarPathFor, } from './sidecar.js';
|
|
4
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
4
5
|
import { workItemRel } from './work-layout.js';
|
|
5
6
|
/**
|
|
6
7
|
* The **unified item-lock module** (spec `ledger-status-per-item-lock-refs`, ADR
|
|
@@ -72,6 +73,21 @@ export function lockEntryFor(item) {
|
|
|
72
73
|
export function itemLockRef(entry) {
|
|
73
74
|
return `${LOCK_REF_PREFIX}/${entry}`;
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The character class a LITERAL lock-entry name must match to be addressable
|
|
78
|
+
* through `release-lock --entry <literal>` (task
|
|
79
|
+
* `release-lock-entry-escape-hatch-and-literal-entry-reporting`): a NON-EMPTY run
|
|
80
|
+
* of `[A-Za-z0-9._-]` — the SAME shape the minting side produces (`<type>-<slug>`,
|
|
81
|
+
* and the pre-cutover `slice-<slug>` / `prd-<slug>` entries it must be able to
|
|
82
|
+
* name). It deliberately EXCLUDES `/` and whitespace so a literal can never escape
|
|
83
|
+
* the `refs/dorfl/lock/` namespace (a `/` would address a different ref path; a
|
|
84
|
+
* space would break the push refspec). This is the ONLY validation the `--entry`
|
|
85
|
+
* escape hatch performs before the git operation — the entry name is taken
|
|
86
|
+
* literally otherwise, bypassing the namespace mapping the item-form path uses.
|
|
87
|
+
*/
|
|
88
|
+
export function isValidLockEntryName(entry) {
|
|
89
|
+
return /^[A-Za-z0-9._-]+$/.test(entry);
|
|
90
|
+
}
|
|
75
91
|
function gitSoft(args, cwd, env) {
|
|
76
92
|
return runAsync('git', args, cwd, { env });
|
|
77
93
|
}
|
|
@@ -82,22 +98,16 @@ async function gitHard(args, cwd, env) {
|
|
|
82
98
|
}
|
|
83
99
|
return r;
|
|
84
100
|
}
|
|
85
|
-
/** The body heading that opens the (possibly multi-line) stuck reason prose. */
|
|
86
|
-
const LOCK_REASON_HEADING = '## Reason';
|
|
87
|
-
/** The body heading that opens the agent-surfaced questions list. */
|
|
88
|
-
const LOCK_QUESTIONS_HEADING = '## Questions';
|
|
89
101
|
/**
|
|
90
102
|
* Serialise a lock entry to the ref's blob body (markdown frontmatter, like the
|
|
91
|
-
* advancing marker, so it round-trips and is previewable).
|
|
92
|
-
* (`
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* future advance-surface rung can render. {@link parseLockEntry} is the exact
|
|
97
|
-
* inverse.
|
|
103
|
+
* advancing marker, so it round-trips and is previewable). Post-CONTRACT step
|
|
104
|
+
* (task `retire-stuck-lock-state`) the body carries ONLY the identity block —
|
|
105
|
+
* no `## Reason` / `## Questions` sections, because the retired `stuck` state
|
|
106
|
+
* was the only state that populated them. {@link parseLockEntry} is the inverse
|
|
107
|
+
* and tolerates a legacy body with those headings by ignoring them.
|
|
98
108
|
*/
|
|
99
109
|
export function serialiseLockEntry(e) {
|
|
100
|
-
|
|
110
|
+
return [
|
|
101
111
|
'---',
|
|
102
112
|
`entry: ${e.entry}`,
|
|
103
113
|
`action: ${e.action}`,
|
|
@@ -107,18 +117,8 @@ export function serialiseLockEntry(e) {
|
|
|
107
117
|
'---',
|
|
108
118
|
'',
|
|
109
119
|
`Lock held for \`${e.entry}\` (${e.action}/${e.state}).`,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
lines.push('', LOCK_REASON_HEADING, '', ...e.reason.split('\n'));
|
|
113
|
-
}
|
|
114
|
-
if (e.state === 'stuck' && e.questions && e.questions.length > 0) {
|
|
115
|
-
lines.push('', LOCK_QUESTIONS_HEADING, '');
|
|
116
|
-
for (const q of e.questions) {
|
|
117
|
-
lines.push(`- ${q}`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
lines.push('');
|
|
121
|
-
return lines.join('\n');
|
|
120
|
+
'',
|
|
121
|
+
].join('\n');
|
|
122
122
|
}
|
|
123
123
|
/**
|
|
124
124
|
* Build a PARENTLESS commit whose tree contains the single lock-entry blob, and
|
|
@@ -208,13 +208,22 @@ export async function acquireItemLock(opts) {
|
|
|
208
208
|
* becomes unreachable for gc. Idempotent: deleting an absent ref is `not-held`.
|
|
209
209
|
*/
|
|
210
210
|
export async function releaseItemLock(opts) {
|
|
211
|
-
const arbiter = opts.arbiter ?? 'origin';
|
|
212
|
-
const env = opts.env;
|
|
213
|
-
const cwd = opts.cwd;
|
|
214
211
|
if (!opts.item) {
|
|
215
212
|
return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
|
|
216
213
|
}
|
|
217
|
-
|
|
214
|
+
return releaseLockEntry(lockEntryFor(opts.item), opts.cwd, opts.arbiter ?? 'origin', opts.env);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The ONE entry-keyed release core shared by BOTH the item-form path
|
|
218
|
+
* ({@link releaseItemLock}, which derives the `<entry>` through the namespace
|
|
219
|
+
* mapping) AND the LITERAL escape hatch ({@link releaseLiteralLockEntry}, which
|
|
220
|
+
* takes the `<entry>` verbatim). It fetches the lock refs, checks the ref, and
|
|
221
|
+
* performs the SAME leased delete (`--force-with-lease=<ref>:<cur>`) with the SAME
|
|
222
|
+
* `not-held` / `released` / `error` semantics — there is deliberately NO second
|
|
223
|
+
* delete mechanism (task `release-lock-entry-escape-hatch-and-literal-entry-reporting`).
|
|
224
|
+
* The ONLY difference between the two callers is HOW the `<entry>` is obtained.
|
|
225
|
+
*/
|
|
226
|
+
async function releaseLockEntry(entry, cwd, arbiter, env) {
|
|
218
227
|
const ref = itemLockRef(entry);
|
|
219
228
|
try {
|
|
220
229
|
await gitHard([
|
|
@@ -258,6 +267,39 @@ export async function releaseItemLock(opts) {
|
|
|
258
267
|
};
|
|
259
268
|
}
|
|
260
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* The `release-lock --entry <literal>` ESCAPE HATCH (task
|
|
272
|
+
* `release-lock-entry-escape-hatch-and-literal-entry-reporting`): release a lock
|
|
273
|
+
* whose `<entry>` name is NOT derivable from any CURRENT item-form — a lock minted
|
|
274
|
+
* BEFORE the slice→task / `prd-to-spec` vocabulary cutover (`slice-<slug>`,
|
|
275
|
+
* `prd-<slug>`), which the item-form path can no longer name because there is no
|
|
276
|
+
* item-form that produces those entries anymore.
|
|
277
|
+
*
|
|
278
|
+
* It takes the `<entry>` LITERALLY (bypassing {@link lockEntryFor} /
|
|
279
|
+
* {@link resolveSidecarIdentity}, the namespace mapping the item-form path uses)
|
|
280
|
+
* and targets `refs/dorfl/lock/<entry>` directly, then reuses the SAME entry-keyed
|
|
281
|
+
* leased-delete core {@link releaseItemLock} uses ({@link releaseLockEntry}) — the
|
|
282
|
+
* SAME lock-lease acquisition, push, absent-is-success no-op, exit codes, and
|
|
283
|
+
* mirror handling. There is NO second delete path.
|
|
284
|
+
*
|
|
285
|
+
* The trust model is UNCHANGED: a human still asserts the lock is dead by NAMING
|
|
286
|
+
* it; the only thing `--entry` drops is the assumption that the entry name is
|
|
287
|
+
* derivable from a current item-form. Rejects (`error`) an `<entry>` that fails
|
|
288
|
+
* {@link isValidLockEntryName} BEFORE any git operation, so a literal can never
|
|
289
|
+
* escape the `refs/dorfl/lock/` namespace.
|
|
290
|
+
*/
|
|
291
|
+
export async function releaseLiteralLockEntry(opts) {
|
|
292
|
+
const entry = opts.entry ?? '';
|
|
293
|
+
if (!isValidLockEntryName(entry)) {
|
|
294
|
+
return {
|
|
295
|
+
outcome: 'error',
|
|
296
|
+
entry,
|
|
297
|
+
ref: '',
|
|
298
|
+
message: `invalid --entry '${entry}': a lock-entry name must be a non-empty run of [A-Za-z0-9._-] (no slashes, no whitespace) so it cannot escape the ${LOCK_REF_PREFIX}/ namespace.`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return releaseLockEntry(entry, opts.cwd, opts.arbiter ?? 'origin', opts.env);
|
|
302
|
+
}
|
|
261
303
|
/**
|
|
262
304
|
* GUARDED release for a runner that KNOWS it acquired and HELD the lock (spec
|
|
263
305
|
* `ledger-status-per-item-lock-refs` US #13): unlike {@link releaseItemLock} —
|
|
@@ -376,18 +418,6 @@ async function leasedDeleteLockRef(ref, expectedSha, cwd, arbiter, env) {
|
|
|
376
418
|
await gitSoft(['update-ref', '-d', ref], cwd, env);
|
|
377
419
|
return 'deleted';
|
|
378
420
|
}
|
|
379
|
-
/**
|
|
380
|
-
* mark-stuck (transition 2): `[action, active] -> [action, stuck] + reason`. The
|
|
381
|
-
* runner bounces (red gate, agent failure, decomposition-unclear). A leased CAS
|
|
382
|
-
* amend of the SAME entry's `state` + `reason`, keeping `action`/`holder`/`since`.
|
|
383
|
-
* It is the source of the needs-attention SURFACE (now read from the lock ref,
|
|
384
|
-
* not a `work/needs-attention/` folder).
|
|
385
|
-
*
|
|
386
|
-
* PRECONDITIONS (the state machine + invariants):
|
|
387
|
-
* - the entry must be HELD and `active` (`not-held` / `wrong-state` otherwise) —
|
|
388
|
-
* stuck is reachable only FROM active, never from absent or already-stuck.
|
|
389
|
-
* - `reason` must be non-empty (the `reason` PRESENT iff `state: stuck` invariant).
|
|
390
|
-
*/
|
|
391
421
|
export async function markStuckItemLock(opts) {
|
|
392
422
|
const arbiter = opts.arbiter ?? 'origin';
|
|
393
423
|
const env = opts.env;
|
|
@@ -397,14 +427,6 @@ export async function markStuckItemLock(opts) {
|
|
|
397
427
|
}
|
|
398
428
|
const entry = lockEntryFor(opts.item);
|
|
399
429
|
const ref = itemLockRef(entry);
|
|
400
|
-
if (!opts.reason || opts.reason.trim() === '') {
|
|
401
|
-
return {
|
|
402
|
-
outcome: 'error',
|
|
403
|
-
entry,
|
|
404
|
-
ref,
|
|
405
|
-
message: 'mark-stuck requires a reason (reason iff stuck)',
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
430
|
try {
|
|
409
431
|
const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
|
|
410
432
|
if (!held) {
|
|
@@ -412,32 +434,16 @@ export async function markStuckItemLock(opts) {
|
|
|
412
434
|
outcome: 'not-held',
|
|
413
435
|
entry,
|
|
414
436
|
ref,
|
|
415
|
-
message: `'${entry}' not locked`,
|
|
437
|
+
message: `'${entry}' not locked (mark-stuck is a no-op shim post retire-stuck-lock-state)`,
|
|
416
438
|
};
|
|
417
439
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
const next = {
|
|
427
|
-
...held.lock,
|
|
428
|
-
state: 'stuck',
|
|
429
|
-
reason: opts.reason.trim(),
|
|
440
|
+
return {
|
|
441
|
+
outcome: 'transitioned',
|
|
442
|
+
entry,
|
|
443
|
+
ref,
|
|
444
|
+
message: `'${entry}' left active (mark-stuck is a no-op shim post retire-stuck-lock-state; a parked item is now a needsAnswers:true sidecar on main)`,
|
|
445
|
+
lock: held.lock,
|
|
430
446
|
};
|
|
431
|
-
const questions = (opts.questions ?? [])
|
|
432
|
-
.map((q) => q.trim())
|
|
433
|
-
.filter((q) => q !== '');
|
|
434
|
-
if (questions.length > 0) {
|
|
435
|
-
next.questions = questions;
|
|
436
|
-
}
|
|
437
|
-
else {
|
|
438
|
-
delete next.questions;
|
|
439
|
-
}
|
|
440
|
-
return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
|
|
441
447
|
}
|
|
442
448
|
catch (err) {
|
|
443
449
|
return {
|
|
@@ -449,14 +455,19 @@ export async function markStuckItemLock(opts) {
|
|
|
449
455
|
}
|
|
450
456
|
}
|
|
451
457
|
/**
|
|
452
|
-
* resume (
|
|
453
|
-
* `
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* lock
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
458
|
+
* resume (crash-orphan recovery only, post `retire-stuck-lock-state`): with the
|
|
459
|
+
* `stuck` lock state retired, the ONLY thing to "resume" is the crash-window
|
|
460
|
+
* orphan the ordered bounce transition (surface-FIRST-release-SECOND) leaves
|
|
461
|
+
* when the surface lands on `<arbiter>/main` but the release never runs. If the
|
|
462
|
+
* held lock is `active` AND the item is SURFACED on `<arbiter>/main`
|
|
463
|
+
* (`needsAnswers:true` + sidecar) AND not terminal, `main` is authoritative:
|
|
464
|
+
* clear the ref via the SHARED leased delete (never `--force`) so the lock
|
|
465
|
+
* converges rather than dangling forever. Every OTHER combination is a no-op
|
|
466
|
+
* from this verb's perspective: an in-flight active hold that is NOT surfaced
|
|
467
|
+
* is a healthy build (`wrong-state` — nothing to resume); no lock is
|
|
468
|
+
* `not-held`. There is no `stuck → active` transition anymore: a parked item
|
|
469
|
+
* is a `needsAnswers:true` pool item on `main`, drained by answering the
|
|
470
|
+
* sidecar (via the apply rung), not by "resuming" a lock.
|
|
460
471
|
*/
|
|
461
472
|
export async function resumeItemLock(opts) {
|
|
462
473
|
const arbiter = opts.arbiter ?? 'origin';
|
|
@@ -477,23 +488,35 @@ export async function resumeItemLock(opts) {
|
|
|
477
488
|
message: `'${entry}' not locked`,
|
|
478
489
|
};
|
|
479
490
|
}
|
|
480
|
-
|
|
491
|
+
const { type, slug } = resolveSidecarIdentity(opts.item);
|
|
492
|
+
// Refresh `<arbiter>/main` so the surfaced-on-main probe reads the live
|
|
493
|
+
// snapshot rather than a stale local tracking ref.
|
|
494
|
+
await gitSoft(['fetch', '--quiet', arbiter], cwd, env);
|
|
495
|
+
const terminalOnMain = await isTerminalOnMain(type, slug, arbiter, cwd, env);
|
|
496
|
+
if (!terminalOnMain &&
|
|
497
|
+
(await isItemSurfacedOnMain(type, slug, opts.item, arbiter, cwd, env))) {
|
|
498
|
+
const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
|
|
499
|
+
if (cleared === 'deleted') {
|
|
500
|
+
return {
|
|
501
|
+
outcome: 'transitioned',
|
|
502
|
+
entry,
|
|
503
|
+
ref,
|
|
504
|
+
message: `cleared the crash-window orphan lock for '${entry}' (item is SURFACED on ${arbiter}/main via needsAnswers + sidecar; the surface landed but the release never ran) — answer the question sidecar to drain it.`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
481
507
|
return {
|
|
482
|
-
outcome: '
|
|
508
|
+
outcome: 'lost',
|
|
483
509
|
entry,
|
|
484
510
|
ref,
|
|
485
|
-
message: `'${entry}'
|
|
511
|
+
message: `'${entry}' crash-orphan clear lost the CAS race (concurrent writer); back off and re-run.`,
|
|
486
512
|
};
|
|
487
513
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
514
|
+
return {
|
|
515
|
+
outcome: 'not-held',
|
|
516
|
+
entry,
|
|
517
|
+
ref,
|
|
518
|
+
message: `'${entry}' is active and not surfaced on ${arbiter}/main — nothing to resume (a healthy in-flight hold; a parked item is drained via its needsAnswers sidecar).`,
|
|
492
519
|
};
|
|
493
|
-
// reason + questions are PRESENT iff stuck: drop them on the way to active.
|
|
494
|
-
delete next.reason;
|
|
495
|
-
delete next.questions;
|
|
496
|
-
return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
|
|
497
520
|
}
|
|
498
521
|
catch (err) {
|
|
499
522
|
return {
|
|
@@ -505,15 +528,13 @@ export async function resumeItemLock(opts) {
|
|
|
505
528
|
}
|
|
506
529
|
}
|
|
507
530
|
/**
|
|
508
|
-
* requeue
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
* `active` entry — abandoning an in-flight active hold goes through `release`, not
|
|
516
|
-
* `requeue`. The removal itself is a leased delete (a concurrent change ⇒ `lost`).
|
|
531
|
+
* requeue: give up on a held lock and return the item to the pool by REMOVING
|
|
532
|
+
* the entry. Post-CONTRACT step (task `retire-stuck-lock-state`) there is NO
|
|
533
|
+
* `stuck` state to guard against — the held lock is always `active`, so
|
|
534
|
+
* requeue works on any held entry (a leased delete; a concurrent change ⇒
|
|
535
|
+
* `lost`). The body never moved (it rests in the pool on `main`), so requeue
|
|
536
|
+
* is purely "release the lock"; the kept `work/<slug>` branch remains for
|
|
537
|
+
* recovery.
|
|
517
538
|
*/
|
|
518
539
|
export async function requeueItemLock(opts) {
|
|
519
540
|
const arbiter = opts.arbiter ?? 'origin';
|
|
@@ -534,14 +555,6 @@ export async function requeueItemLock(opts) {
|
|
|
534
555
|
message: `'${entry}' not locked`,
|
|
535
556
|
};
|
|
536
557
|
}
|
|
537
|
-
if (held.lock.state !== 'stuck') {
|
|
538
|
-
return {
|
|
539
|
-
outcome: 'wrong-state',
|
|
540
|
-
entry,
|
|
541
|
-
ref,
|
|
542
|
-
message: `'${entry}' is ${held.lock.state}, not stuck; use release to abort an active hold`,
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
558
|
const del = await gitSoft([
|
|
546
559
|
'push',
|
|
547
560
|
arbiter,
|
|
@@ -635,17 +648,23 @@ export function terminalMainPaths(type, slug) {
|
|
|
635
648
|
* per {@link terminalMainPaths}) with a STILL-HELD lock — a stale lock with no
|
|
636
649
|
* in-flight work behind it. This is the recovery that converges it.
|
|
637
650
|
*
|
|
638
|
-
* THE RECOVERY RULE (the `main` record is authoritative over
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* - `main` is TERMINAL + the held lock is `
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
651
|
+
* THE RECOVERY RULE (the `main` record is authoritative over an ORPHAN lock,
|
|
652
|
+
* broadened by task `reaper-reap-terminal-stuck-lock-orphans`; ADR
|
|
653
|
+
* `ledger-status-on-per-item-lock-refs` § Addendum 2026-07-10):
|
|
654
|
+
* - `main` is TERMINAL + the held lock is `active` → STRANDED (the crash was
|
|
655
|
+
* after the durable move, before the release) → CLEAR it (`cleared-stale`).
|
|
656
|
+
* - `main` is TERMINAL + the held lock is `stuck` → CRASH-ORPHAN (`done` +
|
|
657
|
+
* `stuck` LEGITIMATELY co-existed during a rebase-conflict bounce, US #10,
|
|
658
|
+
* but the item then reached its terminal folder by ANY path — human finish,
|
|
659
|
+
* re-drive, manual fixup+merge — leaving the stuck lock as an orphan the
|
|
660
|
+
* `main` record supersedes) → CLEAR it (`cleared-stuck-terminal`).
|
|
661
|
+
* - `main` is NOT terminal + the held lock is `stuck` → the GENUINE
|
|
662
|
+
* human-attention case (`kept-stuck`); NEVER auto-cleared here (a human
|
|
663
|
+
* resolves via `resume`/`requeue`/`release-lock`). This is the invariant
|
|
664
|
+
* the contract loosening MUST preserve.
|
|
665
|
+
* - `main` is NOT terminal + the held lock is `active` → the NORMAL in-flight
|
|
647
666
|
* state (`kept-in-flight`); the lock is doing its job, leave it.
|
|
648
|
-
* - no lock at all
|
|
667
|
+
* - no lock at all → `no-lock` (at rest).
|
|
649
668
|
*
|
|
650
669
|
* Best-effort + idempotent: it NEVER throws (a fetch/read fault degrades to
|
|
651
670
|
* `error`, leaving the lock untouched — the safe direction), and re-running it
|
|
@@ -714,7 +733,48 @@ export async function reconcileItemLockAgainstMain(opts) {
|
|
|
714
733
|
};
|
|
715
734
|
}
|
|
716
735
|
if (!terminalOnMain) {
|
|
717
|
-
//
|
|
736
|
+
// Post-`retire-stuck-lock-state`: the held lock is ALWAYS `active`. The
|
|
737
|
+
// split is now solely on whether the item is SURFACED on `<arbiter>/main`
|
|
738
|
+
// (`needsAnswers:true` + sidecar):
|
|
739
|
+
// - surfaced ⇒ the CRASH-WINDOW ORPHAN the ordered bounce transition
|
|
740
|
+
// (surface-FIRST-release-SECOND) leaves when step 1 lands but step 2
|
|
741
|
+
// never runs. `main` is authoritative — CLEAR via the SHARED leased
|
|
742
|
+
// delete (`cleared-stale`).
|
|
743
|
+
// - not surfaced ⇒ the NORMAL in-flight hold (`kept-in-flight`).
|
|
744
|
+
const surfaced = await isItemSurfacedOnMain(type, slug, opts.item, arbiter, cwd, env);
|
|
745
|
+
if (surfaced) {
|
|
746
|
+
const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
|
|
747
|
+
if (cleared === 'deleted') {
|
|
748
|
+
return {
|
|
749
|
+
outcome: 'cleared-stale',
|
|
750
|
+
entry,
|
|
751
|
+
ref,
|
|
752
|
+
terminalOnMain,
|
|
753
|
+
message: `cleared the crash-window orphan lock for '${entry}' (item is SURFACED on ${arbiter}/main via needsAnswers:true + sidecar; the surface landed but the release never ran)`,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
// Leased delete rejected: fall through to the shared rejection
|
|
757
|
+
// arm below (same distinguish-then-report shape).
|
|
758
|
+
const remote = await gitSoft(['ls-remote', arbiter, ref], cwd, env);
|
|
759
|
+
const remoteEmpty = remote.status === 0 && remote.stdout.trim() === '';
|
|
760
|
+
if (remoteEmpty) {
|
|
761
|
+
await gitSoft(['update-ref', '-d', ref], cwd, env);
|
|
762
|
+
return {
|
|
763
|
+
outcome: 'no-lock',
|
|
764
|
+
entry,
|
|
765
|
+
ref,
|
|
766
|
+
terminalOnMain,
|
|
767
|
+
message: `'${entry}' has no lock to reconcile (already cleared by another reaper / release-lock / requeue)`,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
return {
|
|
771
|
+
outcome: 'error',
|
|
772
|
+
entry,
|
|
773
|
+
ref,
|
|
774
|
+
terminalOnMain,
|
|
775
|
+
message: `crash-orphan clear for '${entry}' rejected (changed concurrently to a different value); a racer may have moved the ref. Re-run after re-checking.`,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
718
778
|
return {
|
|
719
779
|
outcome: 'kept-in-flight',
|
|
720
780
|
entry,
|
|
@@ -723,21 +783,8 @@ export async function reconcileItemLockAgainstMain(opts) {
|
|
|
723
783
|
message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
|
|
724
784
|
};
|
|
725
785
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
// NOT corruption. Keep the stuck lock (it wins the human's attention).
|
|
729
|
-
return {
|
|
730
|
-
outcome: 'kept-stuck',
|
|
731
|
-
entry,
|
|
732
|
-
ref,
|
|
733
|
-
terminalOnMain,
|
|
734
|
-
message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
// Terminal on `main` + an `active` lock = a STALE lock (the crash was after
|
|
738
|
-
// the durable move, before the release). The `main` record is authoritative:
|
|
739
|
-
// clear the stale lock with the SHARED leased delete (the SAME one
|
|
740
|
-
// `release-lock` / requeue / the reaper use).
|
|
786
|
+
// Terminal on `main` + a held (active) lock = an ORPHAN over a
|
|
787
|
+
// durably-completed item. Clear via the SHARED leased delete.
|
|
741
788
|
const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
|
|
742
789
|
if (cleared === 'deleted') {
|
|
743
790
|
return {
|
|
@@ -842,6 +889,20 @@ export async function classifyItemLockAgainstMain(opts) {
|
|
|
842
889
|
};
|
|
843
890
|
}
|
|
844
891
|
if (!terminalOnMain) {
|
|
892
|
+
// Post-`retire-stuck-lock-state`: the held lock is ALWAYS `active`. If the
|
|
893
|
+
// item is SURFACED on `<arbiter>/main` (`needsAnswers:true` + sidecar),
|
|
894
|
+
// the lock is a CRASH-WINDOW ORPHAN (reconcilable, NOT auto-cleared by
|
|
895
|
+
// the report); otherwise it is the normal in-flight hold.
|
|
896
|
+
const surfaced = await isItemSurfacedOnMain(type, slug, opts.item, arbiter, cwd, env);
|
|
897
|
+
if (surfaced) {
|
|
898
|
+
return {
|
|
899
|
+
outcome: 'cleared-stale',
|
|
900
|
+
entry,
|
|
901
|
+
ref,
|
|
902
|
+
terminalOnMain,
|
|
903
|
+
message: `'${entry}' is a CRASH-WINDOW ORPHAN — SURFACED on ${arbiter}/main (needsAnswers:true + sidecar) but the release never ran; reconcilable, auto-reapable by 'gc --ledger --reap-stale-locks' (NOT auto-cleared by the report)`,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
845
906
|
return {
|
|
846
907
|
outcome: 'kept-in-flight',
|
|
847
908
|
entry,
|
|
@@ -850,24 +911,16 @@ export async function classifyItemLockAgainstMain(opts) {
|
|
|
850
911
|
message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
|
|
851
912
|
};
|
|
852
913
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
ref,
|
|
858
|
-
terminalOnMain,
|
|
859
|
-
message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
|
|
860
|
-
};
|
|
861
|
-
}
|
|
862
|
-
// Terminal on `main` + an `active` lock = a STALE lock. Unlike
|
|
863
|
-
// `reconcileItemLockAgainstMain` we do NOT clear it here — the report only
|
|
864
|
-
// names it as reconcilable; the human asserts the clear (no auto-sweep).
|
|
914
|
+
// Terminal on `main` + a held (active) lock = an ORPHAN over a
|
|
915
|
+
// durably-completed item. Unlike `reconcileItemLockAgainstMain` we do NOT
|
|
916
|
+
// clear it here — the report only names it as reconcilable; the reaper (or
|
|
917
|
+
// a human) asserts the clear (no auto-sweep from the report path).
|
|
865
918
|
return {
|
|
866
919
|
outcome: 'cleared-stale',
|
|
867
920
|
entry,
|
|
868
921
|
ref,
|
|
869
922
|
terminalOnMain,
|
|
870
|
-
message: `'${entry}' is terminal on ${arbiter}/main
|
|
923
|
+
message: `'${entry}' is terminal on ${arbiter}/main — reconcilable (stale); auto-reapable by 'gc --ledger --reap-stale-locks' (NOT auto-cleared by the report)`,
|
|
871
924
|
};
|
|
872
925
|
}
|
|
873
926
|
catch (err) {
|
|
@@ -880,6 +933,12 @@ export async function classifyItemLockAgainstMain(opts) {
|
|
|
880
933
|
};
|
|
881
934
|
}
|
|
882
935
|
}
|
|
936
|
+
/** True iff a {@link ReconcileOutcome} names an ORPHAN class the
|
|
937
|
+
* `--reap-stale-locks` sweep can auto-clear via the shared leased delete.
|
|
938
|
+
* Post-`retire-stuck-lock-state` this is just `cleared-stale`. */
|
|
939
|
+
export function isReapableTerminalOrphan(outcome) {
|
|
940
|
+
return outcome === 'cleared-stale';
|
|
941
|
+
}
|
|
883
942
|
/**
|
|
884
943
|
* Build the `gc --ledger` stuck/orphaned-lock REPORT (spec
|
|
885
944
|
* `ledger-status-per-item-lock-refs` US #12/#13/#14; ADR
|
|
@@ -940,7 +999,7 @@ export async function reportItemLocks(cwd, arbiter = 'origin', env) {
|
|
|
940
999
|
* every held lock, in-flight ones informationally).
|
|
941
1000
|
*/
|
|
942
1001
|
export function itemLockReportNeedsAttention(report) {
|
|
943
|
-
return report.locks.some((l) => l.reconcile
|
|
1002
|
+
return report.locks.some((l) => isReapableTerminalOrphan(l.reconcile));
|
|
944
1003
|
}
|
|
945
1004
|
export function formatItemLockReport(report) {
|
|
946
1005
|
if (report.locks.length === 0) {
|
|
@@ -952,14 +1011,21 @@ export function formatItemLockReport(report) {
|
|
|
952
1011
|
'a human asserts a stuck/stale lock is dead via `release-lock`):',
|
|
953
1012
|
];
|
|
954
1013
|
for (const { lock, reconcile } of report.locks) {
|
|
955
|
-
const item = itemFromLockEntry(lock.entry);
|
|
956
1014
|
lines.push(` ${lock.entry} [${lock.action}/${lock.state}]`);
|
|
957
1015
|
lines.push(` holder: ${lock.holder || '(unknown)'} since: ${lock.since || '(unknown)'}`);
|
|
958
|
-
if (lock.state === 'stuck' && lock.reason) {
|
|
959
|
-
lines.push(` reason: ${lock.reason}`);
|
|
960
|
-
}
|
|
961
1016
|
lines.push(` ${reconcileNote(reconcile)}`);
|
|
962
|
-
|
|
1017
|
+
// The copy-pasteable clear hint. An entry that reverse-derives to a CURRENT
|
|
1018
|
+
// item-form (`task-`/`spec-`/`observation-`) points at `release-lock <item>`.
|
|
1019
|
+
// A PRE-CUTOVER entry (`slice-<slug>` / `prd-<slug>`) has NO current item-form,
|
|
1020
|
+
// so it is UN-NAMEABLE that way — print ONLY the literal entry name and hint at
|
|
1021
|
+
// the `release-lock --entry <literal>` escape hatch (task
|
|
1022
|
+
// `release-lock-entry-escape-hatch-and-literal-entry-reporting`).
|
|
1023
|
+
if (hasCurrentItemForm(lock.entry)) {
|
|
1024
|
+
lines.push(` resolve (if the lock is dead): \`dorfl release-lock ${itemFromLockEntry(lock.entry)}\` (never --force).`);
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
lines.push(` # no current item-form; clear with: dorfl release-lock --entry ${lock.entry}`);
|
|
1028
|
+
}
|
|
963
1029
|
}
|
|
964
1030
|
return lines;
|
|
965
1031
|
}
|
|
@@ -968,10 +1034,8 @@ function reconcileNote(reconcile) {
|
|
|
968
1034
|
switch (reconcile) {
|
|
969
1035
|
case 'kept-in-flight':
|
|
970
1036
|
return 'in flight (held, not terminal on main) — normal; left untouched.';
|
|
971
|
-
case 'kept-stuck':
|
|
972
|
-
return 'terminal on main + STUCK (done+stuck co-exist) — kept for human attention.';
|
|
973
1037
|
case 'cleared-stale':
|
|
974
|
-
return '
|
|
1038
|
+
return 'STALE / crash-window orphan (reconcilable) — auto-reapable by --reap-stale-locks (NOT auto-cleared by the report).';
|
|
975
1039
|
case 'no-lock':
|
|
976
1040
|
return 'no lock (already at rest).';
|
|
977
1041
|
case 'error':
|
|
@@ -989,13 +1053,19 @@ function reconcileNote(reconcile) {
|
|
|
989
1053
|
* {@link reconcileItemLockAgainstMain} (the recovery's clear, re-checked fresh per
|
|
990
1054
|
* item) — there is NO parallel clear mechanism.
|
|
991
1055
|
*
|
|
992
|
-
* SCOPE FENCE (the trust model the default preserves
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
1056
|
+
* SCOPE FENCE (the trust model the default preserves; broadened by task
|
|
1057
|
+
* `reaper-reap-terminal-stuck-lock-orphans`; ADR
|
|
1058
|
+
* `ledger-status-on-per-item-lock-refs` § Addendum 2026-07-10):
|
|
1059
|
+
* - it clears the TWO terminal-on-`main` ORPHAN classes ONLY: `cleared-stale`
|
|
1060
|
+
* (terminal + `active` = stranded between move and release) AND
|
|
1061
|
+
* `cleared-stuck-terminal` (terminal + `stuck` = crash-orphan the auto-reaper
|
|
1062
|
+
* used to leave forever). A `kept-stuck` (STUCK + NON-terminal — the
|
|
1063
|
+
* genuine human-attention case) and a `kept-in-flight` (`active` +
|
|
1064
|
+
* non-terminal — a healthy build) are NEVER reaped, even here. Because each
|
|
1065
|
+
* clear goes through {@link reconcileItemLockAgainstMain}, which RE-reads +
|
|
1066
|
+
* RE-classifies before deleting, a lock whose item was un-completed on
|
|
1067
|
+
* `main` between the report and the sweep is still safe (reconcile returns
|
|
1068
|
+
* `kept-*`, not a delete).
|
|
999
1069
|
* - the clear is a LEASED delete: a concurrent change to the ref makes it REJECT
|
|
1000
1070
|
* (`lost`), reported — never a blind `--force`.
|
|
1001
1071
|
*
|
|
@@ -1012,10 +1082,11 @@ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
|
|
|
1012
1082
|
let lost = 0;
|
|
1013
1083
|
for (const { lock, ref, reconcile } of report.locks) {
|
|
1014
1084
|
const item = itemFromLockEntry(lock.entry);
|
|
1015
|
-
if (reconcile
|
|
1085
|
+
if (isReapableTerminalOrphan(reconcile)) {
|
|
1016
1086
|
// Re-check + clear through the recovery's SHARED leased delete. Reconcile
|
|
1017
|
-
// re-reads the live ref, so a lock that turned
|
|
1018
|
-
// report is left alone; a concurrent change to the ref makes the lease
|
|
1087
|
+
// re-reads the live ref, so a lock that turned in-flight since the
|
|
1088
|
+
// report is left alone; a concurrent change to the ref makes the lease
|
|
1089
|
+
// lose.
|
|
1019
1090
|
const rec = await reconcileItemLockAgainstMain({
|
|
1020
1091
|
item,
|
|
1021
1092
|
cwd,
|
|
@@ -1027,13 +1098,6 @@ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
|
|
|
1027
1098
|
entries.push({ lock, ref, outcome: 'reaped', message: rec.message });
|
|
1028
1099
|
}
|
|
1029
1100
|
else if (rec.outcome === 'no-lock') {
|
|
1030
|
-
// BENIGN: the ref is already gone — the desired end state. The LOSER of
|
|
1031
|
-
// a concurrent double-reap (another reaper deleted the ref between our
|
|
1032
|
-
// report and our re-read), or a `release-lock`/`requeue` that cleared
|
|
1033
|
-
// the same stale lock in the meantime. NOT a lost lease (the lease was
|
|
1034
|
-
// not REJECTED; there was simply nothing left to delete), so this does
|
|
1035
|
-
// NOT count as needs-attention. Kept SEPARATE from `reaped` so the
|
|
1036
|
-
// summary does not lie about who did the deleting.
|
|
1037
1101
|
alreadyReaped++;
|
|
1038
1102
|
entries.push({
|
|
1039
1103
|
lock,
|
|
@@ -1042,24 +1106,17 @@ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
|
|
|
1042
1106
|
message: rec.message,
|
|
1043
1107
|
});
|
|
1044
1108
|
}
|
|
1045
|
-
else if (rec.outcome === 'kept-
|
|
1046
|
-
rec.outcome === 'kept-in-flight') {
|
|
1047
|
-
// The lock changed between the report and the sweep — no longer stale.
|
|
1109
|
+
else if (rec.outcome === 'kept-in-flight') {
|
|
1048
1110
|
kept++;
|
|
1049
1111
|
entries.push({ lock, ref, outcome: rec.outcome, message: rec.message });
|
|
1050
1112
|
}
|
|
1051
1113
|
else {
|
|
1052
|
-
// A lost lease (the ref was REJECTED because it changed concurrently to
|
|
1053
|
-
// a DIFFERENT value) or a per-item error — REPORTED, never forced.
|
|
1054
|
-
// Counts as needing attention after the sweep.
|
|
1055
1114
|
lost++;
|
|
1056
1115
|
entries.push({ lock, ref, outcome: 'lost', message: rec.message });
|
|
1057
1116
|
}
|
|
1058
1117
|
continue;
|
|
1059
1118
|
}
|
|
1060
|
-
|
|
1061
|
-
// NEVER touch, even with the flag.
|
|
1062
|
-
if (reconcile === 'kept-stuck' || reconcile === 'kept-in-flight') {
|
|
1119
|
+
if (reconcile === 'kept-in-flight') {
|
|
1063
1120
|
kept++;
|
|
1064
1121
|
entries.push({
|
|
1065
1122
|
lock,
|
|
@@ -1091,15 +1148,11 @@ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
|
|
|
1091
1148
|
* {@link itemLockReportNeedsAttention}.
|
|
1092
1149
|
*/
|
|
1093
1150
|
export function reapReportNeedsAttention(report) {
|
|
1094
|
-
// EXIT-CODE CONTRACT
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1099
|
-
// NOT in this set.
|
|
1100
|
-
return report.entries.some((e) => e.outcome === 'kept-stuck' ||
|
|
1101
|
-
e.outcome === 'lost' ||
|
|
1102
|
-
e.outcome === 'error');
|
|
1151
|
+
// EXIT-CODE CONTRACT: the reaper exits 0 when all stale locks are reaped and
|
|
1152
|
+
// only healthy in-flight locks remain; exits 1 when a delete genuinely lost
|
|
1153
|
+
// the race / errored. An `already-reaped` is BENIGN. Post-`retire-stuck-
|
|
1154
|
+
// lock-state` there is no `kept-stuck` outcome to trip this.
|
|
1155
|
+
return report.entries.some((e) => e.outcome === 'lost' || e.outcome === 'error');
|
|
1103
1156
|
}
|
|
1104
1157
|
/**
|
|
1105
1158
|
* Format the `gc --ledger --reap-stale-locks` sweep for the terminal: a header
|
|
@@ -1113,7 +1166,7 @@ export function formatReapReport(report) {
|
|
|
1113
1166
|
}
|
|
1114
1167
|
const lines = [
|
|
1115
1168
|
`Per-item lock sweep (--reap-stale-locks): reaped ${report.reaped} stale ` +
|
|
1116
|
-
`
|
|
1169
|
+
`lock(s), kept ${report.kept} (in-flight, never reaped)` +
|
|
1117
1170
|
(report.alreadyReaped > 0
|
|
1118
1171
|
? `, ${report.alreadyReaped} already reaped by another sweep (no-lock — benign, the desired end state)`
|
|
1119
1172
|
: '') +
|
|
@@ -1124,14 +1177,14 @@ export function formatReapReport(report) {
|
|
|
1124
1177
|
];
|
|
1125
1178
|
for (const { lock, outcome, message } of report.entries) {
|
|
1126
1179
|
const tag = outcome === 'reaped'
|
|
1127
|
-
? '[reaped]
|
|
1180
|
+
? '[reaped] '
|
|
1128
1181
|
: outcome === 'already-reaped'
|
|
1129
|
-
? '[already]
|
|
1182
|
+
? '[already] '
|
|
1130
1183
|
: outcome === 'lost'
|
|
1131
|
-
? '[lost]
|
|
1184
|
+
? '[lost] '
|
|
1132
1185
|
: outcome === 'error'
|
|
1133
|
-
? '[error]
|
|
1134
|
-
: '[kept]
|
|
1186
|
+
? '[error] '
|
|
1187
|
+
: '[kept] ';
|
|
1135
1188
|
lines.push(` ${tag} ${lock.entry} [${lock.action}/${lock.state}] ${message}`);
|
|
1136
1189
|
}
|
|
1137
1190
|
return lines;
|
|
@@ -1146,10 +1199,10 @@ export function formatReapReport(report) {
|
|
|
1146
1199
|
*/
|
|
1147
1200
|
export function itemFromLockEntry(entry) {
|
|
1148
1201
|
// The lock-entry prefixes: `spec` is the parent-spec type token (the legacy
|
|
1149
|
-
//
|
|
1202
|
+
// ''prd'' token is GONE after the hard cutover), so a `spec-<slug>` lock entry
|
|
1150
1203
|
// round-trips back to its namespaced `spec:<slug>` form (the inverse of
|
|
1151
1204
|
// `lockEntryFor('spec:<slug>')`).
|
|
1152
|
-
for (const prefix of
|
|
1205
|
+
for (const prefix of CURRENT_ITEM_FORM_PREFIXES) {
|
|
1153
1206
|
const tag = `${prefix}-`;
|
|
1154
1207
|
if (entry.startsWith(tag)) {
|
|
1155
1208
|
return `${prefix}:${entry.slice(tag.length)}`;
|
|
@@ -1157,6 +1210,73 @@ export function itemFromLockEntry(entry) {
|
|
|
1157
1210
|
}
|
|
1158
1211
|
return entry;
|
|
1159
1212
|
}
|
|
1213
|
+
/** The CURRENT lock-entry type prefixes an `<entry>` can reverse-derive to an
|
|
1214
|
+
* item-form (`<type>:<slug>`). After the slice→task / `prd-to-spec` vocabulary
|
|
1215
|
+
* cutover these are `task`/`spec`/`observation` ONLY — a pre-cutover `slice-`/
|
|
1216
|
+
* `prd-` prefix is NOT here, so its entry has NO current item-form. */
|
|
1217
|
+
const CURRENT_ITEM_FORM_PREFIXES = ['task', 'spec', 'observation'];
|
|
1218
|
+
/**
|
|
1219
|
+
* Does `<entry>` reverse-derive to a CURRENT item-form (`<type>:<slug>`), i.e.
|
|
1220
|
+
* does it carry a known post-cutover type prefix (`task-`/`spec-`/`observation-`)?
|
|
1221
|
+
* FALSE for a pre-cutover `slice-<slug>` / `prd-<slug>` entry (task
|
|
1222
|
+
* `release-lock-entry-escape-hatch-and-literal-entry-reporting`): such an entry is
|
|
1223
|
+
* UN-NAMEABLE through the item-form `release-lock <item>` path and must be cleared
|
|
1224
|
+
* via the `release-lock --entry <literal>` escape hatch instead. This is the
|
|
1225
|
+
* predicate the `gc --ledger` report keys off to decide whether to print the
|
|
1226
|
+
* copy-pasteable item-form hint or the literal-`--entry` hint.
|
|
1227
|
+
*/
|
|
1228
|
+
export function hasCurrentItemForm(entry) {
|
|
1229
|
+
return CURRENT_ITEM_FORM_PREFIXES.some((p) => entry.startsWith(`${p}-`));
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* True iff `<arbiter>/main` shows the item SURFACED as a needs-attention
|
|
1233
|
+
* question (PR-2a classifier fold, task
|
|
1234
|
+
* `bounce-atomic-cutover-retire-stuck-lock`, spec
|
|
1235
|
+
* `surface-stuck-as-questions-and-retire-stuck-lock-state`): the item body
|
|
1236
|
+
* carries `needsAnswers: true` in its frontmatter AND a matching sidecar
|
|
1237
|
+
* (`work/questions/<type>-<slug>.md`) exists on `<arbiter>/main`. This is the
|
|
1238
|
+
* on-`main`-authoritative signature of the ordered bounce transition
|
|
1239
|
+
* (surface-to-main FIRST, release SECOND) that a crash BETWEEN steps 1 and 2
|
|
1240
|
+
* leaves behind: the surface commit landed but the lock ref release never ran.
|
|
1241
|
+
*
|
|
1242
|
+
* Best-effort + degrades safely (`false` on any read fault or absent body):
|
|
1243
|
+
* the recovery direction is to DECIDE the lock is NOT surfaced (fall through
|
|
1244
|
+
* to `kept-in-flight`) when we cannot prove the surface — never a
|
|
1245
|
+
* false-positive clear.
|
|
1246
|
+
*/
|
|
1247
|
+
async function isItemSurfacedOnMain(type, slug, item, arbiter, cwd, env) {
|
|
1248
|
+
const sidecar = sidecarPathFor(item);
|
|
1249
|
+
const sidecarExists = (await gitSoft(['cat-file', '-e', `${arbiter}/main:${sidecar}`], cwd, env))
|
|
1250
|
+
.status === 0;
|
|
1251
|
+
if (!sidecarExists) {
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
// Probe the two bounce body-folder candidates (D1 order) for a body carrying
|
|
1255
|
+
// `needsAnswers: true`. Kept in sync with `resolveBounceItemBodyPathOnMain`
|
|
1256
|
+
// in `needs-attention.ts`; duplicated here (a small closed list) to avoid a
|
|
1257
|
+
// cyclic import between `item-lock.ts` and `needs-attention.ts`.
|
|
1258
|
+
const candidates = type === 'task'
|
|
1259
|
+
? [
|
|
1260
|
+
workItemRel('tasks-ready', `${slug}.md`),
|
|
1261
|
+
workItemRel('tasks-backlog', `${slug}.md`),
|
|
1262
|
+
]
|
|
1263
|
+
: type === 'spec'
|
|
1264
|
+
? [
|
|
1265
|
+
workItemRel('specs-ready', `${slug}.md`),
|
|
1266
|
+
workItemRel('specs-proposed', `${slug}.md`),
|
|
1267
|
+
]
|
|
1268
|
+
: [workItemRel('observations', `${slug}.md`)];
|
|
1269
|
+
for (const path of candidates) {
|
|
1270
|
+
const show = await gitSoft(['show', `${arbiter}/main:${path}`], cwd, env);
|
|
1271
|
+
if (show.status !== 0) {
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
if (parseFrontmatter(show.stdout).needsAnswers === true) {
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1160
1280
|
/** True iff `<arbiter>/main` shows the item TERMINAL — any of
|
|
1161
1281
|
* {@link terminalMainPaths} present in `<arbiter>/main`'s tree. */
|
|
1162
1282
|
async function isTerminalOnMain(type, slug, arbiter, cwd, env) {
|
|
@@ -1233,7 +1353,7 @@ export async function listItemLockEntries(cwd, arbiter = 'origin', env) {
|
|
|
1233
1353
|
* List the TASK slugs currently lock-held on the arbiter — the held-slug set the
|
|
1234
1354
|
* `ready/` pool readers SUBTRACT (spec `ledger-status-per-item-lock-refs` US #15;
|
|
1235
1355
|
* task `claim-acquires-unified-lock-no-body-move`). Enumerates {@link listItemLocks}
|
|
1236
|
-
* and keeps only the `task-<slug>` entries (a
|
|
1356
|
+
* and keeps only the `task-<slug>` entries (a spec/observation lock does not gate
|
|
1237
1357
|
* the TASK pool), mapping each to its bare `<slug>`.
|
|
1238
1358
|
*
|
|
1239
1359
|
* LOAD-BEARING since the lock cut-over: the claim NO LONGER moves the body to
|
|
@@ -1283,11 +1403,13 @@ export async function heldTaskSlugsStrict(cwd, arbiter = 'origin', env) {
|
|
|
1283
1403
|
}
|
|
1284
1404
|
/**
|
|
1285
1405
|
* Parse a serialised lock entry body back into a {@link LockEntry} — the exact
|
|
1286
|
-
* inverse of {@link serialiseLockEntry}.
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1406
|
+
* inverse of {@link serialiseLockEntry}. Post-`retire-stuck-lock-state` a lock
|
|
1407
|
+
* entry whose serialised state is not the single admitted `'active'` value
|
|
1408
|
+
* (e.g. a legacy `state: stuck` blob written by an older binary before the
|
|
1409
|
+
* one-shot migration ran) is COERCED to `'active'` on read — the state is
|
|
1410
|
+
* degenerate, and a lingering stuck ref is left for the migration/recovery
|
|
1411
|
+
* verbs to clear via `main`-authoritative reconciliation rather than surfaced
|
|
1412
|
+
* as a second live state.
|
|
1291
1413
|
*/
|
|
1292
1414
|
export function parseLockEntry(body) {
|
|
1293
1415
|
const normalized = body.replace(/\r\n/g, '\n');
|
|
@@ -1305,60 +1427,13 @@ export function parseLockEntry(body) {
|
|
|
1305
1427
|
if (!fields.entry || !fields.action || !fields.state) {
|
|
1306
1428
|
return undefined;
|
|
1307
1429
|
}
|
|
1308
|
-
|
|
1309
|
-
const reason = extractBodyReason(bodyText) ?? fields.reason;
|
|
1310
|
-
const questions = extractBodyQuestions(bodyText);
|
|
1311
|
-
const entry = {
|
|
1430
|
+
return {
|
|
1312
1431
|
entry: fields.entry,
|
|
1313
1432
|
action: fields.action,
|
|
1314
|
-
state:
|
|
1433
|
+
state: 'active',
|
|
1315
1434
|
holder: fields.holder ?? '',
|
|
1316
1435
|
since: fields.since ?? '',
|
|
1317
1436
|
};
|
|
1318
|
-
if (reason !== undefined && reason !== '') {
|
|
1319
|
-
entry.reason = reason;
|
|
1320
|
-
}
|
|
1321
|
-
if (questions.length > 0) {
|
|
1322
|
-
entry.questions = questions;
|
|
1323
|
-
}
|
|
1324
|
-
return entry;
|
|
1325
|
-
}
|
|
1326
|
-
/** Extract the `## Reason` block's prose (joined multi-line), or undefined. */
|
|
1327
|
-
function extractBodyReason(bodyText) {
|
|
1328
|
-
const lines = bodyText.split('\n');
|
|
1329
|
-
const start = lines.findIndex((l) => l.trim() === LOCK_REASON_HEADING);
|
|
1330
|
-
if (start === -1) {
|
|
1331
|
-
return undefined;
|
|
1332
|
-
}
|
|
1333
|
-
const collected = [];
|
|
1334
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
1335
|
-
if (/^##\s/.test(lines[i])) {
|
|
1336
|
-
break;
|
|
1337
|
-
}
|
|
1338
|
-
collected.push(lines[i]);
|
|
1339
|
-
}
|
|
1340
|
-
// Trim leading/trailing blank lines but PRESERVE interior newlines (rich prose).
|
|
1341
|
-
const text = collected.join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
|
1342
|
-
return text === '' ? undefined : text;
|
|
1343
|
-
}
|
|
1344
|
-
/** Extract the `## Questions` block's bulleted list, or [] when absent. */
|
|
1345
|
-
function extractBodyQuestions(bodyText) {
|
|
1346
|
-
const lines = bodyText.split('\n');
|
|
1347
|
-
const start = lines.findIndex((l) => l.trim() === LOCK_QUESTIONS_HEADING);
|
|
1348
|
-
if (start === -1) {
|
|
1349
|
-
return [];
|
|
1350
|
-
}
|
|
1351
|
-
const questions = [];
|
|
1352
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
1353
|
-
if (/^##\s/.test(lines[i])) {
|
|
1354
|
-
break;
|
|
1355
|
-
}
|
|
1356
|
-
const m = /^-\s+(.*)$/.exec(lines[i].trim());
|
|
1357
|
-
if (m) {
|
|
1358
|
-
questions.push(m[1]);
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
return questions;
|
|
1362
1437
|
}
|
|
1363
1438
|
/**
|
|
1364
1439
|
* Advisory holder id: git user.name, else $USER, else a uuid fragment. Exported
|