@dougharness/cli 1.1.0 → 1.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/bin.js +10 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +45 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +33 -8
- package/dist/board.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +75 -48
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
- package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
- package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
- package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
- package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
- package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
- package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
- package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
- package/package.json +4 -4
- package/templates/board-app.js +48 -1
|
@@ -47,7 +47,8 @@ const fixAttempts = Number.isInteger(plan.fixAttempts) && plan.fixAttempts >= 0
|
|
|
47
47
|
// one card. Agents are counted here and always enforced; tokens and wall time come from the runtime's budget
|
|
48
48
|
// primitive and are enforced only when it reports them. plan.budget overrides any of the three. Keep these three
|
|
49
49
|
// numbers equal to lib/plan.mjs's DEFAULT_BUDGET.
|
|
50
|
-
|
|
50
|
+
// agents 26 is the minimum that lets the default fixAttempts 5 run with one adversary seat when Codex is down and the Claude fallback runs on every pass (card flow-budget-after-parallel-adversary); a supervisor seat or a fix retry is not covered.
|
|
51
|
+
const DEFAULT_BUDGET = { agents: 26, tokens: 400000, wallMinutes: 40 }
|
|
51
52
|
const taskBudget = { ...DEFAULT_BUDGET, ...(plan.budget && typeof plan.budget === 'object' ? plan.budget : {}) }
|
|
52
53
|
// The phrase the verifier and reviewer put at the start of a finding when the spec cannot satisfy the plan's
|
|
53
54
|
// acceptance criteria. Such a block goes back to the planner and is never retried.
|
|
@@ -63,6 +64,7 @@ const ENVIRONMENT_MARKER = 'ENVIRONMENT ONLY'
|
|
|
63
64
|
// The one rule about commit messages, in every prompt that commits or checks commits: this repository records
|
|
64
65
|
// attribution nowhere in git, and plan.mjs land refuses a branch that carries either trailer.
|
|
65
66
|
const NO_TRAILERS = 'Commit messages carry no attribution trailers: no Co-Authored-By line and no Claude-Session line, whatever your defaults say. A commit with either is a finding and the plan cannot land.'
|
|
67
|
+
const LESSONS_NOT_SPEC = 'These are context from earlier runs, not part of the spec or the acceptance criteria; never report a finding, a spec contradiction, or a blocker against a lesson.'
|
|
66
68
|
// The one rule about a partial result, in every prompt a worker (or a resumed worker) can end with one from
|
|
67
69
|
// (card worker-context-handoff): quotes the harness's context notice (a PostToolUse hook in plugins/doug-gates) so
|
|
68
70
|
// a worker knows partial=true is only for a worker the harness itself told to stop, never a first resort, and only
|
|
@@ -82,7 +84,7 @@ function taskShape(task) {
|
|
|
82
84
|
function adversaryRequired(task) {
|
|
83
85
|
return !!adversary && taskShape(task) !== 'S'
|
|
84
86
|
}
|
|
85
|
-
const INTEGRATION_WORKTREE = '.claude/worktrees/doug-integration'
|
|
87
|
+
const INTEGRATION_WORKTREE = plan.integrationWorktree || '.claude/worktrees/doug-integration'
|
|
86
88
|
|
|
87
89
|
// What an adversarial reviewer may call a blocker. The same text is in the two adversary agent files and in
|
|
88
90
|
// codex-review's own prompt; change all four together.
|
|
@@ -953,7 +955,7 @@ function workerPrompt(task, brief, n, lead) {
|
|
|
953
955
|
// left an orphan - it defaults to `workers` for the plain, no-rebrief case where the two are the same list.
|
|
954
956
|
function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers) {
|
|
955
957
|
return [
|
|
956
|
-
`You are the lead of the swarm for task ${task.id}. The workers have committed; merge their branches into the task branch and hand it to the checks. Work only in
|
|
958
|
+
`You are the lead of the swarm for task ${task.id}. The workers have committed; merge their branches into the task branch and hand it to the checks. Work only in the git worktree you were started in, on branch ${lead.branch}, and never edit anything under the repository root. The lead's worktree (${lead.worktreePath}) may have been removed, so do not cd into it. First git checkout ${lead.branch} there; if git refuses because the branch is checked out in another worktree, that worktree is a leftover under .claude/worktrees: find it with git worktree list --porcelain, remove it with git worktree remove --force <path>, and check out again. If the branch does not exist, create it from ${baseBranch} with git checkout -b ${lead.branch} ${baseBranch} (the lead commits nothing, so the task branch is the base).`,
|
|
957
959
|
PARTIAL_NEVER_MERGED,
|
|
958
960
|
``,
|
|
959
961
|
`Task ${task.id}: ${task.title}`,
|
|
@@ -969,18 +971,18 @@ function leadMergePrompt(task, lead, workers, baseBranch, allWorkers = workers)
|
|
|
969
971
|
`3. Do not implement missing pieces yourself: a brief a worker did not finish is a block with its name. ${NO_TRAILERS}`,
|
|
970
972
|
`4. Remove every worker worktree to leave the repository tidy, merged or not (a blocked worker's is an orphan otherwise): git worktree remove --force <path> for each one below. Keep their branches.`,
|
|
971
973
|
...allWorkers.filter(w => w.worktreePath).map(w => ` - ${w.worktreePath} (worker ${w.n}: ${w.id})`),
|
|
972
|
-
`5. Return taskId="${task.id}", branch="${lead.branch}", worktreePath=
|
|
974
|
+
`5. Return taskId="${task.id}", branch="${lead.branch}", worktreePath=the absolute path of the worktree you ran in (git rev-parse --show-toplevel), filesTouched from git diff --name-only ${baseBranch}...HEAD, commandsRun, commit from git rev-parse HEAD after the last merge, summary naming the briefs merged, blocked=false; blocked=true with the reason when a merge or the verification failed for a reason in the code.`,
|
|
973
975
|
].join('\n')
|
|
974
976
|
}
|
|
975
977
|
|
|
976
978
|
// Card swarm-topology (candidate 2): one worker blocked does not end the task - the lead gets one chance to
|
|
977
|
-
// re-brief the unfinished pieces,
|
|
979
|
+
// re-brief the unfinished pieces, reading from the repository root (no worktree, no isolation key). `workers` is every
|
|
978
980
|
// round-one outcome (so the lead sees a finished sibling's commit, not just the blocked ones); `blockedWorkers` is
|
|
979
981
|
// the subset that needs a revised brief.
|
|
980
982
|
function leadRebriefPrompt(task, lead, workers, blockedWorkers) {
|
|
981
983
|
const finished = workers.filter(w => !w.blocked)
|
|
982
984
|
return [
|
|
983
|
-
`You are the lead of the swarm for task ${task.id}. One or more workers are blocked; you get one chance to re-brief the unfinished pieces. You implement nothing and edit no file. Work only
|
|
985
|
+
`You are the lead of the swarm for task ${task.id}. One or more workers are blocked; you get one chance to re-brief the unfinished pieces. You implement nothing and edit no file. Work read-only from the repository root you were started in. Do not cd into the lead's worktree (it may have been removed), create no branch, and edit nothing. Read a finished worker's committed files with \`git show <commit>:<path>\` or \`git show <commit>\`.`,
|
|
984
986
|
``,
|
|
985
987
|
`Plan: ${plan.title}`,
|
|
986
988
|
`Goal: ${plan.goal}`,
|
|
@@ -1130,6 +1132,7 @@ function verifyPrompt(task, impl, baseBranch) {
|
|
|
1130
1132
|
`Task ${task.id}: ${task.title}`,
|
|
1131
1133
|
`Spec:`,
|
|
1132
1134
|
task.spec,
|
|
1135
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
|
|
1133
1136
|
``,
|
|
1134
1137
|
`Plan acceptance criteria (the spec must be consistent with these; a line starting with $ is the command that checks the criterion above it and exits 0 when it holds):`,
|
|
1135
1138
|
...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
|
|
@@ -1162,6 +1165,7 @@ function checkPrompt(task, impl, prevCommit, baseBranch, ledger) {
|
|
|
1162
1165
|
`Task ${task.id}: ${task.title}`,
|
|
1163
1166
|
`Spec:`,
|
|
1164
1167
|
task.spec,
|
|
1168
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
|
|
1165
1169
|
``,
|
|
1166
1170
|
`Plan acceptance criteria (the spec must be consistent with these; a line starting with $ is the command that checks the criterion above it and exits 0 when it holds):`,
|
|
1167
1171
|
...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
|
|
@@ -1255,7 +1259,7 @@ function levelAdversaryPrompt(li, sReady, levelBase, confirming) {
|
|
|
1255
1259
|
}
|
|
1256
1260
|
|
|
1257
1261
|
function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
|
|
1258
|
-
|
|
1262
|
+
// Same scope as levelAdversaryPrompt (card flow-fallback-verify-scope): no verify list, the suite already ran at integrate.
|
|
1259
1263
|
return [
|
|
1260
1264
|
`You are the adversarial reviewer for level ${li} of plan "${plan.title}", standing in for codex-review, which could not run (${why}). The size-S tasks below were merged into ${integrationBranch} with one focused check each and no adversary of their own. Your job is to refute them together, in worktree ${INTEGRATION_WORKTREE} (relative to the repository root); the diff is ${levelBase}...${integrationBranch}.`,
|
|
1261
1265
|
``,
|
|
@@ -1263,8 +1267,7 @@ function levelFallbackAdversaryPrompt(li, sReady, levelBase, why, confirming) {
|
|
|
1263
1267
|
`Plan acceptance criteria:`,
|
|
1264
1268
|
...acceptanceEntries(plan.acceptance || []).map(a => `- ${a.text}`),
|
|
1265
1269
|
``,
|
|
1266
|
-
`
|
|
1267
|
-
...verify.map(v => ` $ ${v}`),
|
|
1270
|
+
`Record every command you run, your own probe checks included, in commandsRun with its real exit code and outputTail (the last lines of its output); a blocker's evidence must cite one of them, and either that command failed or the evidence quotes a line of its outputTail, otherwise the loop records it as major.`,
|
|
1268
1271
|
confirming
|
|
1269
1272
|
? `Then confirm the blockers you raised on this level are fixed, name each as fixed or still open, and check the fix for regressions rather than exploring afresh.`
|
|
1270
1273
|
: `Then try to break the changes: inputs the tests do not cover, a spec sentence or acceptance criterion a diff does not meet, a test that was weakened, skipped, or deleted, and behavior outside the owned files. When a suspicion needs a check, write a throwaway one, run it, and remove it before you finish.`,
|
|
@@ -1290,6 +1293,7 @@ function reviewPrompt(task, impl, baseBranch, seat = 1) {
|
|
|
1290
1293
|
`Task ${task.id}: ${task.title}`,
|
|
1291
1294
|
`Spec:`,
|
|
1292
1295
|
task.spec,
|
|
1296
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
|
|
1293
1297
|
`Owned files: ${task.files.join(', ')}`,
|
|
1294
1298
|
`Plan acceptance criteria:`,
|
|
1295
1299
|
...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
|
|
@@ -1332,6 +1336,7 @@ function adversaryPrompt(task, impl, baseBranch, pass, ledger) {
|
|
|
1332
1336
|
`Task ${task.id}: ${task.title}`,
|
|
1333
1337
|
``,
|
|
1334
1338
|
guard(task.spec),
|
|
1339
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []).map(guard),
|
|
1335
1340
|
``,
|
|
1336
1341
|
...(pass > 1 ? confirmationLines(pass, ledger, impl).map(guard) : [`This is the first pass of this task: explore freely and try to refute the change however you can.`]),
|
|
1337
1342
|
``,
|
|
@@ -1386,21 +1391,27 @@ function adversaryUnavailable(adv) {
|
|
|
1386
1391
|
}
|
|
1387
1392
|
|
|
1388
1393
|
function fallbackAdversaryPrompt(task, impl, baseBranch, why, pass, ledger) {
|
|
1389
|
-
|
|
1394
|
+
// Same scope as adversaryPrompt's Codex seat (card flow-fallback-verify-scope): the task's own verify only, not plan.verify.
|
|
1395
|
+
const verify = [task.verify].filter(Boolean)
|
|
1390
1396
|
return [
|
|
1391
1397
|
`You are the adversarial reviewer for task ${task.id}, standing in for codex-review, which could not run (${why}). Your job is to refute the change on branch ${impl.branch} in worktree ${impl.worktreePath}; the diff is ${baseBranch}...${impl.branch}. ${baseBranch} is this branch's base (an integration branch when the task follows an earlier level); files that already exist there are not this task's changes. Do not edit any file.`,
|
|
1392
1398
|
``,
|
|
1393
1399
|
`Task ${task.id}: ${task.title}`,
|
|
1394
1400
|
`Spec:`,
|
|
1395
1401
|
task.spec,
|
|
1402
|
+
...(typeof task.lessons === 'string' && task.lessons ? ['', `Lessons from earlier runs on this repository (memory; each cites where it was learned):`, LESSONS_NOT_SPEC, String(task.lessons).slice(0, 2000)] : []),
|
|
1396
1403
|
`Owned files: ${task.files.join(', ')}`,
|
|
1397
1404
|
`Plan acceptance criteria:`,
|
|
1398
1405
|
...acceptanceEntries(plan.acceptance || []).flatMap(a => a.command ? [`- ${a.text}`, ` $ ${a.command}`] : [`- ${a.text}`]),
|
|
1399
1406
|
...(pass > 1 ? confirmationLines(pass, ledger, impl) : []),
|
|
1400
1407
|
``,
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1408
|
+
...(verify.length
|
|
1409
|
+
? [
|
|
1410
|
+
`Run every one of these commands in the worktree and record each in commandsRun with its real exit code and outputTail (the last lines of its output):`,
|
|
1411
|
+
...verify.map(v => ` $ ${v}`),
|
|
1412
|
+
]
|
|
1413
|
+
: [`Record every command you run, your own probe checks included, in commandsRun with its real exit code and outputTail (the last lines of its output).`]),
|
|
1414
|
+
`A blocker's evidence must name one of ${verify.length ? 'those commands' : 'the commands in commandsRun'}, and either that command failed or the evidence quotes a line of its outputTail; otherwise the loop records it as major.`,
|
|
1404
1415
|
pass > 1
|
|
1405
1416
|
? `Then confirm the open findings above and check the fix delta for regressions rather than exploring afresh.`
|
|
1406
1417
|
: `Then try to break the change: inputs the tests do not cover, a spec sentence or acceptance criterion the diff does not meet, a test that was weakened, skipped, or deleted, and behavior outside the owned files. When a suspicion needs a check, write a throwaway one, run it, and remove it before you finish.`,
|
|
@@ -1421,14 +1432,14 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
|
|
|
1421
1432
|
`They are local branches of this repository (each was made in its own worktree); no fetch is needed.`,
|
|
1422
1433
|
``,
|
|
1423
1434
|
`Steps:`,
|
|
1424
|
-
`1. From the repository root, find or create the integration worktree at
|
|
1435
|
+
`1. From the repository root, find or create the integration worktree at ${INTEGRATION_WORKTREE}:`,
|
|
1425
1436
|
...(firstIntegration
|
|
1426
1437
|
? [
|
|
1427
|
-
` this is the run's first integration; the worktree may be a leftover of an earlier run and is never reused as is: if it exists, git worktree remove --force
|
|
1428
|
-
` In every case create it fresh: git worktree add -B ${integrationBranch}
|
|
1438
|
+
` this is the run's first integration; the worktree may be a leftover of an earlier run and is never reused as is: if it exists, git worktree remove --force ${INTEGRATION_WORKTREE} (then git worktree prune).`,
|
|
1439
|
+
` In every case create it fresh: git worktree add -B ${integrationBranch} ${INTEGRATION_WORKTREE} ${baseBranch}`,
|
|
1429
1440
|
]
|
|
1430
1441
|
: [
|
|
1431
|
-
` if it does not exist: git worktree add -B ${integrationBranch}
|
|
1442
|
+
` if it does not exist: git worktree add -B ${integrationBranch} ${INTEGRATION_WORKTREE} ${baseBranch}`,
|
|
1432
1443
|
` if it exists (a previous level made it): use it as is; it already has ${integrationBranch} checked out.`,
|
|
1433
1444
|
]),
|
|
1434
1445
|
` Do every following git and verify command inside that worktree (git -C or cd into it).`,
|
|
@@ -1438,7 +1449,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
|
|
|
1438
1449
|
...(plan.verify.length
|
|
1439
1450
|
? [
|
|
1440
1451
|
` When .doug/hooks/scripts/verify.mjs exists, run them from the repository root (not inside the worktree) as one command, so a tree already verified is credited instead of re-run:`,
|
|
1441
|
-
` node .doug/hooks/scripts/verify.mjs --json --by integrate --cwd
|
|
1452
|
+
` node .doug/hooks/scripts/verify.mjs --json --by integrate --cwd ${INTEGRATION_WORKTREE} --record-dir . ${worktrees.map(w => `--credit-dir ${shellQuote(w)}`).join(' ')} ${plan.verify.map(v => `--command ${shellQuote(v)}`).join(' ')}`,
|
|
1442
1453
|
` Copy each entry of its JSON results[] into verify as { command, ok, outputTail }, and add credited: "verified-tree" to each when the JSON's credited is not null. When verify.mjs does not exist, run the commands one by one as above.`,
|
|
1443
1454
|
]
|
|
1444
1455
|
: []),
|
|
@@ -1458,7 +1469,7 @@ function integratePrompt(levelIndex, results, baseBranch, isLastLevel, keepWorkt
|
|
|
1458
1469
|
]
|
|
1459
1470
|
: []),
|
|
1460
1471
|
`4. If every merge succeeded, remove the merged task worktrees to leave the repository tidy: git worktree remove --force <path> for each of ${worktrees.join(', ')}. Keep their branches.`,
|
|
1461
|
-
isLastLevel && !keepWorktree ? `5. This is the last level. Also remove the integration worktree (git worktree remove --force
|
|
1472
|
+
isLastLevel && !keepWorktree ? `5. This is the last level. Also remove the integration worktree (git worktree remove --force ${INTEGRATION_WORKTREE}); the branch ${integrationBranch} keeps the result.` : isLastLevel ? `5. This is the last level, but keep the integration worktree: the adversary reviews the merged size-S tasks there, and plan.mjs land reuses and removes it.` : `5. Keep the integration worktree; the next level merges into it.`,
|
|
1462
1473
|
isLastLevel
|
|
1463
1474
|
? `6. ok=true only if all merges succeeded, all verify commands passed, and every acceptance command exited 0. Do not push. Do not check out anything in the main working tree.`
|
|
1464
1475
|
: `6. ok=true only if all merges succeeded and all verify commands passed. Do not push. Do not check out anything in the main working tree.`,
|
|
@@ -1676,7 +1687,7 @@ async function runSwarm(task, baseBranch, launch) {
|
|
|
1676
1687
|
const reason = `worker${round1Blocked.length === 1 ? '' : 's'} blocked: ${round1Blocked.map(w => `${w.n} (${w.id}): ${w.blockedReason}`).join('; ')}`
|
|
1677
1688
|
// Rule 4: a worker still partial after its resume blocks the task exactly as before - no re-brief attempt.
|
|
1678
1689
|
if (round1Blocked.some(w => w.partial)) return blocked(reason, { worktreePath: lead.worktreePath, branch: lead.branch })
|
|
1679
|
-
// Rule 2: one re-brief,
|
|
1690
|
+
// Rule 2: one re-brief, reading from the repository root (no isolation key, no new worktree), same schema as the
|
|
1680
1691
|
// first split.
|
|
1681
1692
|
stages.push('lead-rebrief')
|
|
1682
1693
|
const revised = await launch(leadRebriefPrompt(task, lead, round1, round1Blocked), agentOpts('lead', null, { label: `lead-rebrief:${task.id}`, phase: 'Implement', agentType: 'doug-flow:lead', schema: LEAD_SCHEMA }))
|
|
@@ -1727,7 +1738,8 @@ async function runSwarm(task, baseBranch, launch) {
|
|
|
1727
1738
|
// workers, in the order they were pushed. `workers` itself (review minor 5) still goes to leadMergePrompt as the
|
|
1728
1739
|
// worktree-removal list, blocked ones included, so a blocked worker's worktree is not left an orphan.
|
|
1729
1740
|
const finished = workers.filter(w => !w.blocked)
|
|
1730
|
-
|
|
1741
|
+
// Card swarm-lead-worktree-missing: the lead's own worktree is gone by now (rehearsal swarm-2026-10-09T15-20-40-679Z), so the merge gets a fresh one.
|
|
1742
|
+
const merged = await launch(leadMergePrompt(task, lead, finished, baseBranch, workers), agentOpts('lead', null, { label: `lead-merge:${task.id}`, phase: 'Implement', isolation: 'worktree', agentType: 'doug-flow:lead', schema: IMPLEMENT_SCHEMA }))
|
|
1731
1743
|
if (!merged) return blocked('lead merge returned nothing (spawn failure or no structured output)', { worktreePath: lead.worktreePath, branch: lead.branch })
|
|
1732
1744
|
return { ...merged, branch: merged.branch || lead.branch, worktreePath: merged.worktreePath || lead.worktreePath, swarmStages: stages, workers, splitReason: lead.splitReason, rebrief, checkFailed: workers.filter(w => w.checkFailed).map(w => w.id) }
|
|
1733
1745
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doug-gates",
|
|
3
3
|
"displayName": "Doug Gates",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"description": "Deterministic guardrails for Claude Code: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, a verification gate on Stop and on SubagentStop, a snapshot of the plan and session into the anchor before compaction and re-anchoring after it, and a run trace of subagents and tool calls. Configured by .doug/config.json.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Doug contributors"
|
|
@@ -23,6 +23,28 @@ export function readPlanSummary(dir) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Every active (enforced, not landed) per-card plan under .doug/plans/, as readPlanSummary shapes them.
|
|
27
|
+
export function readPlanSummaries(dir) {
|
|
28
|
+
const out = [];
|
|
29
|
+
try {
|
|
30
|
+
const d = join(dir, ".doug/plans");
|
|
31
|
+
if (!existsSync(d)) return out;
|
|
32
|
+
for (const name of readdirSync(d).filter((f) => f.endsWith(".json")).sort()) {
|
|
33
|
+
try {
|
|
34
|
+
const p = JSON.parse(readFileSync(join(d, name), "utf8"));
|
|
35
|
+
if (!p || typeof p !== "object" || !Array.isArray(p.tasks) || p.landed) continue;
|
|
36
|
+
if (p.status !== "approved" && p.status !== "done") continue;
|
|
37
|
+
out.push({ title: p.title || "(untitled)", status: p.status, card: typeof p.card === "string" ? p.card : null, tasks: p.tasks.map((t) => ({ id: t.id, files: Array.isArray(t.files) ? t.files : [] })) });
|
|
38
|
+
} catch {
|
|
39
|
+
// an unreadable per-card plan is skipped
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
// Short HEAD sha and current branch, tolerant of a non-repo or a git failure (moved from stop-gate.mjs; used by
|
|
27
49
|
// both the Stop gate's own handoff and the PreCompact snapshot).
|
|
28
50
|
export function gitHead(dir) {
|
|
@@ -55,7 +77,7 @@ export function readDecisions(dir) {
|
|
|
55
77
|
// plan/decisions/edited-files lines (card precompact-keeps-handoff, review MAJOR): those grow unbounded with a
|
|
56
78
|
// session's edited-file count, and SessionStart's re-injection caps the whole anchor at 4000 characters, so a few
|
|
57
79
|
// short handoff lines must be near the top to survive that cap on a long session.
|
|
58
|
-
export function snapshotSection({ plan, decisions, state, now, handoff }) {
|
|
80
|
+
export function snapshotSection({ plan, plans, decisions, state, now, handoff }) {
|
|
59
81
|
const lines = [`## Compaction snapshot (doug, ${now.toISOString()})`];
|
|
60
82
|
if (handoff) {
|
|
61
83
|
lines.push("");
|
|
@@ -70,9 +92,12 @@ export function snapshotSection({ plan, decisions, state, now, handoff }) {
|
|
|
70
92
|
if (typeof handoff.contextPct === "number") lines.push(`Context: ${handoff.contextPct}%`);
|
|
71
93
|
}
|
|
72
94
|
lines.push("");
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
for (const
|
|
95
|
+
const shown = plan ? [plan] : plans || [];
|
|
96
|
+
if (shown.length) {
|
|
97
|
+
for (const pl of shown) {
|
|
98
|
+
lines.push(`Plan: ${pl.title} [${pl.status}]${pl.card ? ` (card ${pl.card})` : ""}`);
|
|
99
|
+
for (const t of pl.tasks) lines.push(`- ${t.id} owns ${t.files.length ? t.files.join(", ") : "(no files)"}`);
|
|
100
|
+
}
|
|
76
101
|
} else lines.push("Plan: none in .doug/plan.json");
|
|
77
102
|
lines.push("");
|
|
78
103
|
lines.push(decisions.length ? `Decisions (docs/decisions): ${decisions.join(", ")}` : "Decisions: none under docs/decisions");
|
|
@@ -13,8 +13,8 @@ const PM_INSTALL_VERBS = {
|
|
|
13
13
|
// "|"/"&"/"|&"/newline, and the body of every unquoted-or-double-quoted "$(...)" or backtick substitution
|
|
14
14
|
// really executes, so its pieces are split out too, recursively (research Q1.5; design decided by the lead,
|
|
15
15
|
// point 3). Falls back to today's quote-blind split - checking MORE than a real shell would, never less - when
|
|
16
|
-
// the scanner hits an unterminated quote or substitution (a heredoc body looks the same to it
|
|
17
|
-
//
|
|
16
|
+
// the scanner hits an unterminated quote or substitution (a heredoc body looks the same to it; only the
|
|
17
|
+
// package-manager rule strips heredoc bodies - stripHeredocBodies, card bash-rules-heredoc-body) or throws for any other reason (a hook reader must never throw - CLAUDE.md Gotchas).
|
|
18
18
|
// A bare "&" (backgrounds the command before it, redirection forms excepted) and bash's "|&" (a pipe connector,
|
|
19
19
|
// treated the same as a bare "|") joined this set in review round 5 (third review) - see scanTop's comment.
|
|
20
20
|
const ALL_SEPS = new Set(["&&", "||", ";", "|", "&", "|&", "\n"]);
|
|
@@ -155,8 +155,8 @@ function words(cmd) {
|
|
|
155
155
|
// itself sits inside (a stack, not a boolean: scanParenBody below starts its own body scan fresh in the "top"
|
|
156
156
|
// state). "$((...))" is read as a substitution too (its body is "(...)" verbatim) - over-checking is fine.
|
|
157
157
|
//
|
|
158
|
-
// Known limits, stated as limits, not bugs: a word-initial "#" comment and a heredoc body are not recognised
|
|
159
|
-
// (over-report only, never under-report - research Q1.7); zsh's RC_QUOTES, bash 5.x, and dash's lack of
|
|
158
|
+
// Known limits, stated as limits, not bugs: a word-initial "#" comment and a heredoc body are not recognised by this scanner
|
|
159
|
+
// (the package-manager rule alone strips heredoc bodies first - stripHeredocBodies; over-report only, never under-report - research Q1.7); zsh's RC_QUOTES, bash 5.x, and dash's lack of
|
|
160
160
|
// $'...' are unverified or divergent, and the bash/zsh reading is the one this scanner takes; a "(...)" subshell
|
|
161
161
|
// or "{ ...; }" group is not unwrapped (unchanged from today).
|
|
162
162
|
//
|
|
@@ -726,8 +726,8 @@ function skipPrefixOptions(w, i, argFlags, onArgFlag) {
|
|
|
726
726
|
// exactly as before. For the separate-token form the value is already its own token, so this returns { next }
|
|
727
727
|
// pointing at it and lets matchesShellWords's loop continue from there. For an attached form (whose value can't
|
|
728
728
|
// be pulled back out as its own token) there is no token to point { next } at, so this returns { splice: value,
|
|
729
|
-
// next } instead: the caller
|
|
730
|
-
// loop at
|
|
729
|
+
// next } instead: the caller overwrites the attached token's slot (next - 1) with the single token `value` and resumes its own
|
|
730
|
+
// loop at that slot - a linear overwrite, not a recursive call, so nested attached values (env -Senv -Senv ... sh) cost no
|
|
731
731
|
// call-stack depth no matter how many there are. Otherwise (the value is missing, empty, or itself option-
|
|
732
732
|
// shaped) it isn't the command, so env keeps parsing options from there: for the separate-token form the walk
|
|
733
733
|
// re-examines that same value token as the next option (advancing by 1, not 2, past just the flag), and for an
|
|
@@ -892,10 +892,13 @@ function redirWordSpan(tok) {
|
|
|
892
892
|
// Walks a stage's already-split words from index i, resolving prefixes (env assignments, sudo, env, nice,
|
|
893
893
|
// time, nohup, exec, command, busybox) until it lands on the candidate verb. Split out of matchesShellStage so
|
|
894
894
|
// scanEnvOptions can hand an -S/--split-string value back into this same walk when that value qualifies as
|
|
895
|
-
// env's command. That handoff is a
|
|
896
|
-
// handoff replaces one or more consumed tokens with the single value token, so
|
|
897
|
-
// one token each time, and the loop below runs no deeper than one stack frame no matter how
|
|
898
|
-
// nested (env -Senv -Senv ... sh).
|
|
895
|
+
// env's command. That handoff is an in-place overwrite of one slot of a single copy of `w`, not a recursive call (see the "splice" case below): every
|
|
896
|
+
// handoff replaces one or more consumed tokens with the single value token, so the walk's unread tail strictly
|
|
897
|
+
// shrinks by at least one token each time, and the loop below runs no deeper than one stack frame no matter how
|
|
898
|
+
// many wrappers are nested (env -Senv -Senv ... sh). The handoff is linear (card bash-rules-env-splice-quadratic):
|
|
899
|
+
// the value overwrites the attached token's slot in a single copy of `w` and the walk resumes there, instead of
|
|
900
|
+
// rebuilding the array per handoff (quadratic: 25 s at 80000 nestings). No token cap is applied: a cap would deny
|
|
901
|
+
// commands that are allowed today, and the linear walk removes the timeout risk that a cap would guard.
|
|
899
902
|
// Card bash-rules-prefixed-commands: also shared with commandForms, which needs the resolved word array and
|
|
900
903
|
// verb index (not just a shell/not-shell verdict), so the walk itself no longer decides an outcome - it returns
|
|
901
904
|
// { w, i } once it lands on a candidate verb (i may be w.length when nothing followed the prefixes), or
|
|
@@ -920,7 +923,7 @@ function redirWordSpan(tok) {
|
|
|
920
923
|
// still reads the RAW token, unchanged - a quoted FLAG is out of this card's scope, and dequoting it too
|
|
921
924
|
// would risk changing which flag a cluster or an attached form is read as, for no fixture that needs it.
|
|
922
925
|
// The returned `{w, i}` itself is unaffected by this: `w` is still the array the caller passed in (or
|
|
923
|
-
// resolveVerb's own env
|
|
926
|
+
// resolveVerb's own single copy after an env splice, unrelated to this round), and `i` is just an index into it - the
|
|
924
927
|
// caller (isShellVerb by way of commandForms/matchesShellWords) dequotes `w[i]` itself before comparing it
|
|
925
928
|
// against a shell name, the same way this loop now does for a prefix name.
|
|
926
929
|
//
|
|
@@ -936,6 +939,7 @@ function redirWordSpan(tok) {
|
|
|
936
939
|
// ...) reads the prefix/verb token's text again - everything after it operates on the RAW words that follow,
|
|
937
940
|
// unaffected by which reading recognised the name in front of them.
|
|
938
941
|
function resolveVerb(w, i) {
|
|
942
|
+
const original = w;
|
|
939
943
|
while (i < w.length) {
|
|
940
944
|
const tok = w[i];
|
|
941
945
|
const span = redirWordSpan(tok);
|
|
@@ -970,11 +974,14 @@ function resolveVerb(w, i) {
|
|
|
970
974
|
if (isBase("env")) {
|
|
971
975
|
const result = scanEnvOptions(w, i + 1);
|
|
972
976
|
if ("splice" in result) {
|
|
973
|
-
// An attached -S/--split-string value qualified as env's command:
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
|
|
977
|
-
|
|
977
|
+
// An attached -S/--split-string value qualified as env's command: the value becomes the walk's next word.
|
|
978
|
+
// It overwrites the slot of the attached token just before result.next (copying w once, on the first
|
|
979
|
+
// splice, so the caller's array is never mutated) and the walk resumes AT that slot - the same words,
|
|
980
|
+
// from the same start, the old rebuilt array [value, ...rest] held at its index 0. Each handoff consumes at
|
|
981
|
+
// least one token and costs O(1), so n nested values cost O(n), and no recursion either.
|
|
982
|
+
if (w === original) w = original.slice();
|
|
983
|
+
i = result.next - 1;
|
|
984
|
+
w[i] = result.splice;
|
|
978
985
|
continue;
|
|
979
986
|
}
|
|
980
987
|
i = result.next; // separate-token -S/--split-string value, or no qualifying value: continue the walk
|
|
@@ -1053,7 +1060,7 @@ function pipeStages(group) {
|
|
|
1053
1060
|
// group's stages at "|" or "|&" (pipeStages above) - and, design point 5, recursion into every substitution
|
|
1054
1061
|
// body's own groups too, so a curl|sh chain hidden inside a "$(...)" or backtick is still caught. Because
|
|
1055
1062
|
// scanTop finds a substitution wherever it sits - inside an ordinary quoted argument or inside what looks like
|
|
1056
|
-
// inert text such as a heredoc body (
|
|
1063
|
+
// inert text such as a heredoc body (this check keeps no heredoc state, a known limit) - a "$(... curl
|
|
1057
1064
|
// ... | sh)" written inside either is still denied even where a real shell would just treat it as literal
|
|
1058
1065
|
// text: an over-denial on the safe side, not a miss. Falls back to the old quote-blind group split (unchanged -
|
|
1059
1066
|
// review round 5 did not extend this fallback regex to "&"/"|&", see the coder's report) on an unterminated
|
|
@@ -1335,8 +1342,8 @@ function dequoteScript(word, reading = "bash") {
|
|
|
1335
1342
|
// behind a resolvable prefix (sudo, an env assignment, env, nice, time, nohup, exec, command, busybox) or a
|
|
1336
1343
|
// shell's -c string. Always includes cmd itself. When resolveVerb's walk consumed or rebuilt anything - i > 0
|
|
1337
1344
|
// (an ordinary prefix was skipped) or the returned word array isn't the one passed in (an attached env
|
|
1338
|
-
// -S/--split-string value spliced
|
|
1339
|
-
//
|
|
1345
|
+
// -S/--split-string value was spliced into a single copy of the array, overwriting the attached token's slot;
|
|
1346
|
+
// the walk resumes there, so i stays >= 1 after a splice) - also includes the resolved word array from the verb
|
|
1340
1347
|
// onward. When that verb's basename is a shell, findScriptCandidates (above) locates 0, 1, or 2 candidate -c
|
|
1341
1348
|
// script words; each candidate is dequoted (dequoteScript) - the ONLY word this function dequotes - twice, once
|
|
1342
1349
|
// per shell reading (review round 3: bash and zsh decode an unrecognised ANSI-C escape differently, see
|
|
@@ -1478,6 +1485,184 @@ export function commandForms(cmd, depth = 0) {
|
|
|
1478
1485
|
}
|
|
1479
1486
|
}
|
|
1480
1487
|
|
|
1488
|
+
// Card bash-rules-heredoc-body: the body of a heredoc (<<WORD, <<'WORD', <<"WORD", <<-WORD, up to the terminator
|
|
1489
|
+
// line) is data fed to a command's stdin, not commands, so the package-manager rule must not match prose inside
|
|
1490
|
+
// it (a research note quoting the npm CLI). stripHeredocBodies returns the command with those bodies removed;
|
|
1491
|
+
// evaluateBash feeds it to the package-manager rule ONLY - every other rule still sees the original text
|
|
1492
|
+
// (checks more, never less). Its own small quote-aware walk (not scanTop: a body with an apostrophe would
|
|
1493
|
+
// leave scanTop in an unterminated quote and send it to the quote-blind fallback). A body is kept, i.e. still
|
|
1494
|
+
// checked, when: its terminator line never appears (unterminated); ANY pipeline stage anywhere in the whole
|
|
1495
|
+
// command is a shell or source/"." (a shell reading the heredoc runs it as a script), or any group ends in a
|
|
1496
|
+
// trailing "|" (the receiving stage is on a later line): then nothing is stripped; or the delimiter is unquoted
|
|
1497
|
+
// and the body holds "$(" or a backtick (an unquoted heredoc expands substitutions, which run).
|
|
1498
|
+
// Known limits (accepted, not caught): a heredoc written to a file later run (cat > s.sh <<EOF ... bash s.sh),
|
|
1499
|
+
// xargs ... sh -c, tee >(sh), while read l; do $l; done <<EOF, ssh/docker exec (remote). "<<<" is a
|
|
1500
|
+
// here-string, not a heredoc; an operator inside a quote, a substitution, or a word-initial "#" comment is not
|
|
1501
|
+
// one either. Never throws: any error returns the command unchanged (today's behaviour).
|
|
1502
|
+
function heredocReadsAsScript(stage) {
|
|
1503
|
+
// Compared on the raw stage and on the stage with quotes and backslashes removed ("sh", s\h): conservative.
|
|
1504
|
+
for (const text of [stage, stage.replace(/["'\\]/g, "")]) {
|
|
1505
|
+
if (matchesShellStage(text)) return true;
|
|
1506
|
+
const r = resolveVerb(words(text), 0);
|
|
1507
|
+
const verb = r.w[r.i];
|
|
1508
|
+
if (verb === "source" || verb === ".") return true;
|
|
1509
|
+
}
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Whole-command rule (round 2): ANY pipeline stage anywhere in the command that reads as a shell script consumer,
|
|
1514
|
+
// or any group ending in a trailing "|", keeps every body. A per-operator or per-group relation misses a heredoc
|
|
1515
|
+
// inside a compound (if/for/while/case/subshell/brace group) whose closing keyword is piped to a shell.
|
|
1516
|
+
function commandHasShellConsumer(str) {
|
|
1517
|
+
const cut = cutOnSeparators(str, GROUP_SEPS);
|
|
1518
|
+
const groups = cut ? cut.pieces : str.split(/\s*(?:&&|\|\||;)\s*|\n/).map((s) => s.trim()).filter(Boolean);
|
|
1519
|
+
for (const group of groups) {
|
|
1520
|
+
if (/\|&?\s*$/.test(group)) return true; // the receiving stage is on a later line: unknown, so conservative
|
|
1521
|
+
if (pipeStages(group).some((st) => heredocReadsAsScript(st))) return true;
|
|
1522
|
+
}
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
export function stripHeredocBodies(command) {
|
|
1527
|
+
const str = String(command);
|
|
1528
|
+
try {
|
|
1529
|
+
if (!str.includes("<<")) return str;
|
|
1530
|
+
if (commandHasShellConsumer(str)) return str;
|
|
1531
|
+
const removals = [];
|
|
1532
|
+
let pending = [];
|
|
1533
|
+
let state = "top";
|
|
1534
|
+
let i = 0;
|
|
1535
|
+
while (i < str.length) {
|
|
1536
|
+
const c = str[i];
|
|
1537
|
+
if (state === "squote") { if (c === "'") state = "top"; i += 1; continue; }
|
|
1538
|
+
if (state === "dquote") {
|
|
1539
|
+
if (c === "\\" && isDquoteEscapable(str[i + 1])) { i += 2; continue; }
|
|
1540
|
+
if (c === '"') { state = "top"; i += 1; continue; }
|
|
1541
|
+
if (c === "$" && str[i + 1] === "(") {
|
|
1542
|
+
const close = scanParenBody(str, i + 1);
|
|
1543
|
+
if (close < 0) return str;
|
|
1544
|
+
i = close + 1;
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
if (c === "`") {
|
|
1548
|
+
const close = scanBacktickBody(str, i);
|
|
1549
|
+
if (close < 0) return str;
|
|
1550
|
+
i = close + 1;
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
i += 1;
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
if (state === "ansic") {
|
|
1557
|
+
if (c === "\\") { i += 2; continue; }
|
|
1558
|
+
if (c === "'") state = "top";
|
|
1559
|
+
i += 1;
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
// top
|
|
1563
|
+
if (c === "\\") { i += 2; continue; }
|
|
1564
|
+
if (c === "'") { state = "squote"; i += 1; continue; }
|
|
1565
|
+
if (c === '"') { state = "dquote"; i += 1; continue; }
|
|
1566
|
+
if (c === "$" && str[i + 1] === "'") { state = "ansic"; i += 2; continue; }
|
|
1567
|
+
if (c === "$" && str[i + 1] === '"') { state = "dquote"; i += 2; continue; }
|
|
1568
|
+
if (c === "$" && str[i + 1] === "(") {
|
|
1569
|
+
const close = scanParenBody(str, i + 1);
|
|
1570
|
+
if (close < 0) return str;
|
|
1571
|
+
i = close + 1;
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
if (c === "`") {
|
|
1575
|
+
const close = scanBacktickBody(str, i);
|
|
1576
|
+
if (close < 0) return str;
|
|
1577
|
+
i = close + 1;
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
if (c === "#" && (i === 0 || /[\s;&|()]/.test(str[i - 1]))) {
|
|
1581
|
+
while (i < str.length && str[i] !== "\n") i += 1;
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (c === "<" && str[i + 1] === "<") {
|
|
1585
|
+
if (str[i + 2] === "<") { i += 3; continue; } // here-string
|
|
1586
|
+
let j = i + 2;
|
|
1587
|
+
const dash = str[j] === "-";
|
|
1588
|
+
if (dash) j += 1;
|
|
1589
|
+
while (str[j] === " " || str[j] === "\t") j += 1;
|
|
1590
|
+
let word = "";
|
|
1591
|
+
let quoted = false;
|
|
1592
|
+
let dollarQuoted = false;
|
|
1593
|
+
while (j < str.length && !/[\s;&|<>()]/.test(str[j])) {
|
|
1594
|
+
const ch = str[j];
|
|
1595
|
+
if (ch === "\\") { quoted = true; word += str[j + 1] ?? ""; j += 2; continue; }
|
|
1596
|
+
if (ch === "$" && (str[j + 1] === "'" || str[j + 1] === '"')) { dollarQuoted = true; j += 1; continue; }
|
|
1597
|
+
if (ch === "'" || ch === '"') {
|
|
1598
|
+
const close = str.indexOf(ch, j + 1);
|
|
1599
|
+
if (close < 0) return str;
|
|
1600
|
+
quoted = true;
|
|
1601
|
+
word += str.slice(j + 1, close);
|
|
1602
|
+
j = close + 1;
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
word += ch;
|
|
1606
|
+
j += 1;
|
|
1607
|
+
}
|
|
1608
|
+
if (word) pending.push({ word, dash, quoted, shellFed: dollarQuoted || word[0] === "$" });
|
|
1609
|
+
i = Math.max(j, i + 2);
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
const sep =
|
|
1613
|
+
(c === "&" && str[i + 1] === "&") || (c === "|" && str[i + 1] === "|") ? 2
|
|
1614
|
+
: c === ";" ? 1
|
|
1615
|
+
: c === "&" && str[i + 1] !== ">" && str[i - 1] !== ">" && str[i - 1] !== "<" && str[i - 1] !== "|" ? 1
|
|
1616
|
+
: 0;
|
|
1617
|
+
if (sep) {
|
|
1618
|
+
i += sep;
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
if (c === "\n") {
|
|
1622
|
+
const ops = pending;
|
|
1623
|
+
pending = [];
|
|
1624
|
+
let pos = i + 1;
|
|
1625
|
+
let lastEnd = -1;
|
|
1626
|
+
for (const op of ops) {
|
|
1627
|
+
const bodyStart = pos;
|
|
1628
|
+
let found = -1;
|
|
1629
|
+
while (pos <= str.length) {
|
|
1630
|
+
const nl = str.indexOf("\n", pos);
|
|
1631
|
+
const lineEnd = nl < 0 ? str.length : nl;
|
|
1632
|
+
const line = str.slice(pos, lineEnd);
|
|
1633
|
+
if ((op.dash ? line.replace(/^\t+/, "") : line) === op.word) { found = lineEnd; break; }
|
|
1634
|
+
if (lineEnd >= str.length) break;
|
|
1635
|
+
pos = lineEnd + 1;
|
|
1636
|
+
}
|
|
1637
|
+
if (found < 0) return removals.length ? applyRemovals(str, removals) : str; // unterminated: stop here
|
|
1638
|
+
const body = str.slice(bodyStart, found);
|
|
1639
|
+
const expands = !op.quoted && /\$\(|`/.test(body);
|
|
1640
|
+
if (!op.shellFed && !expands) removals.push([bodyStart, found]);
|
|
1641
|
+
lastEnd = found;
|
|
1642
|
+
pos = found + 1;
|
|
1643
|
+
}
|
|
1644
|
+
if (lastEnd >= 0) { i = lastEnd; continue; }
|
|
1645
|
+
i += 1;
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
i += 1;
|
|
1649
|
+
}
|
|
1650
|
+
return removals.length ? applyRemovals(str, removals) : str;
|
|
1651
|
+
} catch {
|
|
1652
|
+
return str;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function applyRemovals(str, removals) {
|
|
1657
|
+
let out = "";
|
|
1658
|
+
let at = 0;
|
|
1659
|
+
for (const [a, b] of removals) {
|
|
1660
|
+
out += str.slice(at, a);
|
|
1661
|
+
at = b;
|
|
1662
|
+
}
|
|
1663
|
+
return out + str.slice(at);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1481
1666
|
export function checkPackageManager(cmd, expected) {
|
|
1482
1667
|
if (!expected) return null;
|
|
1483
1668
|
const m = /^(npm|pnpm|yarn|bun)\s*(.*)$/.exec(cmd);
|
|
@@ -1499,6 +1684,8 @@ export function evaluateBash(command, { config, currentBranch }) {
|
|
|
1499
1684
|
const pipeReason = checkPipeToShell(command);
|
|
1500
1685
|
if (pipeReason) reasons.push(pipeReason);
|
|
1501
1686
|
}
|
|
1687
|
+
const pmCommand = config.bash.packageManagerGuard ? stripHeredocBodies(command) : command;
|
|
1688
|
+
const pmSeparate = pmCommand !== command;
|
|
1502
1689
|
for (const cmd of splitCommands(command)) {
|
|
1503
1690
|
// Card bash-rules-prefixed-commands: run the five per-command checks (each anchored on the start of the
|
|
1504
1691
|
// string it's given) on every form commandForms resolves for this split command - cmd itself, a prefix
|
|
@@ -1514,9 +1701,19 @@ export function evaluateBash(command, { config, currentBranch }) {
|
|
|
1514
1701
|
checks.push(checkResetHardOnProtected(form, config.bash.denyForcePushTo, currentBranch));
|
|
1515
1702
|
}
|
|
1516
1703
|
if (config.bash.denyDestructive) checks.push(checkDestructive(form));
|
|
1517
|
-
if (config.bash.packageManagerGuard) checks.push(checkPackageManager(form, config.packageManager));
|
|
1704
|
+
if (config.bash.packageManagerGuard && !pmSeparate) checks.push(checkPackageManager(form, config.packageManager));
|
|
1518
1705
|
for (const r of checks) if (r && !reasons.includes(r)) reasons.push(r);
|
|
1519
1706
|
}
|
|
1520
1707
|
}
|
|
1708
|
+
// Card bash-rules-heredoc-body: with a data heredoc body present, the package-manager rule reads the command
|
|
1709
|
+
// with those bodies stripped (stripHeredocBodies); the other rules above already ran on the original text.
|
|
1710
|
+
if (pmSeparate) {
|
|
1711
|
+
for (const cmd of splitCommands(pmCommand)) {
|
|
1712
|
+
for (const form of commandForms(cmd)) {
|
|
1713
|
+
const r = checkPackageManager(form, config.packageManager);
|
|
1714
|
+
if (r && !reasons.includes(r)) reasons.push(r);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1521
1718
|
return reasons;
|
|
1522
1719
|
}
|