shield-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/lib/ocsf-mapper.js +279 -0
- package/.claude/hooks/lib/openshell-detect.js +235 -0
- package/.claude/hooks/lib/policy-compat.js +176 -0
- package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
- package/.claude/hooks/lib/sh-utils.js +340 -0
- package/.claude/hooks/lint-on-save.js +240 -0
- package/.claude/hooks/sh-circuit-breaker.js +113 -0
- package/.claude/hooks/sh-config-guard.js +275 -0
- package/.claude/hooks/sh-data-boundary.js +390 -0
- package/.claude/hooks/sh-dep-audit.js +101 -0
- package/.claude/hooks/sh-elicitation.js +244 -0
- package/.claude/hooks/sh-evidence.js +193 -0
- package/.claude/hooks/sh-gate.js +365 -0
- package/.claude/hooks/sh-injection-guard.js +196 -0
- package/.claude/hooks/sh-instructions.js +212 -0
- package/.claude/hooks/sh-output-control.js +217 -0
- package/.claude/hooks/sh-permission-learn.js +227 -0
- package/.claude/hooks/sh-permission.js +157 -0
- package/.claude/hooks/sh-pipeline.js +623 -0
- package/.claude/hooks/sh-postcompact.js +173 -0
- package/.claude/hooks/sh-precompact.js +114 -0
- package/.claude/hooks/sh-quiet-inject.js +148 -0
- package/.claude/hooks/sh-session-end.js +143 -0
- package/.claude/hooks/sh-session-start.js +277 -0
- package/.claude/hooks/sh-subagent.js +86 -0
- package/.claude/hooks/sh-task-gate.js +141 -0
- package/.claude/hooks/sh-user-prompt.js +185 -0
- package/.claude/hooks/sh-worktree.js +230 -0
- package/.claude/patterns/injection-patterns.json +137 -0
- package/.claude/policies/openshell-default.yaml +65 -0
- package/.claude/rules/binding-governance.md +62 -0
- package/.claude/rules/channel-security.md +90 -0
- package/.claude/rules/coding-principles.md +79 -0
- package/.claude/rules/dev-environment.md +40 -0
- package/.claude/rules/implementation-context.md +132 -0
- package/.claude/rules/language.md +26 -0
- package/.claude/rules/security.md +109 -0
- package/.claude/rules/testing.md +43 -0
- package/LICENSE +21 -0
- package/README.ja.md +176 -0
- package/README.md +174 -0
- package/bin/shield-harness.js +241 -0
- package/package.json +42 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-pipeline.js — STG gate-driven pipeline (Node.js port)
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §8.1
|
|
4
|
+
// Event: TaskCompleted
|
|
5
|
+
// Execution order: after sh-task-gate.js
|
|
6
|
+
// Target response time: < 30000ms
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { execSync } = require("child_process");
|
|
12
|
+
const {
|
|
13
|
+
readHookInput,
|
|
14
|
+
allow,
|
|
15
|
+
deny,
|
|
16
|
+
readSession,
|
|
17
|
+
writeSession,
|
|
18
|
+
readYaml,
|
|
19
|
+
appendEvidence,
|
|
20
|
+
commandExists,
|
|
21
|
+
SH_DIR,
|
|
22
|
+
} = require("./lib/sh-utils");
|
|
23
|
+
|
|
24
|
+
const HOOK_NAME = "sh-pipeline";
|
|
25
|
+
const PIPELINE_CONFIG = path.join(SH_DIR, "config", "pipeline-config.json");
|
|
26
|
+
const BACKLOG_FILE = path.join("tasks", "backlog.yaml");
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load pipeline configuration.
|
|
34
|
+
* @returns {Object|null}
|
|
35
|
+
*/
|
|
36
|
+
function loadPipelineConfig() {
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(PIPELINE_CONFIG)) return null;
|
|
39
|
+
return JSON.parse(fs.readFileSync(PIPELINE_CONFIG, "utf8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get task data from backlog.yaml.
|
|
47
|
+
* @param {string} taskId
|
|
48
|
+
* @returns {{ stage_status: string, intent: string, branch: string, pr_url: string }|null}
|
|
49
|
+
*/
|
|
50
|
+
function getTaskData(taskId) {
|
|
51
|
+
try {
|
|
52
|
+
const backlog = readYaml(BACKLOG_FILE);
|
|
53
|
+
const tasks = backlog.tasks || [];
|
|
54
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
55
|
+
if (!task) return null;
|
|
56
|
+
return {
|
|
57
|
+
stage_status: task.stage_status || null,
|
|
58
|
+
intent: task.intent || "",
|
|
59
|
+
branch: task.branch || "",
|
|
60
|
+
pr_url: task.pr_url || "",
|
|
61
|
+
};
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Execute a trusted git operation in a child process.
|
|
69
|
+
* Uses SH_PIPELINE=1 env to identify trusted operations.
|
|
70
|
+
* @param {string} taskId
|
|
71
|
+
* @param {string} command
|
|
72
|
+
* @returns {string} stdout
|
|
73
|
+
*/
|
|
74
|
+
function executeTrusted(taskId, command) {
|
|
75
|
+
return execSync(command, {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
timeout: 30000,
|
|
78
|
+
env: {
|
|
79
|
+
...process.env,
|
|
80
|
+
SH_PIPELINE: "1",
|
|
81
|
+
SH_TASK_ID: taskId,
|
|
82
|
+
},
|
|
83
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Update backlog.yaml task fields via js-yaml.
|
|
89
|
+
* @param {string} taskId
|
|
90
|
+
* @param {Object} updates - key-value pairs to update
|
|
91
|
+
*/
|
|
92
|
+
function updateBacklog(taskId, updates) {
|
|
93
|
+
let yaml;
|
|
94
|
+
try {
|
|
95
|
+
yaml = require("js-yaml");
|
|
96
|
+
} catch {
|
|
97
|
+
// js-yaml not available — skip backlog update
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const content = fs.readFileSync(BACKLOG_FILE, "utf8");
|
|
103
|
+
const backlog = yaml.load(content);
|
|
104
|
+
const tasks = backlog.tasks || [];
|
|
105
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
106
|
+
if (!task) return;
|
|
107
|
+
|
|
108
|
+
// Apply updates
|
|
109
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
110
|
+
if (key === "stg_history_push") {
|
|
111
|
+
if (!Array.isArray(task.stg_history)) task.stg_history = [];
|
|
112
|
+
task.stg_history.push(value);
|
|
113
|
+
} else {
|
|
114
|
+
task[key] = value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Write back
|
|
119
|
+
const output = yaml.dump(backlog, {
|
|
120
|
+
lineWidth: -1,
|
|
121
|
+
noRefs: true,
|
|
122
|
+
quotingType: '"',
|
|
123
|
+
forceQuotes: false,
|
|
124
|
+
});
|
|
125
|
+
fs.writeFileSync(BACKLOG_FILE, output);
|
|
126
|
+
} catch {
|
|
127
|
+
// Backlog update failure is non-critical for pipeline
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Format commit message from template.
|
|
133
|
+
* @param {string} template
|
|
134
|
+
* @param {string} taskId
|
|
135
|
+
* @param {string} gate
|
|
136
|
+
* @param {string} intent
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
function formatCommitMsg(template, taskId, gate, intent) {
|
|
140
|
+
return template
|
|
141
|
+
.replace("{task_id}", taskId)
|
|
142
|
+
.replace("{gate}", gate)
|
|
143
|
+
.replace("{intent}", intent);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Priority weight for sorting (lower = higher priority)
|
|
147
|
+
const PRIORITY_WEIGHT = { must: 0, should: 1, could: 2 };
|
|
148
|
+
|
|
149
|
+
// Maximum auto-pickups per session (infinite loop guard)
|
|
150
|
+
const MAX_AUTO_PICKUPS = 10;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Find the next eligible task from backlog.yaml.
|
|
154
|
+
* Filters: status === "backlog", all depends_on are "done".
|
|
155
|
+
* Sorts: priority (must > should > could), then due_date ascending.
|
|
156
|
+
* @returns {{ id: string, intent: string, priority: string }|null}
|
|
157
|
+
*/
|
|
158
|
+
function findNextTask() {
|
|
159
|
+
try {
|
|
160
|
+
const backlog = readYaml(BACKLOG_FILE);
|
|
161
|
+
const tasks = backlog.tasks || [];
|
|
162
|
+
|
|
163
|
+
// Build status lookup for dependency checking
|
|
164
|
+
const statusMap = {};
|
|
165
|
+
for (const t of tasks) {
|
|
166
|
+
statusMap[t.id] = t.status;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Filter candidates: backlog status + all deps done
|
|
170
|
+
const candidates = tasks.filter((t) => {
|
|
171
|
+
if (t.status !== "backlog") return false;
|
|
172
|
+
const deps = t.depends_on || [];
|
|
173
|
+
return deps.every((depId) => statusMap[depId] === "done");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (candidates.length === 0) return null;
|
|
177
|
+
|
|
178
|
+
// Sort: priority ascending, then due_date ascending (null last)
|
|
179
|
+
candidates.sort((a, b) => {
|
|
180
|
+
const pa = PRIORITY_WEIGHT[a.priority] ?? 99;
|
|
181
|
+
const pb = PRIORITY_WEIGHT[b.priority] ?? 99;
|
|
182
|
+
if (pa !== pb) return pa - pb;
|
|
183
|
+
|
|
184
|
+
// due_date: null → Infinity for sorting
|
|
185
|
+
const da = a.due_date ? new Date(a.due_date).getTime() : Infinity;
|
|
186
|
+
const db = b.due_date ? new Date(b.due_date).getTime() : Infinity;
|
|
187
|
+
return da - db;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const next = candidates[0];
|
|
191
|
+
return { id: next.id, intent: next.intent, priority: next.priority };
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Bump version in package.json and return the new version string.
|
|
199
|
+
* @param {string} bumpType - "patch" | "minor" | "major"
|
|
200
|
+
* @returns {string|null} New version string, or null if package.json not found.
|
|
201
|
+
*/
|
|
202
|
+
function bumpVersion(bumpType) {
|
|
203
|
+
const pkgPath = "package.json";
|
|
204
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
205
|
+
|
|
206
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
207
|
+
const parts = (pkg.version || "0.0.0").split(".").map(Number);
|
|
208
|
+
|
|
209
|
+
switch (bumpType) {
|
|
210
|
+
case "major":
|
|
211
|
+
parts[0] += 1;
|
|
212
|
+
parts[1] = 0;
|
|
213
|
+
parts[2] = 0;
|
|
214
|
+
break;
|
|
215
|
+
case "minor":
|
|
216
|
+
parts[1] += 1;
|
|
217
|
+
parts[2] = 0;
|
|
218
|
+
break;
|
|
219
|
+
case "patch":
|
|
220
|
+
default:
|
|
221
|
+
parts[2] += 1;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const newVersion = parts.join(".");
|
|
226
|
+
pkg.version = newVersion;
|
|
227
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
228
|
+
return newVersion;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Main
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const input = readHookInput();
|
|
237
|
+
|
|
238
|
+
// Step 0: Load pipeline config
|
|
239
|
+
const config = loadPipelineConfig();
|
|
240
|
+
if (!config || config.auto_commit !== true) {
|
|
241
|
+
allow();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const autoCommit = config.auto_commit === true;
|
|
246
|
+
const autoPush = config.auto_push === true;
|
|
247
|
+
const autoPR = config.auto_pr === true;
|
|
248
|
+
const autoMerge = config.auto_merge === true;
|
|
249
|
+
const autoTag = config.auto_tag === true;
|
|
250
|
+
const versionBump = config.version_bump || "patch";
|
|
251
|
+
const commitFmt =
|
|
252
|
+
config.commit_message_format || "[{task_id}] STG{gate}: {intent}";
|
|
253
|
+
|
|
254
|
+
// Step 1: Get active task
|
|
255
|
+
const session = readSession();
|
|
256
|
+
const taskId = session.active_task_id;
|
|
257
|
+
if (!taskId) {
|
|
258
|
+
allow();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Step 2: Get stage status
|
|
263
|
+
const taskData = getTaskData(taskId);
|
|
264
|
+
if (!taskData) {
|
|
265
|
+
allow();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const stageStatus = taskData.stage_status;
|
|
270
|
+
if (!stageStatus) {
|
|
271
|
+
allow();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Step 3: STG gate progression
|
|
276
|
+
let summary = "";
|
|
277
|
+
const timestamp = new Date().toISOString();
|
|
278
|
+
const today = timestamp.slice(0, 10);
|
|
279
|
+
|
|
280
|
+
switch (stageStatus) {
|
|
281
|
+
case null:
|
|
282
|
+
case "stg0_passed":
|
|
283
|
+
case "stg1_passed": {
|
|
284
|
+
// STG2: Auto commit
|
|
285
|
+
if (!autoCommit) break;
|
|
286
|
+
|
|
287
|
+
const commitMsg = formatCommitMsg(
|
|
288
|
+
commitFmt,
|
|
289
|
+
taskId,
|
|
290
|
+
"2",
|
|
291
|
+
taskData.intent,
|
|
292
|
+
);
|
|
293
|
+
const branchName = `feature/${taskId}`;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
// Ensure feature branch
|
|
297
|
+
try {
|
|
298
|
+
executeTrusted(taskId, `git checkout -b "${branchName}"`);
|
|
299
|
+
} catch {
|
|
300
|
+
try {
|
|
301
|
+
executeTrusted(taskId, `git checkout "${branchName}"`);
|
|
302
|
+
} catch {
|
|
303
|
+
// Already on the branch
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Sync project views + README drift check (ADR-033, ADR-035)
|
|
308
|
+
if (commandExists("pwsh")) {
|
|
309
|
+
try {
|
|
310
|
+
executeTrusted(taskId, "pwsh scripts/sync-project-views.ps1");
|
|
311
|
+
} catch {
|
|
312
|
+
// Non-critical
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
executeTrusted(taskId, "pwsh scripts/sync-readme.ps1");
|
|
316
|
+
} catch {
|
|
317
|
+
// Non-critical — drift is reported but does not block
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Update backlog
|
|
322
|
+
updateBacklog(taskId, {
|
|
323
|
+
stage_status: "stg2_passed",
|
|
324
|
+
start_date: today,
|
|
325
|
+
branch: branchName,
|
|
326
|
+
stg_history_push: { gate: "stg2", passed_at: timestamp },
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Stage and commit
|
|
330
|
+
executeTrusted(taskId, "git add -A");
|
|
331
|
+
try {
|
|
332
|
+
executeTrusted(taskId, `git commit -m "${commitMsg}"`);
|
|
333
|
+
} catch {
|
|
334
|
+
// No changes to commit
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
summary = `STG2 passed: auto-committed [${taskId}]`;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
summary = `STG2 error: ${err.message}`;
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
case "stg2_passed": {
|
|
345
|
+
// STG3: Auto push
|
|
346
|
+
if (!autoPush) break;
|
|
347
|
+
|
|
348
|
+
const branchName = taskData.branch || `feature/${taskId}`;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
executeTrusted(taskId, `git push -u origin "${branchName}"`);
|
|
352
|
+
|
|
353
|
+
updateBacklog(taskId, {
|
|
354
|
+
stage_status: "stg3_passed",
|
|
355
|
+
stg_history_push: { gate: "stg3", passed_at: timestamp },
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Commit backlog update
|
|
359
|
+
executeTrusted(taskId, "git add tasks/backlog.yaml");
|
|
360
|
+
try {
|
|
361
|
+
executeTrusted(
|
|
362
|
+
taskId,
|
|
363
|
+
`git commit -m "[${taskId}] STG3: pushed to remote"`,
|
|
364
|
+
);
|
|
365
|
+
} catch {
|
|
366
|
+
// No changes
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
summary = `STG3 passed: pushed to ${branchName}`;
|
|
370
|
+
} catch (err) {
|
|
371
|
+
summary = `STG3 error: ${err.message}`;
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
case "stg3_passed":
|
|
377
|
+
case "stg4_passed": {
|
|
378
|
+
// STG5: Auto PR
|
|
379
|
+
if (!autoPR) break;
|
|
380
|
+
|
|
381
|
+
if (!commandExists("gh")) {
|
|
382
|
+
summary = `gh CLI not found. Please create PR manually for feature/${taskId}`;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const prUrl = executeTrusted(
|
|
388
|
+
taskId,
|
|
389
|
+
`gh pr create --title "[${taskId}] ${taskData.intent}" --body "Auto-generated by shield-harness pipeline (ADR-031)"`,
|
|
390
|
+
).trim();
|
|
391
|
+
|
|
392
|
+
if (prUrl) {
|
|
393
|
+
updateBacklog(taskId, {
|
|
394
|
+
stage_status: "stg5_passed",
|
|
395
|
+
pr_url: prUrl,
|
|
396
|
+
stg_history_push: { gate: "stg5", passed_at: timestamp },
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
executeTrusted(taskId, "git add tasks/backlog.yaml");
|
|
400
|
+
try {
|
|
401
|
+
executeTrusted(
|
|
402
|
+
taskId,
|
|
403
|
+
`git commit -m "[${taskId}] STG5: PR created"`,
|
|
404
|
+
);
|
|
405
|
+
} catch {
|
|
406
|
+
// No changes
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
summary = `STG5 passed: PR created at ${prUrl}`;
|
|
410
|
+
} else {
|
|
411
|
+
summary = `STG5: PR creation failed for feature/${taskId}`;
|
|
412
|
+
}
|
|
413
|
+
} catch (err) {
|
|
414
|
+
summary = `STG5 error: ${err.message}`;
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
case "stg5_passed": {
|
|
420
|
+
// STG6: Auto merge
|
|
421
|
+
if (!autoMerge) break;
|
|
422
|
+
|
|
423
|
+
if (!commandExists("gh")) {
|
|
424
|
+
summary = "gh CLI not found. Please merge PR manually.";
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const branchName = taskData.branch || `feature/${taskId}`;
|
|
430
|
+
const prNumberStr = executeTrusted(
|
|
431
|
+
taskId,
|
|
432
|
+
`gh pr list --head "${branchName}" --json number -q ".[0].number"`,
|
|
433
|
+
).trim();
|
|
434
|
+
|
|
435
|
+
if (!prNumberStr) {
|
|
436
|
+
summary = `STG5: No PR found for ${branchName}`;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Check CI status
|
|
441
|
+
let failedCount;
|
|
442
|
+
try {
|
|
443
|
+
failedCount = executeTrusted(
|
|
444
|
+
taskId,
|
|
445
|
+
`gh pr checks ${prNumberStr} --json state -q '[.[] | select(.state != "SUCCESS")] | length'`,
|
|
446
|
+
).trim();
|
|
447
|
+
} catch {
|
|
448
|
+
failedCount = "unknown";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (failedCount !== "0") {
|
|
452
|
+
summary = `STG5: CI checks not passed yet (${failedCount} failing). Waiting...`;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Merge
|
|
457
|
+
executeTrusted(taskId, `gh pr merge ${prNumberStr} --squash`);
|
|
458
|
+
executeTrusted(taskId, "git checkout main");
|
|
459
|
+
executeTrusted(taskId, "git pull origin main");
|
|
460
|
+
|
|
461
|
+
// Branch cleanup
|
|
462
|
+
try {
|
|
463
|
+
executeTrusted(taskId, `git branch -d "${branchName}"`);
|
|
464
|
+
} catch {
|
|
465
|
+
// Already deleted
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
executeTrusted(taskId, `git push origin --delete "${branchName}"`);
|
|
469
|
+
} catch {
|
|
470
|
+
// Already deleted remotely
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Update backlog to done
|
|
474
|
+
updateBacklog(taskId, {
|
|
475
|
+
status: "done",
|
|
476
|
+
stage_status: "stg6_passed",
|
|
477
|
+
completed_date: today,
|
|
478
|
+
stg_history_push: { gate: "stg6", passed_at: timestamp },
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Sync views
|
|
482
|
+
if (commandExists("pwsh")) {
|
|
483
|
+
try {
|
|
484
|
+
executeTrusted(taskId, "pwsh scripts/sync-project-views.ps1");
|
|
485
|
+
} catch {
|
|
486
|
+
// Non-critical
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Final commit
|
|
491
|
+
executeTrusted(taskId, "git add -A");
|
|
492
|
+
try {
|
|
493
|
+
executeTrusted(
|
|
494
|
+
taskId,
|
|
495
|
+
`git commit -m "[${taskId}] STG6: merged and completed"`,
|
|
496
|
+
);
|
|
497
|
+
} catch {
|
|
498
|
+
// No changes
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Auto-tag release version (TASK-013)
|
|
502
|
+
if (autoTag) {
|
|
503
|
+
try {
|
|
504
|
+
const newVersion = bumpVersion(versionBump);
|
|
505
|
+
if (newVersion) {
|
|
506
|
+
const tag = `v${newVersion}`;
|
|
507
|
+
executeTrusted(taskId, `git tag "${tag}"`);
|
|
508
|
+
executeTrusted(taskId, `git push origin "${tag}"`);
|
|
509
|
+
summary = `STG6 passed: PR #${prNumberStr} merged, tagged ${tag} [${taskId}]`;
|
|
510
|
+
} else {
|
|
511
|
+
summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}] (tag skipped: no package.json)`;
|
|
512
|
+
}
|
|
513
|
+
} catch (tagErr) {
|
|
514
|
+
summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}] (tag failed: ${tagErr.message})`;
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}]`;
|
|
518
|
+
}
|
|
519
|
+
} catch (err) {
|
|
520
|
+
summary = `STG6 error: ${err.message}`;
|
|
521
|
+
}
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
case "stg6_passed": {
|
|
526
|
+
// Auto-pickup next task (ADR-034)
|
|
527
|
+
if (!config.auto_pickup_next_task) {
|
|
528
|
+
summary = `Task ${taskId} already completed (stg6_passed)`;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Infinite loop guard
|
|
533
|
+
const session = readSession();
|
|
534
|
+
const pickupCount = session.auto_pickup_count || 0;
|
|
535
|
+
if (pickupCount >= MAX_AUTO_PICKUPS) {
|
|
536
|
+
summary = `Auto-pickup limit reached (${MAX_AUTO_PICKUPS}). Stopping.`;
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const nextTask = findNextTask();
|
|
541
|
+
if (!nextTask) {
|
|
542
|
+
summary = "All tasks completed or no eligible tasks found.";
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
// Update next task to in_progress
|
|
548
|
+
const nextBranch = `feature/${nextTask.id}`;
|
|
549
|
+
updateBacklog(nextTask.id, {
|
|
550
|
+
status: "in_progress",
|
|
551
|
+
stage_status: "stg0_passed",
|
|
552
|
+
start_date: today,
|
|
553
|
+
branch: nextBranch,
|
|
554
|
+
stg_history_push: { gate: "stg0", passed_at: timestamp },
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Create branch
|
|
558
|
+
try {
|
|
559
|
+
executeTrusted(nextTask.id, `git checkout -b "${nextBranch}"`);
|
|
560
|
+
} catch {
|
|
561
|
+
try {
|
|
562
|
+
executeTrusted(nextTask.id, `git checkout "${nextBranch}"`);
|
|
563
|
+
} catch {
|
|
564
|
+
// Already on branch
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Update session
|
|
569
|
+
writeSession({
|
|
570
|
+
...session,
|
|
571
|
+
active_task_id: nextTask.id,
|
|
572
|
+
auto_pickup_count: pickupCount + 1,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
summary = `Auto-pickup: starting ${nextTask.id} (${nextTask.intent}) [${nextTask.priority}]`;
|
|
576
|
+
} catch (err) {
|
|
577
|
+
summary = `Auto-pickup error: ${err.message}`;
|
|
578
|
+
}
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
default:
|
|
583
|
+
summary = `Unknown stage: ${stageStatus}`;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Step 4: Output
|
|
588
|
+
if (summary) {
|
|
589
|
+
try {
|
|
590
|
+
appendEvidence({
|
|
591
|
+
hook: HOOK_NAME,
|
|
592
|
+
event: "TaskCompleted",
|
|
593
|
+
decision: "allow",
|
|
594
|
+
task_id: taskId,
|
|
595
|
+
stage: stageStatus,
|
|
596
|
+
summary,
|
|
597
|
+
session_id: input.sessionId,
|
|
598
|
+
});
|
|
599
|
+
} catch {
|
|
600
|
+
// Non-blocking
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
allow(`[${HOOK_NAME}] ${summary}`);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
allow();
|
|
608
|
+
} catch (_err) {
|
|
609
|
+
// Pipeline is operational — fail-open
|
|
610
|
+
allow();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
// Exports (for testing)
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
module.exports = {
|
|
618
|
+
loadPipelineConfig,
|
|
619
|
+
getTaskData,
|
|
620
|
+
executeTrusted,
|
|
621
|
+
updateBacklog,
|
|
622
|
+
formatCommitMsg,
|
|
623
|
+
};
|